Merge branch 'master' into master

This commit is contained in:
Alexander Wang 2025-02-04 05:54:20 -08:00 committed by GitHub
commit 8762d9d784
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1649 changed files with 341027 additions and 110338 deletions

View file

@ -17,7 +17,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: d2chaos

View file

@ -5,3 +5,4 @@ d2renderers/d2latex/polyfills.js
d2renderers/d2latex/setup.js
d2renderers/d2sketch/rough.js
lib/png/generate_png.js
d2js

View file

@ -1,7 +1,7 @@
.POSIX:
.PHONY: all
all: fmt gen lint build test
all: fmt gen js lint build test
.PHONY: fmt
fmt:
@ -21,3 +21,6 @@ test: fmt
.PHONY: race
race: fmt
prefix "$@" ./ci/test.sh --race ./...
.PHONY: js
js: gen
cd d2js/js && prefix "$@" ./make.sh all

View file

@ -238,7 +238,7 @@ let us know and we'll be happy to include it here!
### Community plugins
- **Tree-sitter grammar**: [https://git.pleshevski.ru/pleshevskiy/tree-sitter-d2](https://git.pleshevski.ru/pleshevskiy/tree-sitter-d2)
- **Tree-sitter grammar**: [https://github.com/ravsii/tree-sitter-d2](https://github.com/ravsii/tree-sitter-d2)
- **Emacs major mode**: [https://github.com/andorsk/d2-mode](https://github.com/andorsk/d2-mode)
- **Goldmark extension**: [https://github.com/FurqanSoftware/goldmark-d2](https://github.com/FurqanSoftware/goldmark-d2)
- **Telegram bot**: [https://github.com/meinside/telegram-d2-bot](https://github.com/meinside/telegram-d2-bot)
@ -262,6 +262,7 @@ let us know and we'll be happy to include it here!
- **MkDocs Plugin**: [https://github.com/landmaj/mkdocs-d2-plugin](https://github.com/landmaj/mkdocs-d2-plugin)
- **Remark Plugin**: [https://github.com/mech-a/remark-d2](https://github.com/mech-a/remark-d2)
- **VitePress Plugin**: [https://github.com/BadgerHobbs/vitepress-plugin-d2](https://github.com/BadgerHobbs/vitepress-plugin-d2)
- **Zed extension**: [https://github.com/gabeidx/zed-d2](https://github.com/gabeidx/zed-d2)
### Misc
@ -292,6 +293,12 @@ Do you have or see an open-source project with `.d2` files? Please submit a PR a
this selected list of featured projects using D2.
- [ElasticSearch](https://github.com/elastic/beats/blob/main/libbeat/publisher/queue/proxy/diagrams/broker.d2)
- [Sourcegraph](https://handbook.sourcegraph.com/departments/engineering/managed-services/telemetry-gateway/#dev-architecture-diagram)
- [Temporal](https://github.com/temporalio/temporal/blob/0be2681c994470c7c61ea88e4fcef89bb4024e58/docs/_assets/matching-context.d2)
- [Tauri](https://v2.tauri.app/concept/inter-process-communication/)
- Rust GUI framework (78.5k stars)
- [Intellij](https://github.com/JetBrains/intellij-community/blob/45bcfc17a3f3e0d8548bc69e922d4ca97ac21b2b/platform/settings/docs/topics/overview.md)
- [Coder](https://coder.com/blog/managing-templates-in-coder)
- [UC
Berkeley](https://github.com/ucb-bar/hammer/blob/2b5c04d7b7d9ee3c73575efcd7ee0698bd5bfa88/doc/Hammer-Use/hier.d2)
- [Coronacheck](https://github.com/minvws/nl-covid19-coronacheck-app-ios/blob/e1567e9d1633b3273c537a105bff0e7d3a57ecfe/Diagrams/client-side-datamodel.d2)

28
ci/peek-wasm-size.sh Executable file
View file

@ -0,0 +1,28 @@
#!/bin/bash
OUTPUT_FILE="main.wasm"
SOURCE_PACKAGE="./d2js"
echo "Building WASM file..."
GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o "$OUTPUT_FILE" "$SOURCE_PACKAGE"
if [ $? -eq 0 ]; then
echo "Build successful."
if [ -f "$OUTPUT_FILE" ]; then
FILE_SIZE_BYTES=$(stat -f%z "$OUTPUT_FILE")
FILE_SIZE_MB=$(echo "scale=2; $FILE_SIZE_BYTES / 1024 / 1024" | bc)
echo "File size of $OUTPUT_FILE: $FILE_SIZE_MB MB"
else
echo "File $OUTPUT_FILE not found!"
exit 1
fi
echo "Deleting $OUTPUT_FILE..."
rm "$OUTPUT_FILE"
echo "File deleted."
else
echo "Build failed."
exit 1
fi

View file

@ -10,7 +10,7 @@ cd -- "$(dirname "$0")/../.."
help() {
cat <<EOF
usage: $0 [--rebuild] [--local] [--dry-run] [--run=regex] [--host-only] [--lockfile-force]
usage: $0 [--rebuild] [--dry-run] [--run=regex] [--host-only] [--lockfile-force]
[--install] [--uninstall]
$0 builds D2 release archives into ./ci/release/build/<version>/d2-<VERSION>-<OS>-<ARCH>.tar.gz
@ -24,14 +24,6 @@ Flags:
By default build.sh will avoid rebuilding finished assets if they already exist but if you
changed something and need to force rebuild, use this flag.
--local
By default build.sh uses \$CI_D2_LINUX_AMD64, \$CI_D2_LINUX_ARM64,
\$CI_D2_MACOS_AMD64 and \$CI_D2_MACOS_ARM64 to build the release
archives. It's required for now due to the following issue:
https://github.com/terrastruct/d2/issues/31
With --local, build.sh will cross compile locally. warning: This is only for testing
purposes, do not use in production!
--host-only
Use to build the release archive for the host OS-ARCH only. All logging is done to stderr
so in a script you can read from stdout to get the path to the release archive.
@ -75,10 +67,6 @@ main() {
flag_noarg && shift "$FLAGSHIFT"
REBUILD=1
;;
local)
flag_noarg && shift "$FLAGSHIFT"
LOCAL=1
;;
dry-run)
flag_noarg && shift "$FLAGSHIFT"
DRY_RUN=1
@ -90,7 +78,6 @@ main() {
host-only)
flag_noarg && shift "$FLAGSHIFT"
HOST_ONLY=1
LOCAL=1
;;
version)
flag_nonemptyarg && shift "$FLAGSHIFT"
@ -104,13 +91,11 @@ main() {
flag_noarg && shift "$FLAGSHIFT"
INSTALL=1
HOST_ONLY=1
LOCAL=1
;;
uninstall)
flag_noarg && shift "$FLAGSHIFT"
UNINSTALL=1
HOST_ONLY=1
LOCAL=1
;;
push-docker)
flag_noarg && shift "$FLAGSHIFT"
@ -170,45 +155,8 @@ build() {
return 0
fi
if [ -n "${LOCAL-}" ]; then
build_local
return 0
fi
case $OS in
macos)
case $ARCH in
amd64)
REMOTE_HOST=$CI_D2_MACOS_AMD64 build_remote_macos
;;
arm64)
REMOTE_HOST=$CI_D2_MACOS_ARM64 build_remote_macos
;;
*)
warn "no builder for OS=$OS ARCH=$ARCH, building locally..."
build_local
;;
esac
;;
linux)
case $ARCH in
amd64)
REMOTE_HOST=$CI_D2_LINUX_AMD64 build_remote_linux
;;
arm64)
REMOTE_HOST=$CI_D2_LINUX_ARM64 build_remote_linux
;;
*)
warn "no builder for OS=$OS ARCH=$ARCH, building locally..."
build_local
;;
esac
;;
*)
warn "no builder for OS=$OS, building locally..."
build_local
;;
esac
build_local
return 0
}
build_local() {
@ -221,39 +169,6 @@ build_local() {
sh_c ./ci/release/_build.sh
}
build_remote_macos() {(
sh_c lockfile_ssh "$REMOTE_HOST" .d2-build-lock
sh_c gitsync "$REMOTE_HOST" src/d2
sh_c ssh "$REMOTE_HOST" "COLOR=${COLOR-} \
TERM=${TERM-} \
DRY_RUN=${DRY_RUN-} \
HW_BUILD_DIR=$HW_BUILD_DIR \
VERSION=$VERSION \
OS=$OS \
ARCH=$ARCH \
ARCHIVE=$ARCHIVE \
PATH=\\\"/usr/local/bin:/usr/local/sbin:/opt/homebrew/bin:/opt/homebrew/sbin\\\${PATH+:\\\$PATH}\\\" \
./src/d2/ci/release/_build.sh"
sh_c mkdir -p "$HW_BUILD_DIR"
sh_c rsync --archive --human-readable "$REMOTE_HOST:src/d2/$ARCHIVE" "$ARCHIVE"
)}
build_remote_linux() {(
sh_c lockfile_ssh "$REMOTE_HOST" .d2-build-lock
sh_c gitsync "$REMOTE_HOST" src/d2
sh_c ssh "$REMOTE_HOST" "COLOR=${COLOR-} \
TERM=${TERM-} \
DRY_RUN=${DRY_RUN-} \
HW_BUILD_DIR=$HW_BUILD_DIR \
VERSION=$VERSION \
OS=$OS \
ARCH=$ARCH \
ARCHIVE=$ARCHIVE \
./src/d2/ci/release/build_in_docker.sh"
sh_c mkdir -p "$HW_BUILD_DIR"
sh_c rsync --archive --human-readable "$REMOTE_HOST:src/d2/$ARCHIVE" "$ARCHIVE"
)}
build_docker() {
if [ -n "${LOCAL-}" ]; then
sh_c ./ci/release/docker/build.sh \

View file

@ -1,3 +1,35 @@
Hotfix for 0.6.4 breaking plugins, along with 2 other compiler bugfixes.
#### Features 🚀
- Animations: `style.animated: true` is supported on shapes [#2250](https://github.com/terrastruct/d2/pull/2250)
- Connections now support `link` [#1955](https://github.com/terrastruct/d2/pull/1955)
- Vars: vars in markdown blocks are substituted [#2218](https://github.com/terrastruct/d2/pull/2218)
- Markdown: Github-flavored tables work in `md` blocks [#2221](https://github.com/terrastruct/d2/pull/2221)
- Render: adds box arrowheads [#2227](https://github.com/terrastruct/d2/issues/2227)
- `d2 fmt` now supports a `--check` flag [#2253](https://github.com/terrastruct/d2/pull/2253)
- CLI: PNG output to stdout is supported using `--stdout-format png -` [#2291](https://github.com/terrastruct/d2/pull/2291)
- Globs: `&connected` and `&leaf` filters are implemented [#2299](https://github.com/terrastruct/d2/pull/2299)
- CLI: add --no-xml-tag for direct HTML embedding [#2302](https://github.com/terrastruct/d2/pull/2302)
- CLI: `play` cmd added for opening d2 input in online playground [#2242](https://github.com/terrastruct/d2/pull/2242)
#### Improvements 🧹
- Composition: links pointing to own board are purged [#2203](https://github.com/terrastruct/d2/pull/2203)
- Syntax: reserved keywords must be unquoted [#2231](https://github.com/terrastruct/d2/pull/2231)
- Latex: Backslashes in Latex blocks do not escape [#2232](https://github.com/terrastruct/d2/pull/2232)
- This is a breaking change. Previously Latex blocks required escaping the backslash. So
for older D2 versions, you should remove the excess backslashes.
- Links: non-http url scheme links are supported (e.g. `x.link: vscode://file/`) [#2237](https://github.com/terrastruct/d2/issues/2237)
- Compiler: reserved keywords with missing values error instead of silently doing nothing [#2251](https://github.com/terrastruct/d2/pull/2251)
- Render: SVG outputs conform to stricter HTML standards, e.g. no duplicate ids [#2273](https://github.com/terrastruct/d2/issues/2273)
- Themes: theme names are consistently cased [#2322](https://github.com/terrastruct/d2/pull/2322)
- Nears: constant nears avoid collision with edge routes [#2327](https://github.com/terrastruct/d2/pull/2327)
#### Bugfixes ⛑️
- Imports: fixes using substitutions in `icon` values [#2207](https://github.com/terrastruct/d2/pull/2207)
- Markdown: fixes ampersands in URLs in markdown [#2219](https://github.com/terrastruct/d2/pull/2219)
- Globs: fixes edge case where globs with imported boards would create empty boards [#2247](https://github.com/terrastruct/d2/pull/2247)
- Sequence diagrams: fixes alignment of notes when self messages are above it [#2264](https://github.com/terrastruct/d2/pull/2264)
- Null: fixes `null`ing a connection with absolute syntax [#2318](https://github.com/terrastruct/d2/issues/2318)
- Gradients: works with connection fills [#2326](https://github.com/terrastruct/d2/pull/2326)
- Latex: fixes backslashes doubling on successive parses [#2328](https://github.com/terrastruct/d2/pull/2328)

View file

@ -0,0 +1,23 @@
#### Features 🚀
- Glob inverse filters are implemented (e.g. `*: {!&shape: circle; style.fill: red}` to turn all non-circles red) [#2008](https://github.com/terrastruct/d2/pull/2008)
- Globs can be used in glob filter values, including checking for existence (e.g. `*: {&link: *; style.fill: red}` to turn all objects with a link red) [#2009](https://github.com/terrastruct/d2/pull/2009)
#### Improvements 🧹
- Opacity 0 shapes no longer have a label mask which made any segment of connections going through them lower opacity [#1940](https://github.com/terrastruct/d2/pull/1940)
- Bidirectional connections are now animated in opposite directions rather than one direction [#1939](https://github.com/terrastruct/d2/pull/1939)
#### Bugfixes ⛑️
- Local relative icons are relative to the d2 file instead of CLI invoke path [#1924](https://github.com/terrastruct/d2/pull/1924)
- Custom label positions weren't being read when the width was smaller than the label [#1928](https://github.com/terrastruct/d2/pull/1928)
- Using `shape: circle` for arrowheads no longer removes all arrowheads along path in sketch mode [#1942](https://github.com/terrastruct/d2/pull/1942)
- Globs to null connections work [#1965](https://github.com/terrastruct/d2/pull/1965)
- Edge globs setting styles inherit correctly in child boards [#1967](https://github.com/terrastruct/d2/pull/1967)
- Board links imported with spread imports work [#1972](https://github.com/terrastruct/d2/pull/1972)
- Fix importing a file with nested boards [#1998](https://github.com/terrastruct/d2/pull/1998)
- Fix importing a file with underscores in links [#1999](https://github.com/terrastruct/d2/pull/1999)
- Replace a panic with an error message resulting from invalid `link` usage [#2011](https://github.com/terrastruct/d2/pull/2011)
- Fix globs not applying to scenarios on keys that were applied in earlier scenarios [#2021](https://github.com/terrastruct/d2/pull/2021)
- Fix edge case of invalid SVG from code blocks [#2031](https://github.com/terrastruct/d2/pull/2031)

View file

@ -0,0 +1,38 @@
#### Features 🚀
- Vars: Variable definitions can refer to other variables in the current scope [#2052](https://github.com/terrastruct/d2/pull/2052)
- Composition: Imported boards can use underscores to reference boards beyond its own scope (e.g. to a sibling board at the scope its imported to) [#2075](https://github.com/terrastruct/d2/pull/2075)
- Autoformat: Reserved keywords are formatted to be lowercase [#2098](https://github.com/terrastruct/d2/pull/2098)
- Misc: support for characters in the Latin-1 and geometric shapes unicode range [#2100](https://github.com/terrastruct/d2/pull/2100)
- Imports: can now import from absolute file paths [#2113](https://github.com/terrastruct/d2/pull/2113)
- Render: linear and radial gradients are now available for `fill`, `stroke` and `font-color` [#2120](https://github.com/terrastruct/d2/pull/2120)
#### Improvements 🧹
- Sequence diagram: edge groups account for edge label heights [#2038](https://github.com/terrastruct/d2/pull/2038)
- Sequence diagram: self-referential edges account for edge label heights [#2040](https://github.com/terrastruct/d2/pull/2040)
- Sequence diagram: The spacing between self-referential edges and regular edges is uniform [#2043](https://github.com/terrastruct/d2/pull/2043)
- Compiler: Error on multi-line labels in `sql_table` shapes [#2057](https://github.com/terrastruct/d2/pull/2057)
- Sequence diagram: Image shape actors can use spans and notes [#2056](https://github.com/terrastruct/d2/issues/2056)
- Globs: Filters work with default values (e.g. `&opacity: 1` will capture everything without opacity explicitly set) [#2090](https://github.com/terrastruct/d2/pull/2090)
- Render: connection label fills have a bit of padding and border-radius for better aesthetics [#2094](https://github.com/terrastruct/d2/pull/2094)
- Sequence diagram: the padding between message labels and message endpoints are slightly increased [#2096](https://github.com/terrastruct/d2/pull/2096)
- Render: code syntax highlighter dependency upgrade caused some slight subtle color changes in code snippets [#2119](https://github.com/terrastruct/d2/pull/2119)
#### Bugfixes ⛑️
- Sequence diagram: multi-line edge labels no longer can collide with other elements [#2049](https://github.com/terrastruct/d2/pull/2049)
- Sequence diagram: long self-referential edge labels no longer can collide neighboring actors (or its own) lifeline edges [#2050](https://github.com/terrastruct/d2/pull/2050)
- Sequence diagram: fixes layout when sequence diagrams are in children boards (e.g. a layer) [#1692](https://github.com/terrastruct/d2/issues/1692)
- Globs: An edge case was fixed where globs used in edges were creating nodes when it shouldn't have [#2051](https://github.com/terrastruct/d2/pull/2051)
- Render: Multi-line class labels/headers are rendered correctly [#2057](https://github.com/terrastruct/d2/pull/2057)
- CLI: Watch mode uses correct backlinks (`_` usages) [#2058](https://github.com/terrastruct/d2/pull/2058)
- Vars: Spread variables are inserted in place instead of appending to end of scope [#2062](https://github.com/terrastruct/d2/pull/2062)
- Imports: fix local icon imports from files that are imported [#2066](https://github.com/terrastruct/d2/pull/2066)
- CLI: fixes edge case of watch mode links to nested board that had more nested boards not working [#2070](https://github.com/terrastruct/d2/pull/2070)
- CLI: fixes theme flag not being passed to GIF outputs [#2071](https://github.com/terrastruct/d2/pull/2071)
- CLI: fixes scale flag not being passed to animated SVG outputs [#2071](https://github.com/terrastruct/d2/pull/2071)
- CLI: pptx exports use theme flags correctly [#2099](https://github.com/terrastruct/d2/pull/2099)
- Imports: importing files with url links is fixed [#2105](https://github.com/terrastruct/d2/pull/2105)
- Composition: linking to invalid boards no longer produces an invalid link [#2118](https://github.com/terrastruct/d2/pull/2118)

View file

@ -0,0 +1,17 @@
#### Features 🚀
- Render: SVG files render in non-browser contexts (e.g. Inkscape, LaTeX) [#2147](https://github.com/terrastruct/d2/pull/2147)
#### Improvements 🧹
- Lib: removes a dependency on external slog that was causing troubles with installation [#2137](https://github.com/terrastruct/d2/pull/2137)
- CLI: attempts writing to path atomically, falling back to non-atomic if failed [#2141](https://github.com/terrastruct/d2/pull/2141)
- Export: pptx has "created at" metadata removed, so successive runs yield the same result [#2169](https://github.com/terrastruct/d2/pull/2160)
- Formatter: empty board keywords (e.g. `layers`) are removed [#2178](https://github.com/terrastruct/d2/pull/2178)
- Render: a tooltip or link by itself will not expand width of shape [#2183](https://github.com/terrastruct/d2/pull/2183)
#### Bugfixes ⛑️
- Render: fixes edge case of a 3d shape with outside label being cut off [#2132](https://github.com/terrastruct/d2/pull/2132)
- Composition: labels for boards set with shorthand `x: y` was not applied [#2182](https://github.com/terrastruct/d2/pull/2182)
- Globs: double globs (`**`) were erroring when used with multiple scenario boards [#2195](https://github.com/terrastruct/d2/pull/2195)

View file

@ -5,9 +5,9 @@ ARG TARGETARCH
RUN apt-get update && apt-get install -y ca-certificates curl dumb-init sudo
RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash -s - && \
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash -s - && \
apt-get install -y nodejs
RUN npx playwright@1.31.1 install --with-deps chromium
RUN npx playwright@1.45.3 install --with-deps chromium
RUN adduser --gecos '' --disabled-password debian \
&& echo "debian ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd

View file

@ -8,12 +8,15 @@
.Nm d2
.Op Fl -watch Ar false
.Op Fl -theme Em 0
.Op Fl -salt Ar string
.Ar file.d2
.Op Ar file.svg | file.png
.Nm d2
.Ar layout Op Ar name
.Nm d2
.Ar fmt Ar file.d2 ...
.Nm d2
.Ar play Ar file.d2
.Sh DESCRIPTION
.Nm
compiles and renders
@ -96,7 +99,7 @@ Path to .ttf file to use for the bold font. If none provided, Source Sans Pro Bo
Pixels padded around the rendered diagram
.Ns .
.It Fl -animate-interval Ar 0
If given, multiple boards are packaged as 1 SVG which transitions through each board at the interval (in milliseconds). Can only be used with SVG exports
If given, multiple boards are packaged as 1 SVG which transitions through each board at the interval (in milliseconds). Can only be used with SVG and GIF exports
.Ns .
.It Fl -browser Ar true
Browser executable that watch opens. Setting to 0 opens no browser
@ -125,12 +128,24 @@ In watch mode, images used in icons are cached for subsequent compilations. This
.It Fl -timeout Ar 120
The maximum number of seconds that D2 runs for before timing out and exiting. When rendering a large diagram, it is recommended to increase this value
.Ns .
.It Fl -check Ar false
Check that the specified files are formatted correctly
.Ns .
.It Fl -salt Ar string
Add a salt value to ensure the output uses unique IDs. This is useful when generating multiple identical diagrams to be included in the same HTML doc, so that duplicate id's do not cause invalid HTML. The salt value is a string that will be appended to IDs in the output.
.Ns .
.It Fl h , -help
Print usage information and exit
.Ns .
.It Fl v , -version
Print version information and exit
.Ns .
.It Fl -stdout-format Ar string
Set the output format when writing to stdout. Supported formats are: png, svg. Only used when output is set to stdout (-)
.Ns .
.It Fl -no-xml-tag Ar false
Omit XML tag (<?xml ...?>) from output SVG files. Useful when generating SVGs for direct HTML embedding
.Ns .
.El
.Sh SUBCOMMANDS
.Bl -tag -width Fl
@ -145,7 +160,60 @@ Lists available themes
.Ns .
.It Ar fmt Ar file.d2 ...
Format all passed files
.Ns .
.It Ar play Ar file.d2
Opens the file in playground, an online web viewer (https://play.d2lang.com)
.El
.Sh ENVIRONMENT VARIABLES
Many flags can also be set with environment variables.
.Bl -tag -width Ds
.It Ev Sy D2_WATCH
See -w[atch] flag.
.It Ev Sy D2_LAYOUT
See -l[ayout] flag.
.It Ev Sy D2_THEME
See -t[heme] flag.
.It Ev Sy D2_DARK_THEME
See --dark-theme flag.
.It Ev Sy D2_PAD
See --pad flag.
.It Ev Sy D2_CENTER
See --center flag.
.It Ev Sy D2_SKETCH
See -s[ketch] flag.
.It Ev Sy D2_BUNDLE
See -b[undle] flag.
.It Ev Sy D2_FORCE_APPENDIX
See --force-appendix flag.
.It Ev Sy D2_FONT_REGULAR
See --font-regular flag.
.It Ev Sy D2_FONT_ITALIC
See --font-italic flag.
.It Ev Sy D2_FONT_BOLD
See --font-bold flag.
.It Ev Sy D2_FONT_SEMIBOLD
See --font-semibold flag.
.It Ev Sy D2_ANIMATE_INTERVAL
See --animate-interval flag.
.It Ev Sy D2_TIMEOUT
See --timeout flag.
.It Ev Sy D2_CHECK
See --check flag.
.El
.Bl -tag -width Ds
.It Ev Sy DEBUG
See -d[ebug] flag.
.It Ev Sy IMG_CACHE
See --img-cache flag.
.It Ev Sy HOST
See -h[ost] flag.
.It Ev Sy PORT
See -p[ort] flag.
.It Ev Sy BROWSER
See --browser flag.
.It Ev Sy D2_STDOUT_FORMAT
See --stdout-format flag.
.It Ev Sy D2_NO_XML_TAG
See --no-xml-tag flag.
.El
.Sh SEE ALSO
.Xr d2plugin-tala 1

2
ci/sub

@ -1 +1 @@
Subproject commit 7a2914b504ed0dfca6d2dcd923b660052217cccb
Subproject commit 2594100ac939644f134e24edac7c9bcd569b99f6

View file

@ -49,8 +49,7 @@ type Node interface {
// GetRange returns the range a node occupies in its file.
GetRange() Range
// TODO: add Children() for walking AST
// Children() []Node
Children() []Node
}
var _ Node = &Comment{}
@ -167,7 +166,8 @@ func (r Range) Before(r2 Range) bool {
type Position struct {
Line int
Column int
Byte int
// -1 is used as sentinel that a constructed position is missing byte offset (for LSP usage)
Byte int
}
var _ fmt.Stringer = Position{}
@ -277,7 +277,13 @@ func (p Position) SubtractString(s string, byUTF16 bool) Position {
}
func (p Position) Before(p2 Position) bool {
return p.Byte < p2.Byte
if p.Byte != p2.Byte && p.Byte != -1 && p2.Byte != -1 {
return p.Byte < p2.Byte
}
if p.Line != p2.Line {
return p.Line < p2.Line
}
return p.Column < p2.Column
}
// MapNode is implemented by nodes that may be children of Maps.
@ -332,6 +338,7 @@ type String interface {
SetString(string)
Copy() String
_string()
IsUnquoted() bool
}
var _ String = &UnquotedString{}
@ -432,6 +439,139 @@ func (s *DoubleQuotedString) scalar() {}
func (s *SingleQuotedString) scalar() {}
func (s *BlockString) scalar() {}
func (c *Comment) Children() []Node { return nil }
func (c *BlockComment) Children() []Node { return nil }
func (n *Null) Children() []Node { return nil }
func (b *Boolean) Children() []Node { return nil }
func (n *Number) Children() []Node { return nil }
func (s *SingleQuotedString) Children() []Node { return nil }
func (s *BlockString) Children() []Node { return nil }
func (ei *EdgeIndex) Children() []Node { return nil }
func (s *UnquotedString) Children() []Node {
var children []Node
for _, box := range s.Value {
if box.Substitution != nil {
children = append(children, box.Substitution)
}
}
return children
}
func (s *DoubleQuotedString) Children() []Node {
var children []Node
for _, box := range s.Value {
if box.Substitution != nil {
children = append(children, box.Substitution)
}
}
return children
}
func (s *Substitution) Children() []Node {
var children []Node
for _, sb := range s.Path {
if sb != nil {
if child := sb.Unbox(); child != nil {
children = append(children, child)
}
}
}
return children
}
func (i *Import) Children() []Node {
var children []Node
for _, sb := range i.Path {
if sb != nil {
if child := sb.Unbox(); child != nil {
children = append(children, child)
}
}
}
return children
}
func (a *Array) Children() []Node {
var children []Node
for _, box := range a.Nodes {
if child := box.Unbox(); child != nil {
children = append(children, child)
}
}
return children
}
func (m *Map) Children() []Node {
var children []Node
for _, box := range m.Nodes {
if child := box.Unbox(); child != nil {
children = append(children, child)
}
}
return children
}
func (k *Key) Children() []Node {
var children []Node
if k.Key != nil {
children = append(children, k.Key)
}
for _, edge := range k.Edges {
if edge != nil {
children = append(children, edge)
}
}
if k.EdgeIndex != nil {
children = append(children, k.EdgeIndex)
}
if k.EdgeKey != nil {
children = append(children, k.EdgeKey)
}
if scalar := k.Primary.Unbox(); scalar != nil {
children = append(children, scalar)
}
if value := k.Value.Unbox(); value != nil {
children = append(children, value)
}
return children
}
func (kp *KeyPath) Children() []Node {
var children []Node
for _, sb := range kp.Path {
if sb != nil {
if child := sb.Unbox(); child != nil {
children = append(children, child)
}
}
}
return children
}
func (e *Edge) Children() []Node {
var children []Node
if e.Src != nil {
children = append(children, e.Src)
}
if e.Dst != nil {
children = append(children, e.Dst)
}
return children
}
func Walk(node Node, fn func(Node) bool) {
if node == nil {
return
}
if !fn(node) {
return
}
for _, child := range node.Children() {
Walk(child, fn)
}
}
// TODO: mistake, move into parse.go
func (n *Null) ScalarString() string { return "" }
func (b *Boolean) ScalarString() string { return strconv.FormatBool(b.Value) }
@ -472,6 +612,11 @@ func (s *DoubleQuotedString) _string() {}
func (s *SingleQuotedString) _string() {}
func (s *BlockString) _string() {}
func (s *UnquotedString) IsUnquoted() bool { return true }
func (s *DoubleQuotedString) IsUnquoted() bool { return false }
func (s *SingleQuotedString) IsUnquoted() bool { return false }
func (s *BlockString) IsUnquoted() bool { return false }
type Comment struct {
Range Range `json:"range"`
Value string `json:"value"`
@ -660,6 +805,9 @@ func (mk1 *Key) D2OracleEquals(mk2 *Key) bool {
if mk1.Ampersand != mk2.Ampersand {
return false
}
if mk1.NotAmpersand != mk2.NotAmpersand {
return false
}
if (mk1.Key == nil) != (mk2.Key == nil) {
return false
}
@ -739,6 +887,9 @@ func (mk1 *Key) Equals(mk2 *Key) bool {
if mk1.Ampersand != mk2.Ampersand {
return false
}
if mk1.NotAmpersand != mk2.NotAmpersand {
return false
}
if (mk1.Key == nil) != (mk2.Key == nil) {
return false
}
@ -862,10 +1013,22 @@ func (mk *Key) HasTripleGlob() bool {
return true
}
}
if mk.EdgeIndex != nil && mk.EdgeIndex.Glob {
if mk.EdgeKey.HasTripleGlob() {
return true
}
if mk.EdgeKey.HasTripleGlob() {
return false
}
func (mk *Key) HasMultiGlob() bool {
if mk.Key.HasMultiGlob() {
return true
}
for _, e := range mk.Edges {
if e.Src.HasMultiGlob() || e.Dst.HasMultiGlob() {
return true
}
}
if mk.EdgeKey.HasMultiGlob() {
return true
}
return false
@ -902,7 +1065,22 @@ func MakeKeyPath(a []string) *KeyPath {
return kp
}
func (kp *KeyPath) IDA() (ida []string) {
func MakeKeyPathString(a []String) *KeyPath {
kp := &KeyPath{}
for _, el := range a {
kp.Path = append(kp.Path, MakeValueBox(RawString(el.ScalarString(), true)).StringBox())
}
return kp
}
func (kp *KeyPath) IDA() (ida []String) {
for _, el := range kp.Path {
ida = append(ida, el.Unbox())
}
return ida
}
func (kp *KeyPath) StringIDA() (ida []string) {
for _, el := range kp.Path {
ida = append(ida, el.Unbox().ScalarString())
}
@ -1415,9 +1593,9 @@ func (s *Substitution) IDA() (ida []string) {
return ida
}
func (i *Import) IDA() (ida []string) {
func (i *Import) IDA() (ida []String) {
for _, el := range i.Path[1:] {
ida = append(ida, el.Unbox().ScalarString())
ida = append(ida, el.Unbox())
}
return ida
}
@ -1428,3 +1606,7 @@ func (i *Import) PathWithPre() string {
}
return path.Join(i.Pre, i.Path[0].Unbox().ScalarString())
}
func (i *Import) Dir() string {
return path.Dir(i.PathWithPre())
}

200
d2ast/keywords.go Normal file
View file

@ -0,0 +1,200 @@
package d2ast
import "oss.terrastruct.com/d2/lib/label"
// All reserved keywords. See init below.
var ReservedKeywords map[string]struct{}
// Non Style/Holder keywords.
var SimpleReservedKeywords = map[string]struct{}{
"label": {},
"shape": {},
"icon": {},
"constraint": {},
"tooltip": {},
"link": {},
"near": {},
"width": {},
"height": {},
"direction": {},
"top": {},
"left": {},
"grid-rows": {},
"grid-columns": {},
"grid-gap": {},
"vertical-gap": {},
"horizontal-gap": {},
"class": {},
"vars": {},
}
// ReservedKeywordHolders are reserved keywords that are meaningless on its own and must hold composites
var ReservedKeywordHolders = map[string]struct{}{
"style": {},
}
// CompositeReservedKeywords are reserved keywords that can hold composites
var CompositeReservedKeywords = map[string]struct{}{
"source-arrowhead": {},
"target-arrowhead": {},
"classes": {},
"constraint": {},
"label": {},
"icon": {},
}
// StyleKeywords are reserved keywords which cannot exist outside of the "style" keyword
var StyleKeywords = map[string]struct{}{
"opacity": {},
"stroke": {},
"fill": {},
"fill-pattern": {},
"stroke-width": {},
"stroke-dash": {},
"border-radius": {},
// Only for text
"font": {},
"font-size": {},
"font-color": {},
"bold": {},
"italic": {},
"underline": {},
"text-transform": {},
// Only for shapes
"shadow": {},
"multiple": {},
"double-border": {},
// Only for squares
"3d": {},
// Only for edges
"animated": {},
"filled": {},
}
// TODO maybe autofmt should allow other values, and transform them to conform
// e.g. left-center becomes center-left
var NearConstantsArray = []string{
"top-left",
"top-center",
"top-right",
"center-left",
"center-right",
"bottom-left",
"bottom-center",
"bottom-right",
}
var NearConstants map[string]struct{}
// LabelPositionsArray are the values that labels and icons can set `near` to
var LabelPositionsArray = []string{
"top-left",
"top-center",
"top-right",
"center-left",
"center-center",
"center-right",
"bottom-left",
"bottom-center",
"bottom-right",
"outside-top-left",
"outside-top-center",
"outside-top-right",
"outside-left-top",
"outside-left-center",
"outside-left-bottom",
"outside-right-top",
"outside-right-center",
"outside-right-bottom",
"outside-bottom-left",
"outside-bottom-center",
"outside-bottom-right",
}
var LabelPositions map[string]struct{}
var LabelPositionsMapping = map[string]label.Position{
"top-left": label.InsideTopLeft,
"top-center": label.InsideTopCenter,
"top-right": label.InsideTopRight,
"center-left": label.InsideMiddleLeft,
"center-center": label.InsideMiddleCenter,
"center-right": label.InsideMiddleRight,
"bottom-left": label.InsideBottomLeft,
"bottom-center": label.InsideBottomCenter,
"bottom-right": label.InsideBottomRight,
"outside-top-left": label.OutsideTopLeft,
"outside-top-center": label.OutsideTopCenter,
"outside-top-right": label.OutsideTopRight,
"outside-left-top": label.OutsideLeftTop,
"outside-left-center": label.OutsideLeftMiddle,
"outside-left-bottom": label.OutsideLeftBottom,
"outside-right-top": label.OutsideRightTop,
"outside-right-center": label.OutsideRightMiddle,
"outside-right-bottom": label.OutsideRightBottom,
"outside-bottom-left": label.OutsideBottomLeft,
"outside-bottom-center": label.OutsideBottomCenter,
"outside-bottom-right": label.OutsideBottomRight,
}
var FillPatterns = []string{
"none",
"dots",
"lines",
"grain",
"paper",
}
var TextTransforms = []string{"none", "uppercase", "lowercase", "capitalize"}
// BoardKeywords contains the keywords that create new boards.
var BoardKeywords = map[string]struct{}{
"layers": {},
"scenarios": {},
"steps": {},
}
func init() {
ReservedKeywords = make(map[string]struct{})
for k, v := range SimpleReservedKeywords {
ReservedKeywords[k] = v
}
for k, v := range StyleKeywords {
ReservedKeywords[k] = v
}
for k, v := range ReservedKeywordHolders {
CompositeReservedKeywords[k] = v
}
for k, v := range BoardKeywords {
CompositeReservedKeywords[k] = v
}
for k, v := range CompositeReservedKeywords {
ReservedKeywords[k] = v
}
NearConstants = make(map[string]struct{}, len(NearConstantsArray))
for _, k := range NearConstantsArray {
NearConstants[k] = struct{}{}
}
LabelPositions = make(map[string]struct{}, len(LabelPositionsArray))
for _, k := range LabelPositionsArray {
LabelPositions[k] = struct{}{}
}
}

View file

@ -269,7 +269,7 @@ func (gs *dslGenState) randStr(n int, inKey bool) string {
func (gs *dslGenState) randShape() string {
for {
s := shapes[gs.rand.Intn(len(shapes))]
if s != d2target.ShapeImage {
if s != d2target.ShapeImage && s != d2target.ShapeText {
return s
}
}

View file

@ -115,7 +115,7 @@ func test(t *testing.T, textPath, text string) {
}
}()
ctx := log.WithTB(context.Background(), t, nil)
ctx := log.WithTB(context.Background(), t)
ruler, err := textmeasure.NewRuler()
assert.Nil(t, err)

View file

@ -1,7 +1,9 @@
package d2cli
import (
"fmt"
"path/filepath"
"strings"
)
type exportExtension string
@ -14,6 +16,24 @@ const SVG exportExtension = ".svg"
var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF}
var STDOUT_FORMAT_MAP = map[string]exportExtension{
"png": PNG,
"svg": SVG,
}
var SUPPORTED_STDOUT_FORMATS = []string{"png", "svg"}
func getOutputFormat(stdoutFormatFlag *string, outputPath string) (exportExtension, error) {
if stdoutFormatFlag != nil && *stdoutFormatFlag != "" {
format := strings.ToLower(*stdoutFormatFlag)
if ext, ok := STDOUT_FORMAT_MAP[format]; ok {
return ext, nil
}
return "", fmt.Errorf("%s is not a supported format. Supported formats are: %s", *stdoutFormatFlag, SUPPORTED_STDOUT_FORMATS)
}
return getExportExtension(outputPath), nil
}
func getExportExtension(outputPath string) exportExtension {
ext := filepath.Ext(outputPath)
for _, kext := range SUPPORTED_EXTENSIONS {

View file

@ -8,6 +8,7 @@ import (
func TestOutputFormat(t *testing.T) {
type testCase struct {
stdoutFormatFlag string
outputPath string
extension exportExtension
supportsDarkTheme bool
@ -41,6 +42,15 @@ func TestOutputFormat(t *testing.T) {
requiresAnimationInterval: false,
requiresPngRender: false,
},
{
stdoutFormatFlag: "png",
outputPath: "-",
extension: PNG,
supportsDarkTheme: false,
supportsAnimation: false,
requiresAnimationInterval: false,
requiresPngRender: true,
},
{
outputPath: "/out.png",
extension: PNG,
@ -78,7 +88,8 @@ func TestOutputFormat(t *testing.T) {
for _, tc := range testCases {
tc := tc
t.Run(tc.outputPath, func(t *testing.T) {
extension := getExportExtension(tc.outputPath)
extension, err := getOutputFormat(&tc.stdoutFormatFlag, tc.outputPath)
assert.NoError(t, err)
assert.Equal(t, tc.extension, extension)
assert.Equal(t, tc.supportsAnimation, extension.supportsAnimation())
assert.Equal(t, tc.supportsDarkTheme, extension.supportsDarkTheme())

View file

@ -12,9 +12,10 @@ import (
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/lib/log"
)
func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
func fmtCmd(ctx context.Context, ms *xmain.State, check bool) (err error) {
defer xdefer.Errorf(&err, "failed to fmt")
ms.Opts = xmain.NewOpts(ms.Env, ms.Opts.Flags.Args()[1:])
@ -22,6 +23,8 @@ func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
return xmain.UsageErrorf("fmt must be passed at least one file to be formatted")
}
unformattedCount := 0
for _, inputPath := range ms.Opts.Args {
if inputPath != "-" {
inputPath = ms.AbsPath(inputPath)
@ -43,10 +46,25 @@ func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
output := []byte(d2format.Format(m))
if !bytes.Equal(output, input) {
if err := ms.WritePath(inputPath, output); err != nil {
return err
if check {
unformattedCount += 1
log.Warn(ctx, inputPath)
} else {
if err := ms.WritePath(inputPath, output); err != nil {
return err
}
}
}
}
if unformattedCount > 0 {
pluralFiles := "file"
if unformattedCount > 1 {
pluralFiles = "files"
}
return xmain.ExitErrorf(1, "found %d unformatted %s. Run d2 fmt to fix.", unformattedCount, pluralFiles)
}
return nil
}

View file

@ -22,6 +22,7 @@ Usage:
%[1]s [--watch=false] [--theme=0] file.d2 [file.svg | file.png]
%[1]s layout [name]
%[1]s fmt file.d2 ...
%[1]s play [--theme=0] [--sketch] file.d2
%[1]s compiles and renders file.d2 to file.svg | file.png
It defaults to file.svg if an output path is not provided.
@ -38,6 +39,7 @@ Subcommands:
%[1]s layout [name] - Display long help for a particular layout engine, including its configuration options
%[1]s themes - Lists available themes
%[1]s fmt file.d2 ... - Format passed files
%[1]s play file.d2 - Opens the file in playground, an online web viewer (https://play.d2lang.com)
See more docs and the source code at https://oss.terrastruct.com/d2.
Hosted icons at https://icons.terrastruct.com.

View file

@ -5,8 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"os/exec"
"os/user"
@ -36,7 +36,7 @@ import (
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/background"
"oss.terrastruct.com/d2/lib/imgbundler"
ctxlog "oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/pdf"
"oss.terrastruct.com/d2/lib/png"
"oss.terrastruct.com/d2/lib/pptx"
@ -45,15 +45,10 @@ import (
timelib "oss.terrastruct.com/d2/lib/time"
"oss.terrastruct.com/d2/lib/version"
"oss.terrastruct.com/d2/lib/xgif"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
)
func Run(ctx context.Context, ms *xmain.State) (err error) {
// :(
ctx = DiscardSlog(ctx)
ctx = log.WithDefault(ctx)
// These should be kept up-to-date with the d2 man page
watchFlag, err := ms.Opts.Bool("D2_WATCH", "watch", "w", false, "watch for changes to input and live reload. Use $HOST and $PORT to specify the listening address.\n(default localhost:0, which is will open on a randomly available local port).")
if err != nil {
@ -108,6 +103,11 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
return err
}
stdoutFormatFlag := ms.Opts.String("", "stdout-format", "", "", "output format when writing to stdout (svg, png). Usage: d2 input.d2 --stdout-format png - > output.png")
if err != nil {
return err
}
browserFlag := ms.Opts.String("BROWSER", "browser", "", "", "browser executable that watch opens. Setting to 0 opens no browser.")
centerFlag, err := ms.Opts.Bool("D2_CENTER", "center", "c", false, "center the SVG in the containing viewbox, such as your browser screen")
if err != nil {
@ -124,6 +124,18 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
fontBoldFlag := ms.Opts.String("D2_FONT_BOLD", "font-bold", "", "", "path to .ttf file to use for the bold font. If none provided, Source Sans Pro Bold is used.")
fontSemiboldFlag := ms.Opts.String("D2_FONT_SEMIBOLD", "font-semibold", "", "", "path to .ttf file to use for the semibold font. If none provided, Source Sans Pro Semibold is used.")
checkFlag, err := ms.Opts.Bool("D2_CHECK", "check", "", false, "check that the specified files are formatted correctly.")
if err != nil {
return err
}
noXMLTagFlag, err := ms.Opts.Bool("D2_NO_XML_TAG", "no-xml-tag", "", false, "omit XML tag (<?xml ...?>) from output SVG files. Useful when generating SVGs for direct HTML embedding")
if err != nil {
return err
}
saltFlag := ms.Opts.String("", "salt", "", "", "Add a salt value to ensure the output uses unique IDs. This is useful when generating multiple identical diagrams to be included in the same HTML doc, so that duplicate IDs do not cause invalid HTML. The salt value is a string that will be appended to IDs in the output.")
plugins, err := d2plugin.ListPlugins(ctx)
if err != nil {
return err
@ -158,7 +170,9 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
themesCmd(ctx, ms)
return nil
case "fmt":
return fmtCmd(ctx, ms)
return fmtCmd(ctx, ms, *checkFlag)
case "play":
return playCmd(ctx, ms)
case "version":
if len(ms.Opts.Flags.Args()) > 1 {
return xmain.UsageErrorf("version subcommand accepts no arguments")
@ -169,6 +183,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}
if *debugFlag {
ctx = log.Leveled(ctx, slog.LevelDebug)
ms.Env.Setenv("DEBUG", "1")
}
if *imgCacheFlag {
@ -217,7 +232,12 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if filepath.Ext(outputPath) == ".ppt" {
return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?")
}
outputFormat := getExportExtension(outputPath)
outputFormat, err := getOutputFormat(stdoutFormatFlag, outputPath)
if err != nil {
return xmain.UsageErrorf("%v", err)
}
if outputPath != "-" {
outputPath = ms.AbsPath(outputPath)
if *animateIntervalFlag > 0 && !outputFormat.supportsAnimation() {
@ -307,6 +327,8 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
ThemeID: themeFlag,
DarkThemeID: darkThemeFlag,
Scale: scale,
NoXMLTag: noXMLTagFlag,
Salt: saltFlag,
}
if *watchFlag {
@ -329,6 +351,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
forceAppendix: *forceAppendixFlag,
pw: pw,
fontFamily: fontFamily,
outputFormat: outputFormat,
})
if err != nil {
return err
@ -353,13 +376,13 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
return xmain.UsageErrorf("invalid target: %s", *targetFlag)
}
boardPath = key.IDA()
boardPath = key.StringIDA()
}
ctx, cancel := timelib.WithTimeout(ctx, time.Minute*2)
defer cancel()
_, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page)
_, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page, outputFormat)
if err != nil {
if written {
return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), err)
@ -434,7 +457,7 @@ func RouterResolver(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plu
}
}
func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page, ext exportExtension) (_ []byte, written bool, _ error) {
start := time.Now()
input, err := ms.ReadPath(inputPath)
if err != nil {
@ -459,7 +482,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
if os.Getenv("D2_LSP_MODE") == "1" {
// only the parse result is needed if running d2 for lsp,
// if this, "fails", the AST is still valid and can be sent
// to vscode extention
// to vscode extension
ast, err := d2lib.Parse(ctx, string(input), opts)
type LspOutputData struct {
@ -480,13 +503,13 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
}, time.Second*5)
defer cancel()
diagram, g, err := d2lib.Compile(ctx, string(input), opts, &renderOpts)
rootDiagram, g, err := d2lib.Compile(ctx, string(input), opts, &renderOpts)
if err != nil {
return nil, false, err
}
cancel()
diagram = diagram.GetBoard(boardPath)
diagram := rootDiagram.GetBoard(boardPath)
if diagram == nil {
return nil, false, fmt.Errorf(`render target "%s" not found`, strings.Join(boardPath, "."))
}
@ -499,7 +522,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
plugin, _ := d2plugin.FindPlugin(ctx, plugins, *opts.Layout)
if animateInterval > 0 {
masterID, err := diagram.HashID()
masterID, err := diagram.HashID(renderOpts.Salt)
if err != nil {
return nil, false, err
}
@ -526,10 +549,9 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
return nil, false, err
}
ext := getExportExtension(outputPath)
switch ext {
case GIF:
svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, diagram)
svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, inputPath, diagram)
if err != nil {
return nil, false, err
}
@ -541,7 +563,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
if err != nil {
return nil, false, err
}
err = ms.WritePath(outputPath, out)
err = Write(ms, outputPath, out)
if err != nil {
return nil, false, err
}
@ -553,7 +575,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
path := []pdf.BoardTitle{
{Name: diagram.Root.Label, BoardID: "root"},
}
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, path, pageMap, diagram.Root.Label != "")
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, inputPath, outputPath, page, ruler, diagram, nil, path, pageMap, diagram.Root.Label != "")
if err != nil {
return pdf, false, err
}
@ -574,7 +596,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
path := []pptx.BoardTitle{
{Name: "root", BoardID: "root", LinkToSlide: boardIdToIndex["root"] + 1},
}
svg, err := renderPPTX(ctx, ms, p, plugin, renderOpts, ruler, outputPath, page, diagram, path, boardIdToIndex)
svg, err := renderPPTX(ctx, ms, p, plugin, renderOpts, ruler, inputPath, outputPath, page, diagram, path, boardIdToIndex)
if err != nil {
return nil, false, err
}
@ -589,11 +611,11 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
compileDur := time.Since(start)
if animateInterval <= 0 {
// Rename all the "root.layers.x" to the paths that the boards get output to
linkToOutput, err := resolveLinks("root", outputPath, diagram)
linkToOutput, err := resolveLinks("root", outputPath, rootDiagram)
if err != nil {
return nil, false, err
}
err = relink("root", diagram, linkToOutput)
err = relink("root", rootDiagram, linkToOutput)
if err != nil {
return nil, false, err
}
@ -602,9 +624,9 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
var boards [][]byte
var err error
if noChildren {
boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext)
} else {
boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext)
}
if err != nil {
return nil, false, err
@ -617,11 +639,15 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
if err != nil {
return nil, false, err
}
out, err = plugin.PostProcess(ctx, out)
if err != nil {
return nil, false, err
}
err = os.MkdirAll(filepath.Dir(outputPath), 0755)
if err != nil {
return nil, false, err
}
err = ms.WritePath(outputPath, out)
err = Write(ms, outputPath, out)
if err != nil {
return nil, false, err
}
@ -739,7 +765,7 @@ func relink(currDiagramPath string, d *d2target.Diagram, linkToOutput map[string
return nil
}
func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, ext exportExtension) ([][]byte, error) {
if diagram.Name != "" {
ext := filepath.Ext(outputPath)
outputPath = strings.TrimSuffix(outputPath, ext)
@ -785,21 +811,21 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
var boards [][]byte
for _, dl := range diagram.Layers {
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl)
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
if err != nil {
return nil, err
}
boards = append(boards, childrenBoards...)
}
for _, dl := range diagram.Scenarios {
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl)
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
if err != nil {
return nil, err
}
boards = append(boards, childrenBoards...)
}
for _, dl := range diagram.Steps {
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl)
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
if err != nil {
return nil, err
}
@ -808,7 +834,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
if !diagram.IsFolderOnly {
start := time.Now()
out, err := _render(ctx, ms, plugin, opts, boardOutputPath, bundle, forceAppendix, page, ruler, diagram)
out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram, ext)
if err != nil {
return boards, err
}
@ -822,9 +848,9 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
return boards, nil
}
func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([][]byte, error) {
start := time.Now()
out, err := _render(ctx, ms, plugin, opts, outputPath, bundle, forceAppendix, page, ruler, diagram)
out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, outputFormat)
if err != nil {
return [][]byte{}, err
}
@ -835,49 +861,55 @@ func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration
return [][]byte{out}, nil
}
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
toPNG := getExportExtension(outputPath) == PNG
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([]byte, error) {
toPNG := outputFormat == PNG
var scale *float64
if opts.Scale != nil {
scale = opts.Scale
} else if toPNG {
scale = go2.Pointer(1.)
}
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
renderOpts := &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
MasterID: opts.MasterID,
ThemeID: opts.ThemeID,
DarkThemeID: opts.DarkThemeID,
MasterID: opts.MasterID,
ThemeOverrides: opts.ThemeOverrides,
DarkThemeOverrides: opts.DarkThemeOverrides,
NoXMLTag: opts.NoXMLTag,
Salt: opts.Salt,
Scale: scale,
})
}
svg, err := d2svg.Render(diagram, renderOpts)
if err != nil {
return nil, err
}
svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return svg, err
if opts.MasterID == "" {
svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return svg, err
}
}
cacheImages := ms.Env.Getenv("IMG_CACHE") == "1"
l := simplelog.FromCmdLog(ms.Log)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, svg, cacheImages)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, inputPath, svg, cacheImages)
if bundle {
var bundleErr2 error
svg, bundleErr2 = imgbundler.BundleRemote(ctx, l, svg, cacheImages)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
}
if forceAppendix && !toPNG {
svg = appendix.Append(diagram, ruler, svg)
svg = appendix.Append(diagram, renderOpts, ruler, svg)
}
out := svg
if toPNG {
svg := appendix.Append(diagram, ruler, svg)
svg := appendix.Append(diagram, renderOpts, ruler, svg)
if !bundle {
var bundleErr2 error
@ -904,7 +936,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts
if err != nil {
return svg, err
}
err = ms.WritePath(outputPath, out)
err = Write(ms, outputPath, out)
if err != nil {
return svg, err
}
@ -915,7 +947,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts
return svg, nil
}
func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, doc *pdf.GoFPDF, boardPath []pdf.BoardTitle, pageMap map[string]int, includeNav bool) (svg []byte, err error) {
func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, doc *pdf.GoFPDF, boardPath []pdf.BoardTitle, pageMap map[string]int, includeNav bool) (svg []byte, err error) {
var isRoot bool
if doc == nil {
doc = pdf.Init()
@ -935,13 +967,17 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
scale = go2.Pointer(1.)
}
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
Scale: scale,
ThemeID: opts.ThemeID,
})
renderOpts := &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
Scale: scale,
ThemeID: opts.ThemeID,
DarkThemeID: opts.DarkThemeID,
ThemeOverrides: opts.ThemeOverrides,
DarkThemeOverrides: opts.DarkThemeOverrides,
}
svg, err = d2svg.Render(diagram, renderOpts)
if err != nil {
return nil, err
}
@ -953,13 +989,13 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
cacheImages := ms.Env.Getenv("IMG_CACHE") == "1"
l := simplelog.FromCmdLog(ms.Log)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, svg, cacheImages)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, inputPath, svg, cacheImages)
svg, bundleErr2 := imgbundler.BundleRemote(ctx, l, svg, cacheImages)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
if bundleErr != nil {
return svg, bundleErr
}
svg = appendix.Append(diagram, ruler, svg)
svg = appendix.Append(diagram, renderOpts, ruler, svg)
pngImg, err := ConvertSVG(ms, page, svg)
if err != nil {
@ -986,7 +1022,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
Name: dl.Root.Label,
BoardID: strings.Join([]string{boardPath[len(boardPath)-1].BoardID, LAYERS, dl.Name}, "."),
})
_, err := renderPDF(ctx, ms, plugin, opts, "", page, ruler, dl, doc, path, pageMap, includeNav)
_, err := renderPDF(ctx, ms, plugin, opts, inputPath, "", page, ruler, dl, doc, path, pageMap, includeNav)
if err != nil {
return nil, err
}
@ -996,7 +1032,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
Name: dl.Root.Label,
BoardID: strings.Join([]string{boardPath[len(boardPath)-1].BoardID, SCENARIOS, dl.Name}, "."),
})
_, err := renderPDF(ctx, ms, plugin, opts, "", page, ruler, dl, doc, path, pageMap, includeNav)
_, err := renderPDF(ctx, ms, plugin, opts, inputPath, "", page, ruler, dl, doc, path, pageMap, includeNav)
if err != nil {
return nil, err
}
@ -1006,7 +1042,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
Name: dl.Root.Label,
BoardID: strings.Join([]string{boardPath[len(boardPath)-1].BoardID, STEPS, dl.Name}, "."),
})
_, err := renderPDF(ctx, ms, plugin, opts, "", page, ruler, dl, doc, path, pageMap, includeNav)
_, err := renderPDF(ctx, ms, plugin, opts, inputPath, "", page, ruler, dl, doc, path, pageMap, includeNav)
if err != nil {
return nil, err
}
@ -1022,7 +1058,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
return svg, nil
}
func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Presentation, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, outputPath string, page playwright.Page, diagram *d2target.Diagram, boardPath []pptx.BoardTitle, boardIDToIndex map[string]int) ([]byte, error) {
func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Presentation, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, inputPath, outputPath string, page playwright.Page, diagram *d2target.Diagram, boardPath []pptx.BoardTitle, boardIDToIndex map[string]int) ([]byte, error) {
var svg []byte
if !diagram.IsFolderOnly {
// gofpdf will print the png img with a slight filter
@ -1038,12 +1074,17 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
var err error
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
Scale: scale,
})
renderOpts := &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
Scale: scale,
ThemeID: opts.ThemeID,
DarkThemeID: opts.DarkThemeID,
ThemeOverrides: opts.ThemeOverrides,
DarkThemeOverrides: opts.DarkThemeOverrides,
}
svg, err = d2svg.Render(diagram, renderOpts)
if err != nil {
return nil, err
}
@ -1055,14 +1096,14 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
cacheImages := ms.Env.Getenv("IMG_CACHE") == "1"
l := simplelog.FromCmdLog(ms.Log)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, svg, cacheImages)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, inputPath, svg, cacheImages)
svg, bundleErr2 := imgbundler.BundleRemote(ctx, l, svg, cacheImages)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
if bundleErr != nil {
return nil, bundleErr
}
svg = appendix.Append(diagram, ruler, svg)
svg = appendix.Append(diagram, renderOpts, ruler, svg)
pngImg, err := ConvertSVG(ms, page, svg)
if err != nil {
@ -1120,7 +1161,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
BoardID: boardID,
LinkToSlide: boardIDToIndex[boardID] + 1,
})
_, err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, "", page, dl, path, boardIDToIndex)
_, err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, inputPath, "", page, dl, path, boardIDToIndex)
if err != nil {
return nil, err
}
@ -1132,7 +1173,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
BoardID: boardID,
LinkToSlide: boardIDToIndex[boardID] + 1,
})
_, err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, "", page, dl, path, boardIDToIndex)
_, err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, inputPath, "", page, dl, path, boardIDToIndex)
if err != nil {
return nil, err
}
@ -1144,7 +1185,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
BoardID: boardID,
LinkToSlide: boardIDToIndex[boardID] + 1,
})
_, err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, "", page, dl, path, boardIDToIndex)
_, err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, inputPath, "", page, dl, path, boardIDToIndex)
if err != nil {
return nil, err
}
@ -1168,11 +1209,6 @@ func getFileName(path string) string {
return strings.TrimSuffix(filepath.Base(path), ext)
}
// TODO: remove after removing slog
func DiscardSlog(ctx context.Context) context.Context {
return ctxlog.With(ctx, slog.Make(sloghuman.Sink(io.Discard)))
}
func populateLayoutOpts(ctx context.Context, ms *xmain.State, ps []d2plugin.Plugin) error {
pluginFlags, err := d2plugin.ListPluginFlags(ctx, ps)
if err != nil {
@ -1276,7 +1312,7 @@ func buildBoardIDToIndex(diagram *d2target.Diagram, dictionary map[string]int, p
return dictionary
}
func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, page playwright.Page, diagram *d2target.Diagram) (svg []byte, pngs [][]byte, err error) {
func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, page playwright.Page, inputPath string, diagram *d2target.Diagram) (svg []byte, pngs [][]byte, err error) {
if !diagram.IsFolderOnly {
var scale *float64
@ -1285,12 +1321,17 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug
} else {
scale = go2.Pointer(1.)
}
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
Scale: scale,
})
renderOpts := &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
Scale: scale,
ThemeID: opts.ThemeID,
DarkThemeID: opts.DarkThemeID,
ThemeOverrides: opts.ThemeOverrides,
DarkThemeOverrides: opts.DarkThemeOverrides,
}
svg, err = d2svg.Render(diagram, renderOpts)
if err != nil {
return nil, nil, err
}
@ -1302,14 +1343,14 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug
cacheImages := ms.Env.Getenv("IMG_CACHE") == "1"
l := simplelog.FromCmdLog(ms.Log)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, svg, cacheImages)
svg, bundleErr := imgbundler.BundleLocal(ctx, l, inputPath, svg, cacheImages)
svg, bundleErr2 := imgbundler.BundleRemote(ctx, l, svg, cacheImages)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
if bundleErr != nil {
return nil, nil, bundleErr
}
svg = appendix.Append(diagram, ruler, svg)
svg = appendix.Append(diagram, renderOpts, ruler, svg)
pngImg, err := ConvertSVG(ms, page, svg)
if err != nil {
@ -1319,21 +1360,21 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug
}
for _, dl := range diagram.Layers {
_, layerPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
_, layerPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, inputPath, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, layerPNGs...)
}
for _, dl := range diagram.Scenarios {
_, scenarioPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
_, scenarioPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, inputPath, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, scenarioPNGs...)
}
for _, dl := range diagram.Steps {
_, stepsPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
_, stepsPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, inputPath, dl)
if err != nil {
return nil, nil, err
}
@ -1361,6 +1402,15 @@ func AnimatePNGs(ms *xmain.State, pngs [][]byte, animIntervalMs int) ([]byte, er
return xgif.AnimatePNGs(pngs, animIntervalMs)
}
func init() {
ctxlog.Init()
func Write(ms *xmain.State, path string, out []byte) error {
err := ms.AtomicWritePath(path, out)
if err == nil {
return nil
}
ms.Log.Debug.Printf("atomic write failed: %s, trying non-atomic write", err.Error())
return ms.WritePath(path, out)
}
func init() {
log.Init()
}

75
d2cli/play.go Normal file
View file

@ -0,0 +1,75 @@
package d2cli
import (
"context"
"fmt"
"io"
"os"
"oss.terrastruct.com/d2/lib/urlenc"
"oss.terrastruct.com/util-go/xbrowser"
"oss.terrastruct.com/util-go/xmain"
)
func playCmd(ctx context.Context, ms *xmain.State) error {
if len(ms.Opts.Flags.Args()) != 2 {
return xmain.UsageErrorf("play must be passed one argument: either a filepath or '-' for stdin")
}
filepath := ms.Opts.Flags.Args()[1]
theme, err := ms.Opts.Flags.GetInt64("theme")
if err != nil {
return err
}
sketch, err := ms.Opts.Flags.GetBool("sketch")
if err != nil {
return err
}
var sketchNumber int
if sketch {
sketchNumber = 1
} else {
sketchNumber = 0
}
fileRaw, err := readInput(filepath)
if err != nil {
return err
}
encoded, err := urlenc.Encode(fileRaw)
if err != nil {
return err
}
url := fmt.Sprintf("https://play.d2lang.com/?script=%s&sketch=%d&theme=%d&", encoded, sketchNumber, theme)
openBrowser(ctx, ms, url)
return nil
}
func readInput(filepath string) (string, error) {
if filepath == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("error reading from stdin: %w", err)
}
return string(data), nil
}
data, err := os.ReadFile(filepath)
if err != nil {
return "", xmain.UsageErrorf(err.Error())
}
return string(data), nil
}
func openBrowser(ctx context.Context, ms *xmain.State, url string) {
ms.Log.Info.Printf("opening playground: %s", url)
err := xbrowser.Open(ctx, ms.Env, url)
if err != nil {
ms.Log.Warn.Printf("failed to open browser to %v: %v", url, err)
}
}

View file

@ -27,7 +27,7 @@ function init(reconnectDelay) {
const parsedXML = new DOMParser().parseFromString(msg.svg, "text/xml");
d2SVG.replaceChildren(parsedXML.documentElement);
changeFavicon("/static/favicon.ico");
const svgEl = d2SVG.querySelector("#d2-svg");
const svgEl = d2SVG.querySelector(".d2-svg");
// just use inner SVG in watch mode
svgEl.parentElement.replaceWith(svgEl);
let width = parseInt(svgEl.getAttribute("width"), 10);

View file

@ -57,6 +57,7 @@ type watcherOpts struct {
forceAppendix bool
pw png.Playwright
fontFamily *d2fonts.FontFamily
outputFormat exportExtension
}
type watcher struct {
@ -430,7 +431,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
if w.boardPath != "" {
boardPath = strings.Split(w.boardPath, string(os.PathSeparator))
}
svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page)
svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page, w.outputFormat)
w.boardpathMu.Unlock()
errs := ""
if err != nil {
@ -520,6 +521,11 @@ func (w *watcher) handleRoot(hw http.ResponseWriter, r *http.Request) {
if idx := strings.LastIndexByte(boardPath, '.'); idx != -1 {
boardPath = boardPath[:idx]
}
// if path is "/index", we just want "/"
boardPath = strings.TrimSuffix(boardPath, "/index")
if boardPath == "index" {
boardPath = ""
}
recompile := false
if boardPath != w.boardPath {
w.boardPath = boardPath

View file

@ -3,9 +3,11 @@ package d2compiler
import (
"encoding/xml"
"fmt"
"html"
"io"
"io/fs"
"net/url"
"slices"
"strconv"
"strings"
@ -69,6 +71,7 @@ func compileIR(ast *d2ast.Map, m *d2ir.Map) (*d2graph.Graph, error) {
g := d2graph.NewGraph()
g.AST = ast
g.BaseAST = ast
c.compileBoard(g, m)
if len(c.err.Errors) > 0 {
return nil, c.err
@ -107,24 +110,30 @@ func (c *compiler) compileBoard(g *d2graph.Graph, ir *d2ir.Map) *d2graph.Graph {
}
func (c *compiler) compileBoardsField(g *d2graph.Graph, ir *d2ir.Map, fieldName string) {
layers := ir.GetField(fieldName)
if layers.Map() == nil {
boards := ir.GetField(d2ast.FlatUnquotedString(fieldName))
if boards.Map() == nil {
return
}
for _, f := range layers.Map().Fields {
for _, f := range boards.Map().Fields {
m := f.Map()
if f.Map() == nil {
continue
m = &d2ir.Map{}
}
if g.GetBoard(f.Name) != nil {
c.errorf(f.References[0].AST(), "board name %v already used by another board", f.Name)
if g.GetBoard(f.Name.ScalarString()) != nil {
c.errorf(f.References[0].AST(), "board name %v already used by another board", f.Name.ScalarString())
continue
}
g2 := d2graph.NewGraph()
g2.Parent = g
g2.AST = f.Map().AST().(*d2ast.Map)
g2.BaseAST = findFieldAST(g.AST, f)
c.compileBoard(g2, f.Map())
g2.Name = f.Name
g2.AST = m.AST().(*d2ast.Map)
if g.BaseAST != nil {
g2.BaseAST = findFieldAST(g.BaseAST, f)
}
c.compileBoard(g2, m)
if f.Primary() != nil {
c.compileLabel(&g2.Root.Attributes, f)
}
g2.Name = f.Name.ScalarString()
switch fieldName {
case "layers":
g.Layers = append(g.Layers, g2)
@ -138,9 +147,9 @@ func (c *compiler) compileBoardsField(g *d2graph.Graph, ir *d2ir.Map, fieldName
func findFieldAST(ast *d2ast.Map, f *d2ir.Field) *d2ast.Map {
path := []string{}
var curr *d2ir.Field = f
curr := f
for {
path = append([]string{curr.Name}, path...)
path = append([]string{curr.Name.ScalarString()}, path...)
boardKind := d2ir.NodeBoardKind(curr)
if boardKind == "" {
break
@ -215,7 +224,7 @@ func (c *compiler) errorf(n d2ast.Node, f string, v ...interface{}) {
}
func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
class := m.GetField("class")
class := m.GetField(d2ast.FlatUnquotedString("class"))
if class != nil {
var classNames []string
if class.Primary() != nil {
@ -230,8 +239,6 @@ func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
}
}
}
} else {
c.errorf(class.LastRef().AST(), "class missing value")
}
for _, className := range classNames {
@ -256,7 +263,7 @@ func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
}
}
}
shape := m.GetField("shape")
shape := m.GetField(d2ast.FlatUnquotedString("shape"))
if shape != nil {
if shape.Composite != nil {
c.errorf(shape.LastPrimaryKey(), "reserved field shape does not accept composite")
@ -265,10 +272,10 @@ func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
}
}
for _, f := range m.Fields {
if f.Name == "shape" {
if f.Name.ScalarString() == "shape" && f.Name.IsUnquoted() {
continue
}
if _, ok := d2graph.BoardKeywords[f.Name]; ok {
if _, ok := d2ast.BoardKeywords[f.Name.ScalarString()]; ok && f.Name.IsUnquoted() {
continue
}
c.compileField(obj, f)
@ -289,14 +296,15 @@ func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
}
func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
keyword := strings.ToLower(f.Name)
_, isStyleReserved := d2graph.StyleKeywords[keyword]
if isStyleReserved {
c.errorf(f.LastRef().AST(), "%v must be style.%v", f.Name, f.Name)
keyword := strings.ToLower(f.Name.ScalarString())
_, isStyleReserved := d2ast.StyleKeywords[keyword]
if isStyleReserved && f.Name.IsUnquoted() {
c.errorf(f.LastRef().AST(), "%v must be style.%v", f.Name.ScalarString(), f.Name.ScalarString())
return
}
_, isReserved := d2graph.SimpleReservedKeywords[keyword]
if f.Name == "classes" {
_, isReserved := d2ast.SimpleReservedKeywords[keyword]
isReserved = isReserved && f.Name.IsUnquoted()
if f.Name.ScalarString() == "classes" && f.Name.IsUnquoted() {
if f.Map() != nil {
if len(f.Map().Edges) > 0 {
c.errorf(f.Map().Edges[0].LastRef().AST(), "classes cannot contain an edge")
@ -306,34 +314,31 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
continue
}
for _, cf := range classesField.Map().Fields {
if _, ok := d2graph.ReservedKeywords[cf.Name]; !ok {
c.errorf(cf.LastRef().AST(), "%s is an invalid class field, must be reserved keyword", cf.Name)
if _, ok := d2ast.ReservedKeywords[cf.Name.ScalarString()]; !(ok && f.Name.IsUnquoted()) {
c.errorf(cf.LastRef().AST(), "%s is an invalid class field, must be reserved keyword", cf.Name.ScalarString())
}
if cf.Name == "class" {
if cf.Name.ScalarString() == "class" && cf.Name.IsUnquoted() {
c.errorf(cf.LastRef().AST(), `"class" cannot appear within "classes"`)
}
}
}
}
return
} else if f.Name == "vars" {
} else if f.Name.ScalarString() == "vars" && f.Name.IsUnquoted() {
return
} else if f.Name == "source-arrowhead" || f.Name == "target-arrowhead" {
c.errorf(f.LastRef().AST(), `%#v can only be used on connections`, f.Name)
} else if (f.Name.ScalarString() == "source-arrowhead" || f.Name.ScalarString() == "target-arrowhead") && f.Name.IsUnquoted() {
c.errorf(f.LastRef().AST(), `%#v can only be used on connections`, f.Name.ScalarString())
return
} else if isReserved {
c.compileReserved(&obj.Attributes, f)
return
} else if f.Name == "style" {
} else if f.Name.ScalarString() == "style" && f.Name.IsUnquoted() {
if f.Map() == nil || len(f.Map().Fields) == 0 {
c.errorf(f.LastRef().AST(), `"style" expected to be set to a map of key-values, or contain an additional keyword like "style.opacity: 0.4"`)
return
}
c.compileStyle(&obj.Attributes, f.Map())
if obj.Style.Animated != nil {
c.errorf(obj.Style.Animated.MapKey, `key "animated" can only be applied to edges`)
}
return
}
@ -348,7 +353,8 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
}
}
obj = obj.EnsureChild(d2graphIDA([]string{f.Name}))
parent := obj
obj = obj.EnsureChild(([]d2ast.String{f.Name}))
if f.Primary() != nil {
c.compileLabel(&obj.Attributes, f)
}
@ -373,9 +379,11 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
MapKeyEdgeIndex: fr.Context_.EdgeIndex(),
Scope: fr.Context_.Scope,
ScopeAST: fr.Context_.ScopeAST,
ScopeObj: parent,
IsVar: d2ir.IsVar(fr.Context_.ScopeMap),
}
if fr.Context_.ScopeMap != nil && !d2ir.IsVar(fr.Context_.ScopeMap) {
scopeObjIDA := d2graphIDA(d2ir.BoardIDA(fr.Context_.ScopeMap))
scopeObjIDA := d2ir.BoardIDA(fr.Context_.ScopeMap)
r.ScopeObj = obj.Graph.Root.EnsureChild(scopeObjIDA)
}
obj.References = append(obj.References, r)
@ -428,7 +436,7 @@ func (c *compiler) compilePosition(attrs *d2graph.Attributes, f *d2ir.Field) {
name := f.Name
if f.Map() != nil {
for _, f := range f.Map().Fields {
if f.Name == "near" {
if f.Name.ScalarString() == "near" && f.Name.IsUnquoted() {
if f.Primary() == nil {
c.errorf(f.LastPrimaryKey(), `invalid "near" field`)
} else {
@ -437,10 +445,10 @@ func (c *compiler) compilePosition(attrs *d2graph.Attributes, f *d2ir.Field) {
case *d2ast.Null:
attrs.LabelPosition = nil
default:
if _, ok := d2graph.LabelPositions[scalar.ScalarString()]; !ok {
if _, ok := d2ast.LabelPositions[scalar.ScalarString()]; !ok {
c.errorf(f.LastPrimaryKey(), `invalid "near" field`)
} else {
switch name {
switch name.ScalarString() {
case "label":
attrs.LabelPosition = &d2graph.Scalar{}
attrs.LabelPosition.Value = scalar.ScalarString()
@ -455,7 +463,7 @@ func (c *compiler) compilePosition(attrs *d2graph.Attributes, f *d2ir.Field) {
}
} else {
if f.LastPrimaryKey() != nil {
c.errorf(f.LastPrimaryKey(), `unexpected field %s`, f.Name)
c.errorf(f.LastPrimaryKey(), `unexpected field %s`, f.Name.ScalarString())
}
}
}
@ -468,7 +476,7 @@ func (c *compiler) compilePosition(attrs *d2graph.Attributes, f *d2ir.Field) {
func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
if f.Primary() == nil {
if f.Composite != nil {
switch f.Name {
switch f.Name.ScalarString() {
case "class":
if arr, ok := f.Composite.(*d2ir.Array); ok {
for _, class := range arr.Values {
@ -493,13 +501,15 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
case "label", "icon":
c.compilePosition(attrs, f)
default:
c.errorf(f.LastPrimaryKey(), "reserved field %v does not accept composite", f.Name)
c.errorf(f.LastPrimaryKey(), "reserved field %v does not accept composite", f.Name.ScalarString())
}
} else {
c.errorf(f.LastRef().AST(), `reserved field "%v" must have a value`, f.Name.ScalarString())
}
return
}
scalar := f.Primary().Value
switch f.Name {
switch f.Name.ScalarString() {
case "label":
c.compileLabel(attrs, f)
c.compilePosition(attrs, f)
@ -668,6 +678,13 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
case "classes":
}
if attrs.Link != nil && attrs.Label.Value != "" {
u, err := url.ParseRequestURI(attrs.Label.Value)
if err == nil && u.Host != "" {
c.errorf(scalar, "Label cannot be set to URL when link is also set (for security)")
}
}
if attrs.Link != nil && attrs.Tooltip != nil {
u, err := url.ParseRequestURI(attrs.Tooltip.Value)
if err == nil && u.Host != "" {
@ -683,8 +700,8 @@ func (c *compiler) compileStyle(attrs *d2graph.Attributes, m *d2ir.Map) {
}
func (c *compiler) compileStyleField(attrs *d2graph.Attributes, f *d2ir.Field) {
if _, ok := d2graph.StyleKeywords[strings.ToLower(f.Name)]; !ok {
c.errorf(f.LastRef().AST(), `invalid style keyword: "%s"`, f.Name)
if _, ok := d2ast.StyleKeywords[strings.ToLower(f.Name.ScalarString())]; !(ok && f.Name.IsUnquoted()) {
c.errorf(f.LastRef().AST(), `invalid style keyword: "%s"`, f.Name.ScalarString())
return
}
if f.Primary() == nil {
@ -692,7 +709,7 @@ func (c *compiler) compileStyleField(attrs *d2graph.Attributes, f *d2ir.Field) {
}
compileStyleFieldInit(attrs, f)
scalar := f.Primary().Value
err := attrs.Style.Apply(f.Name, scalar.ScalarString())
err := attrs.Style.Apply(f.Name.ScalarString(), scalar.ScalarString())
if err != nil {
c.errorf(scalar, err.Error())
return
@ -700,7 +717,7 @@ func (c *compiler) compileStyleField(attrs *d2graph.Attributes, f *d2ir.Field) {
}
func compileStyleFieldInit(attrs *d2graph.Attributes, f *d2ir.Field) {
switch f.Name {
switch f.Name.ScalarString() {
case "opacity":
attrs.Style.Opacity = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
case "stroke":
@ -753,7 +770,7 @@ func compileStyleFieldInit(attrs *d2graph.Attributes, f *d2ir.Field) {
}
func (c *compiler) compileEdge(obj *d2graph.Object, e *d2ir.Edge) {
edge, err := obj.Connect(d2graphIDA(e.ID.SrcPath), d2graphIDA(e.ID.DstPath), e.ID.SrcArrow, e.ID.DstArrow, "")
edge, err := obj.Connect(e.ID.SrcPath, e.ID.DstPath, e.ID.SrcArrow, e.ID.DstArrow, "")
if err != nil {
c.errorf(e.References[0].AST(), err.Error())
return
@ -774,9 +791,10 @@ func (c *compiler) compileEdge(obj *d2graph.Object, e *d2ir.Edge) {
MapKeyEdgeIndex: er.Context_.EdgeIndex(),
Scope: er.Context_.Scope,
ScopeAST: er.Context_.ScopeAST,
ScopeObj: obj,
}
if er.Context_.ScopeMap != nil && !d2ir.IsVar(er.Context_.ScopeMap) {
scopeObjIDA := d2graphIDA(d2ir.BoardIDA(er.Context_.ScopeMap))
scopeObjIDA := d2ir.BoardIDA(er.Context_.ScopeMap)
r.ScopeObj = edge.Src.Graph.Root.EnsureChild(scopeObjIDA)
}
edge.References = append(edge.References, r)
@ -784,7 +802,7 @@ func (c *compiler) compileEdge(obj *d2graph.Object, e *d2ir.Edge) {
}
func (c *compiler) compileEdgeMap(edge *d2graph.Edge, m *d2ir.Map) {
class := m.GetField("class")
class := m.GetField(d2ast.FlatUnquotedString("class"))
if class != nil {
var classNames []string
if class.Primary() != nil {
@ -799,8 +817,6 @@ func (c *compiler) compileEdgeMap(edge *d2graph.Edge, m *d2ir.Map) {
}
}
}
} else {
c.errorf(class.LastRef().AST(), "class missing value")
}
for _, className := range classNames {
@ -811,8 +827,8 @@ func (c *compiler) compileEdgeMap(edge *d2graph.Edge, m *d2ir.Map) {
}
}
for _, f := range m.Fields {
_, ok := d2graph.ReservedKeywords[f.Name]
if !ok {
_, ok := d2ast.ReservedKeywords[f.Name.ScalarString()]
if !(ok && f.Name.IsUnquoted()) {
c.errorf(f.References[0].AST(), `edge map keys must be reserved keywords`)
continue
}
@ -821,17 +837,18 @@ func (c *compiler) compileEdgeMap(edge *d2graph.Edge, m *d2ir.Map) {
}
func (c *compiler) compileEdgeField(edge *d2graph.Edge, f *d2ir.Field) {
keyword := strings.ToLower(f.Name)
_, isStyleReserved := d2graph.StyleKeywords[keyword]
keyword := strings.ToLower(f.Name.ScalarString())
_, isStyleReserved := d2ast.StyleKeywords[keyword]
isStyleReserved = isStyleReserved && f.Name.IsUnquoted()
if isStyleReserved {
c.errorf(f.LastRef().AST(), "%v must be style.%v", f.Name, f.Name)
c.errorf(f.LastRef().AST(), "%v must be style.%v", f.Name.ScalarString(), f.Name.ScalarString())
return
}
_, isReserved := d2graph.SimpleReservedKeywords[keyword]
_, isReserved := d2ast.SimpleReservedKeywords[keyword]
if isReserved {
c.compileReserved(&edge.Attributes, f)
return
} else if f.Name == "style" {
} else if f.Name.ScalarString() == "style" {
if f.Map() == nil {
return
}
@ -839,14 +856,14 @@ func (c *compiler) compileEdgeField(edge *d2graph.Edge, f *d2ir.Field) {
return
}
if f.Name == "source-arrowhead" || f.Name == "target-arrowhead" {
if (f.Name.ScalarString() == "source-arrowhead" || f.Name.ScalarString() == "target-arrowhead") && f.Name.IsUnquoted() {
c.compileArrowheads(edge, f)
}
}
func (c *compiler) compileArrowheads(edge *d2graph.Edge, f *d2ir.Field) {
var attrs *d2graph.Attributes
if f.Name == "source-arrowhead" {
if f.Name.ScalarString() == "source-arrowhead" {
if edge.SrcArrowhead == nil {
edge.SrcArrowhead = &d2graph.Attributes{}
}
@ -864,12 +881,13 @@ func (c *compiler) compileArrowheads(edge *d2graph.Edge, f *d2ir.Field) {
if f.Map() != nil {
for _, f2 := range f.Map().Fields {
keyword := strings.ToLower(f2.Name)
_, isReserved := d2graph.SimpleReservedKeywords[keyword]
keyword := strings.ToLower(f2.Name.ScalarString())
_, isReserved := d2ast.SimpleReservedKeywords[keyword]
isReserved = isReserved && f2.Name.IsUnquoted()
if isReserved {
c.compileReserved(attrs, f2)
continue
} else if f2.Name == "style" {
} else if f2.Name.ScalarString() == "style" && f2.Name.IsUnquoted() {
if f2.Map() == nil {
continue
}
@ -982,7 +1000,7 @@ func (c *compiler) compileSQLTable(obj *d2graph.Object) {
func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ir.Map) {
for _, f := range m.Fields {
if _, ok := d2graph.BoardKeywords[f.Name]; ok {
if _, ok := d2ast.BoardKeywords[f.Name.ScalarString()]; ok && f.Name.IsUnquoted() {
continue
}
c.validateKey(obj, f)
@ -990,8 +1008,9 @@ func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ir.Map) {
}
func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
keyword := strings.ToLower(f.Name)
_, isReserved := d2graph.ReservedKeywords[keyword]
keyword := strings.ToLower(f.Name.ScalarString())
_, isReserved := d2ast.ReservedKeywords[keyword]
isReserved = isReserved && f.Name.IsUnquoted()
if isReserved {
switch obj.Shape.Value {
case d2target.ShapeCircle, d2target.ShapeSquare:
@ -1001,7 +1020,7 @@ func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
}
}
switch f.Name {
switch f.Name.ScalarString() {
case "style":
if obj.Style.ThreeDee != nil {
if !strings.EqualFold(obj.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(obj.Shape.Value, d2target.ShapeRectangle) && !strings.EqualFold(obj.Shape.Value, d2target.ShapeHexagon) {
@ -1031,12 +1050,12 @@ func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
return
}
if strings.EqualFold(obj.Shape.Value, d2target.ShapeImage) {
if strings.EqualFold(obj.Shape.Value, d2target.ShapeImage) && obj.OuterSequenceDiagram() == nil {
c.errorf(f.LastRef().AST(), "image shapes cannot have children.")
return
}
obj, ok := obj.HasChild([]string{f.Name})
obj, ok := obj.HasChild([]string{f.Name.ScalarString()})
if ok && f.Map() != nil {
c.validateKeys(obj, f.Map())
}
@ -1044,16 +1063,18 @@ func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
func (c *compiler) validateLabels(g *d2graph.Graph) {
for _, obj := range g.Objects {
if !strings.EqualFold(obj.Shape.Value, d2target.ShapeText) {
continue
}
if obj.Attributes.Language != "" {
// blockstrings have already been validated
continue
}
if strings.TrimSpace(obj.Label.Value) == "" {
c.errorf(obj.Label.MapKey, "shape text must have a non-empty label")
continue
if strings.EqualFold(obj.Shape.Value, d2target.ShapeText) {
if obj.Attributes.Language != "" {
// blockstrings have already been validated
continue
}
if strings.TrimSpace(obj.Label.Value) == "" {
c.errorf(obj.Label.MapKey, "shape text must have a non-empty label")
}
} else if strings.EqualFold(obj.Shape.Value, d2target.ShapeSQLTable) {
if strings.Contains(obj.Label.Value, "\n") {
c.errorf(obj.Label.MapKey, "shape sql_table cannot have newlines in label")
}
}
}
}
@ -1062,7 +1083,7 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
for _, obj := range g.Objects {
if obj.NearKey != nil {
nearObj, isKey := g.Root.HasChild(d2graph.Key(obj.NearKey))
_, isConst := d2graph.NearConstants[d2graph.Key(obj.NearKey)[0]]
_, isConst := d2ast.NearConstants[d2graph.Key(obj.NearKey)[0]]
if isKey {
// Doesn't make sense to set near to an ancestor or descendant
nearIsAncestor := false
@ -1092,7 +1113,7 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
continue
}
if nearObj.NearKey != nil {
_, nearObjNearIsConst := d2graph.NearConstants[d2graph.Key(nearObj.NearKey)[0]]
_, nearObjNearIsConst := d2ast.NearConstants[d2graph.Key(nearObj.NearKey)[0]]
if nearObjNearIsConst {
c.errorf(obj.NearKey, "near keys cannot be set to an object with a constant near key")
continue
@ -1112,7 +1133,7 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
continue
}
} else {
c.errorf(obj.NearKey, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
c.errorf(obj.NearKey, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.NearKey), strings.Join(d2ast.NearConstantsArray, ", "))
continue
}
}
@ -1195,12 +1216,24 @@ func (c *compiler) validateBoardLinks(g *d2graph.Graph) {
continue
}
u, err := url.Parse(html.UnescapeString(obj.Link.Value))
isRemote := err == nil && u.Scheme != ""
if isRemote {
continue
}
if linkKey.Path[0].Unbox().ScalarString() != "root" {
obj.Link = nil
continue
}
if !hasBoard(g.RootBoard(), linkKey.IDA()) {
c.errorf(obj.Link.MapKey, "linked board not found")
obj.Link = nil
continue
}
if slices.Equal(linkKey.StringIDA(), obj.Graph.IDA()) {
obj.Link = nil
continue
}
}
@ -1215,34 +1248,34 @@ func (c *compiler) validateBoardLinks(g *d2graph.Graph) {
}
}
func hasBoard(root *d2graph.Graph, ida []string) bool {
func hasBoard(root *d2graph.Graph, ida []d2ast.String) bool {
if len(ida) == 0 {
return true
}
if ida[0] == "root" {
if ida[0].ScalarString() == "root" && ida[0].IsUnquoted() {
return hasBoard(root, ida[1:])
}
id := ida[0]
if len(ida) == 1 {
return root.Name == id
return root.Name == id.ScalarString()
}
nextID := ida[1]
switch id {
switch id.ScalarString() {
case "layers":
for _, b := range root.Layers {
if b.Name == nextID {
if b.Name == nextID.ScalarString() {
return hasBoard(b, ida[2:])
}
}
case "scenarios":
for _, b := range root.Scenarios {
if b.Name == nextID {
if b.Name == nextID.ScalarString() {
return hasBoard(b, ida[2:])
}
}
case "steps":
for _, b := range root.Steps {
if b.Name == nextID {
if b.Name == nextID.ScalarString() {
return hasBoard(b, ida[2:])
}
}
@ -1257,20 +1290,10 @@ func init() {
}
}
func d2graphIDA(irIDA []string) (ida []string) {
for _, el := range irIDA {
n := &d2ast.KeyPath{
Path: []*d2ast.StringBox{d2ast.MakeValueBox(d2ast.RawString(el, true)).StringBox()},
}
ida = append(ida, d2format.Format(n))
}
return ida
}
// Unused for now until shape: edge_group
func (c *compiler) preprocessSeqDiagrams(m *d2ir.Map) {
for _, f := range m.Fields {
if f.Name == "shape" && f.Primary_.Value.ScalarString() == d2target.ShapeSequenceDiagram {
if f.Name.ScalarString() == "shape" && f.Name.IsUnquoted() && f.Primary_.Value.ScalarString() == d2target.ShapeSequenceDiagram {
c.preprocessEdgeGroup(m, m)
return
}
@ -1322,8 +1345,8 @@ func (c *compiler) preprocessEdgeGroup(seqDiagram, m *d2ir.Map) {
f := srcParent.GetField(el)
if !isEdgeGroup(f) {
for j := 0; j < i+1; j++ {
e.ID.SrcPath = append([]string{"_"}, e.ID.SrcPath...)
e.ID.DstPath = append([]string{"_"}, e.ID.DstPath...)
e.ID.SrcPath = append([]d2ast.String{d2ast.FlatUnquotedString("_")}, e.ID.SrcPath...)
e.ID.DstPath = append([]d2ast.String{d2ast.FlatUnquotedString("_")}, e.ID.DstPath...)
}
break
}
@ -1338,7 +1361,7 @@ func hoistActor(seqDiagram *d2ir.Map, f *d2ir.Field) {
seqDiagram.Fields = append(seqDiagram.Fields, f.Copy(seqDiagram).(*d2ir.Field))
} else {
d2ir.OverlayField(f2, f)
d2ir.ParentMap(f).DeleteField(f.Name)
d2ir.ParentMap(f).DeleteField(f.Name.ScalarString())
}
}
@ -1383,7 +1406,7 @@ func parentSeqDiagram(n d2ir.Node) *d2ir.Map {
return nil
}
for _, f := range m.Fields {
if f.Name == "shape" && f.Primary_.Value.ScalarString() == d2target.ShapeSequenceDiagram {
if f.Name.ScalarString() == "shape" && f.Name.IsUnquoted() && f.Primary_.Value.ScalarString() == d2target.ShapeSequenceDiagram {
return m
}
}
@ -1392,7 +1415,7 @@ func parentSeqDiagram(n d2ir.Node) *d2ir.Map {
}
func compileConfig(ir *d2ir.Map) (*d2target.Config, error) {
f := ir.GetField("vars", "d2-config")
f := ir.GetField(d2ast.FlatUnquotedString("vars"), d2ast.FlatUnquotedString("d2-config"))
if f == nil || f.Map() == nil {
return nil, nil
}
@ -1401,36 +1424,36 @@ func compileConfig(ir *d2ir.Map) (*d2target.Config, error) {
config := &d2target.Config{}
f = configMap.GetField("sketch")
f = configMap.GetField(d2ast.FlatUnquotedString("sketch"))
if f != nil {
val, _ := strconv.ParseBool(f.Primary().Value.ScalarString())
config.Sketch = &val
}
f = configMap.GetField("theme-id")
f = configMap.GetField(d2ast.FlatUnquotedString("theme-id"))
if f != nil {
val, _ := strconv.Atoi(f.Primary().Value.ScalarString())
config.ThemeID = go2.Pointer(int64(val))
}
f = configMap.GetField("dark-theme-id")
f = configMap.GetField(d2ast.FlatUnquotedString("dark-theme-id"))
if f != nil {
val, _ := strconv.Atoi(f.Primary().Value.ScalarString())
config.DarkThemeID = go2.Pointer(int64(val))
}
f = configMap.GetField("pad")
f = configMap.GetField(d2ast.FlatUnquotedString("pad"))
if f != nil {
val, _ := strconv.Atoi(f.Primary().Value.ScalarString())
config.Pad = go2.Pointer(int64(val))
}
f = configMap.GetField("layout-engine")
f = configMap.GetField(d2ast.FlatUnquotedString("layout-engine"))
if f != nil {
config.LayoutEngine = go2.Pointer(f.Primary().Value.ScalarString())
}
f = configMap.GetField("theme-overrides")
f = configMap.GetField(d2ast.FlatUnquotedString("theme-overrides"))
if f != nil {
overrides, err := compileThemeOverrides(f.Map())
if err != nil {
@ -1438,7 +1461,7 @@ func compileConfig(ir *d2ir.Map) (*d2target.Config, error) {
}
config.ThemeOverrides = overrides
}
f = configMap.GetField("dark-theme-overrides")
f = configMap.GetField(d2ast.FlatUnquotedString("dark-theme-overrides"))
if f != nil {
overrides, err := compileThemeOverrides(f.Map())
if err != nil {
@ -1446,6 +1469,27 @@ func compileConfig(ir *d2ir.Map) (*d2target.Config, error) {
}
config.DarkThemeOverrides = overrides
}
f = configMap.GetField(d2ast.FlatUnquotedString("data"))
if f != nil && f.Map() != nil {
config.Data = make(map[string]interface{})
for _, f := range f.Map().Fields {
if f.Primary() != nil {
config.Data[f.Name.ScalarString()] = f.Primary().Value.ScalarString()
} else if f.Composite != nil {
var arr []interface{}
switch c := f.Composite.(type) {
case *d2ir.Array:
for _, f := range c.Values {
switch c := f.(type) {
case *d2ir.Scalar:
arr = append(arr, c.String())
}
}
}
config.Data[f.Name.ScalarString()] = arr
}
}
}
return config, nil
}
@ -1459,7 +1503,7 @@ func compileThemeOverrides(m *d2ir.Map) (*d2target.ThemeOverrides, error) {
err := &d2parser.ParseError{}
FOR:
for _, f := range m.Fields {
switch strings.ToUpper(f.Name) {
switch strings.ToUpper(f.Name.ScalarString()) {
case "N1":
themeOverrides.N1 = go2.Pointer(f.Primary().Value.ScalarString())
case "N2":
@ -1497,11 +1541,11 @@ FOR:
case "AB5":
themeOverrides.AB5 = go2.Pointer(f.Primary().Value.ScalarString())
default:
err.Errors = append(err.Errors, d2parser.Errorf(f.LastPrimaryKey(), fmt.Sprintf(`"%s" is not a valid theme code`, f.Name)).(d2ast.Error))
err.Errors = append(err.Errors, d2parser.Errorf(f.LastPrimaryKey(), fmt.Sprintf(`"%s" is not a valid theme code`, f.Name.ScalarString())).(d2ast.Error))
continue FOR
}
if !go2.Contains(color.NamedColors, strings.ToLower(f.Primary().Value.ScalarString())) && !color.ColorHexRegex.MatchString(f.Primary().Value.ScalarString()) {
err.Errors = append(err.Errors, d2parser.Errorf(f.LastPrimaryKey(), fmt.Sprintf(`expected "%s" to be a valid named color ("orange") or a hex code ("#f0ff3a")`, f.Name)).(d2ast.Error))
err.Errors = append(err.Errors, d2parser.Errorf(f.LastPrimaryKey(), fmt.Sprintf(`expected "%s" to be a valid named color ("orange") or a hex code ("#f0ff3a")`, f.Name.ScalarString())).(d2ast.Error))
}
}

File diff suppressed because it is too large Load diff

View file

@ -194,6 +194,9 @@ func toShape(obj *d2graph.Object, g *d2graph.Graph) d2target.Shape {
if obj.Tooltip != nil {
shape.Tooltip = obj.Tooltip.Value
}
if obj.Style.Animated != nil {
shape.Animated, _ = strconv.ParseBool(obj.Style.Animated.Value)
}
if obj.Link != nil {
shape.Link = obj.Link.Value
shape.PrettyLink = toPrettyLink(g, obj.Link.Value)
@ -354,6 +357,9 @@ func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection
if edge.Style.Font != nil {
connection.FontFamily = edge.Style.Font.Value
}
if edge.Link != nil {
connection.Link = edge.Link.Value
}
connection.Label = text.Text
connection.LabelWidth = text.Dimensions.Width
connection.LabelHeight = text.Dimensions.Height

View file

@ -2,12 +2,11 @@ package d2exporter_test
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"cdr.dev/slog"
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
@ -218,7 +217,7 @@ func runa(t *testing.T, tcs []testCase) {
func run(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
ctx = log.WithTB(ctx, t)
ctx = log.Leveled(ctx, slog.LevelDebug)
g, config, err := d2compiler.Compile("", strings.NewReader(tc.dsl), &d2compiler.CompileOptions{
@ -277,7 +276,7 @@ func run(t *testing.T, tc testCase) {
// TestHashID tests that 2 diagrams with different theme configs do not equal each other
func TestHashID(t *testing.T) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
ctx = log.WithTB(ctx, t)
ctx = log.Leveled(ctx, slog.LevelDebug)
aString := `
@ -304,10 +303,10 @@ a -> b
db, err := compile(ctx, bString)
assert.JSON(t, nil, err)
hashA, err := da.HashID()
hashA, err := da.HashID(nil)
assert.JSON(t, nil, err)
hashB, err := db.HashID()
hashB, err := db.HashID(nil)
assert.JSON(t, nil, err)
assert.NotEqual(t, hashA, hashB)

View file

@ -135,6 +135,12 @@ func (p *printer) interpolationBoxes(boxes []d2ast.InterpolationBox, isDoubleStr
}
b.StringRaw = &s
}
if !isDoubleString {
if _, ok := d2ast.ReservedKeywords[strings.ToLower(*b.StringRaw)]; ok {
s := strings.ToLower(*b.StringRaw)
b.StringRaw = &s
}
}
p.sb.WriteString(*b.StringRaw)
}
}
@ -287,11 +293,18 @@ func (p *printer) _map(m *d2ast.Map) {
if nb.IsBoardNode() {
switch nb.MapKey.Key.Path[0].Unbox().ScalarString() {
case "layers":
layerNodes = append(layerNodes, nb)
// remove useless
if nb.MapKey.Value.Map != nil && len(nb.MapKey.Value.Map.Nodes) > 0 {
layerNodes = append(layerNodes, nb)
}
case "scenarios":
scenarioNodes = append(scenarioNodes, nb)
if nb.MapKey.Value.Map != nil && len(nb.MapKey.Value.Map.Nodes) > 0 {
scenarioNodes = append(scenarioNodes, nb)
}
case "steps":
stepNodes = append(stepNodes, nb)
if nb.MapKey.Value.Map != nil && len(nb.MapKey.Value.Map.Nodes) > 0 {
stepNodes = append(stepNodes, nb)
}
}
prev = n
continue
@ -333,12 +346,14 @@ func (p *printer) _map(m *d2ast.Map) {
n := boards[i].Unbox()
// if this board is the very first line of the file, don't add an extra newline
if n.GetRange().Start.Line != 0 {
p.newline()
p.sb.WriteByte('\n')
}
// if scope only has boards, don't newline the first board
if i != 0 || len(m.Nodes) > len(boards) {
p.newline()
p.sb.WriteByte('\n')
}
p.sb.WriteString(p.indentStr)
p.node(n)
prev = n
}
@ -358,6 +373,9 @@ func (p *printer) _map(m *d2ast.Map) {
func (p *printer) mapKey(mk *d2ast.Key) {
if mk.Ampersand {
p.sb.WriteByte('&')
} else if mk.NotAmpersand {
p.sb.WriteByte('!')
p.sb.WriteByte('&')
}
if mk.Key != nil {
p.key(mk.Key)

View file

@ -27,7 +27,6 @@ x -> y
exp: `x -> y
`,
},
{
name: "complex",
in: `
@ -153,7 +152,7 @@ meow
diagram: int {constraint: foreign_key}
}
meow <- diagrams.id
steps: {
shape: sql_table
id: {type: int; constraint: primary_key}
@ -665,8 +664,8 @@ x: @"x/../file"
b: {
e
scenarios: {
p: {
x
p: {
x
}
}
}
@ -678,14 +677,14 @@ x: @"x/../file"
exp: `layers: {
b: {
e
scenarios: {
p: {
x
}
}
}
steps: {
a
}
@ -763,7 +762,7 @@ only-layers: {
X
Y
}
layers: {
Z
}
@ -773,10 +772,10 @@ layers: {
Test super nested: {
base-layer
last-layer
layers: {
layer-board
layers: {
grand-child-layer: {
grand-child-board
@ -789,7 +788,7 @@ layers: {
scenarios: {
scenario-1: {
non-step
steps: {
step-1: {
Test
@ -829,6 +828,70 @@ mybox: {
mybox: {
label: prefix${test}suffix
}
`,
},
{
name: "not-filter",
in: `jacob: {
shape: circle
}
jeremy: {
shape: rectangle
}
*: {
!&shape: rectangle
label: I'm not a rectangle
}`,
exp: `jacob: {
shape: circle
}
jeremy: {
shape: rectangle
}
*: {
!&shape: rectangle
label: I'm not a rectangle
}
`,
},
{
name: "lowercase-reserved",
in: `jacob: {
SHAPE: circle
}
jeremy.SHAPE: rectangle
alice.STYLE.fill: red
bob.style.FILL: red
carmen.STYLE.FILL: red
coop: {
STYLE: {
FILL: blue
}
}
`,
exp: `jacob: {
shape: circle
}
jeremy.shape: rectangle
alice.style.fill: red
bob.style.fill: red
carmen.style.fill: red
coop: {
style: {
fill: blue
}
}
`,
},
{
name: "remove-empty-boards",
in: `k
layers
scenarios: {}
steps: asdf
`,
exp: `k
`,
},
}

View file

@ -60,6 +60,11 @@ type Graph struct {
// Object.Level uses the location of a nested graph
RootLevel int `json:"rootLevel,omitempty"`
// Currently this holds data embedded from source code configuration variables
// Plugins only have access to exported graph, so this data structure allows
// carrying arbitrary metadata that any plugin might handle
Data map[string]interface{} `json:"data,omitempty"`
}
func NewGraph() *Graph {
@ -79,6 +84,66 @@ func (g *Graph) RootBoard() *Graph {
return g
}
func (g *Graph) IDA() []string {
if g == nil {
return nil
}
var parts []string
current := g
for current != nil {
if current.Name != "" {
parts = append(parts, current.Name)
}
current = current.Parent
}
for i := 0; i < len(parts)/2; i++ {
j := len(parts) - 1 - i
parts[i], parts[j] = parts[j], parts[i]
}
if len(parts) == 0 {
return []string{"root"}
}
parts = append([]string{"root"}, parts...)
if g.Parent != nil {
var containerName string
if len(g.Parent.Layers) > 0 {
for _, l := range g.Parent.Layers {
if l == g {
containerName = "layers"
break
}
}
}
if len(g.Parent.Scenarios) > 0 {
for _, s := range g.Parent.Scenarios {
if s == g {
containerName = "scenarios"
break
}
}
}
if len(g.Parent.Steps) > 0 {
for _, s := range g.Parent.Steps {
if s == g {
containerName = "steps"
break
}
}
}
if containerName != "" {
parts = append(parts[:1], append([]string{containerName}, parts[1:]...)...)
}
}
return parts
}
type LayoutGraph func(context.Context, *Graph) error
type RouteEdges func(context.Context, *Graph, []*Edge) error
@ -197,6 +262,7 @@ type Reference struct {
Scope *d2ast.Map `json:"-"`
ScopeObj *Object `json:"-"`
ScopeAST *d2ast.Map `json:"-"`
IsVar bool `json:"-"`
}
func (r Reference) MapKeyEdgeDest() bool {
@ -252,24 +318,24 @@ func (s *Style) Apply(key, value string) error {
if s.Stroke == nil {
break
}
if !go2.Contains(color.NamedColors, strings.ToLower(value)) && !color.ColorHexRegex.MatchString(value) {
return errors.New(`expected "stroke" to be a valid named color ("orange") or a hex code ("#f0ff3a")`)
if !color.ValidColor(value) {
return errors.New(`expected "stroke" to be a valid named color ("orange"), a hex code ("#f0ff3a"), or a gradient ("linear-gradient(red, blue)")`)
}
s.Stroke.Value = value
case "fill":
if s.Fill == nil {
break
}
if !go2.Contains(color.NamedColors, strings.ToLower(value)) && !color.ColorHexRegex.MatchString(value) {
return errors.New(`expected "fill" to be a valid named color ("orange") or a hex code ("#f0ff3a")`)
if !color.ValidColor(value) {
return errors.New(`expected "fill" to be a valid named color ("orange"), a hex code ("#f0ff3a"), or a gradient ("linear-gradient(red, blue)")`)
}
s.Fill.Value = value
case "fill-pattern":
if s.FillPattern == nil {
break
}
if !go2.Contains(FillPatterns, strings.ToLower(value)) {
return fmt.Errorf(`expected "fill-pattern" to be one of: %s`, strings.Join(FillPatterns, ", "))
if !go2.Contains(d2ast.FillPatterns, strings.ToLower(value)) {
return fmt.Errorf(`expected "fill-pattern" to be one of: %s`, strings.Join(d2ast.FillPatterns, ", "))
}
s.FillPattern.Value = value
case "stroke-width":
@ -347,8 +413,8 @@ func (s *Style) Apply(key, value string) error {
if s.FontColor == nil {
break
}
if !go2.Contains(color.NamedColors, strings.ToLower(value)) && !color.ColorHexRegex.MatchString(value) {
return errors.New(`expected "font-color" to be a valid named color ("orange") or a hex code ("#f0ff3a")`)
if !color.ValidColor(value) {
return errors.New(`expected "font-color" to be a valid named color ("orange"), a hex code ("#f0ff3a"), or a gradient ("linear-gradient(red, blue)")`)
}
s.FontColor.Value = value
case "animated":
@ -409,8 +475,8 @@ func (s *Style) Apply(key, value string) error {
if s.TextTransform == nil {
break
}
if !go2.Contains(textTransforms, strings.ToLower(value)) {
return fmt.Errorf(`expected "text-transform" to be one of (%s)`, strings.Join(textTransforms, ", "))
if !go2.Contains(d2ast.TextTransforms, strings.ToLower(value)) {
return fmt.Errorf(`expected "text-transform" to be one of (%s)`, strings.Join(d2ast.TextTransforms, ", "))
}
s.TextTransform.Value = value
default:
@ -628,7 +694,10 @@ func (obj *Object) Text() *d2target.MText {
}
}
func (obj *Object) newObject(id string) *Object {
func (obj *Object) newObject(ids d2ast.String) *Object {
id := d2format.Format(&d2ast.KeyPath{
Path: []*d2ast.StringBox{d2ast.MakeValueBox(d2ast.RawString(ids.ScalarString(), true)).StringBox()},
})
idval := id
k, _ := d2parser.ParseKey(id)
if k != nil && len(k.Path) > 0 {
@ -667,7 +736,7 @@ func (obj *Object) HasChild(ids []string) (*Object, bool) {
return obj, true
}
if len(ids) == 1 && ids[0] != "style" {
_, ok := ReservedKeywords[ids[0]]
_, ok := d2ast.ReservedKeywords[ids[0]]
if ok {
return obj, true
}
@ -781,7 +850,7 @@ func (obj *Object) FindEdges(mk *d2ast.Key) ([]*Edge, bool) {
return ea, true
}
func (obj *Object) ensureChildEdge(ida []string) *Object {
func (obj *Object) ensureChildEdge(ida []d2ast.String) *Object {
for i := range ida {
switch obj.Shape.Value {
case d2target.ShapeClass, d2target.ShapeSQLTable:
@ -797,12 +866,12 @@ func (obj *Object) ensureChildEdge(ida []string) *Object {
// EnsureChild grabs the child by ids or creates it if it does not exist including all
// intermediate nodes.
func (obj *Object) EnsureChild(ida []string) *Object {
func (obj *Object) EnsureChild(ida []d2ast.String) *Object {
seq := obj.OuterSequenceDiagram()
if seq != nil {
for _, c := range seq.ChildrenArray {
if c.ID == ida[0] {
if obj.ID == ida[0] {
if c.ID == ida[0].ScalarString() {
if obj.ID == ida[0].ScalarString() {
// In cases of a.a where EnsureChild is called on the parent a, the second a should
// be created as a child of a and not as a child of the diagram. This is super
// unfortunate code but alas.
@ -818,9 +887,11 @@ func (obj *Object) EnsureChild(ida []string) *Object {
return obj
}
_, is := ReservedKeywordHolders[ida[0]]
_, is := d2ast.ReservedKeywordHolders[ida[0].ScalarString()]
is = is && ida[0].IsUnquoted()
if len(ida) == 1 && !is {
_, ok := ReservedKeywords[ida[0]]
_, ok := d2ast.ReservedKeywords[ida[0].ScalarString()]
ok = ok && ida[0].IsUnquoted()
if ok {
return obj
}
@ -829,11 +900,14 @@ func (obj *Object) EnsureChild(ida []string) *Object {
id := ida[0]
ida = ida[1:]
if id == "_" {
if id.ScalarString() == "_" && id.IsUnquoted() {
return obj.Parent.EnsureChild(ida)
}
child, ok := obj.Children[strings.ToLower(id)]
head := d2format.Format(&d2ast.KeyPath{
Path: []*d2ast.StringBox{d2ast.MakeValueBox(d2ast.RawString(id.ScalarString(), true)).StringBox()},
})
child, ok := obj.Children[strings.ToLower(head)]
if !ok {
child = obj.newObject(id)
}
@ -986,7 +1060,7 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
}
if anyRowText != nil {
rowHeight := GetTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + d2target.VerticalPadding
dims.Height = rowHeight * (len(obj.Class.Fields) + len(obj.Class.Methods) + 2)
dims.Height = rowHeight*(len(obj.Class.Fields)+len(obj.Class.Methods)) + go2.Max(2*rowHeight, labelDims.Height+2*label.PADDING)
} else {
dims.Height = 2*go2.Max(12, labelDims.Height) + d2target.VerticalPadding
}
@ -1120,7 +1194,7 @@ func (obj *Object) IsConstantNear() bool {
if isKey {
return false
}
_, isConst := NearConstants[keyPath[0]]
_, isConst := d2ast.NearConstants[keyPath[0]]
return isConst
}
@ -1232,10 +1306,10 @@ func (e *Edge) AbsID() string {
return fmt.Sprintf("%s(%s %s %s)[%d]", commonKey, strings.Join(srcIDA, "."), e.ArrowString(), strings.Join(dstIDA, "."), e.Index)
}
func (obj *Object) Connect(srcID, dstID []string, srcArrow, dstArrow bool, label string) (*Edge, error) {
for _, id := range [][]string{srcID, dstID} {
func (obj *Object) Connect(srcID, dstID []d2ast.String, srcArrow, dstArrow bool, label string) (*Edge, error) {
for _, id := range [][]d2ast.String{srcID, dstID} {
for _, p := range id {
if _, ok := ReservedKeywords[p]; ok {
if _, ok := d2ast.ReservedKeywords[p.ScalarString()]; ok && p.IsUnquoted() {
return nil, errors.New("cannot connect to reserved keyword")
}
}
@ -1263,7 +1337,7 @@ func (obj *Object) Connect(srcID, dstID []string, srcArrow, dstArrow bool, label
return e, nil
}
func addSQLTableColumnIndices(e *Edge, srcID, dstID []string, obj, src, dst *Object) {
func addSQLTableColumnIndices(e *Edge, srcID, dstID []d2ast.String, obj, src, dst *Object) {
if src.Shape.Value == d2target.ShapeSQLTable {
if src == dst {
// Ignore edge to column inside table.
@ -1273,7 +1347,7 @@ func addSQLTableColumnIndices(e *Edge, srcID, dstID []string, obj, src, dst *Obj
srcAbsID := src.AbsIDArray()
if len(objAbsID)+len(srcID) > len(srcAbsID) {
for i, d2col := range src.SQLTable.Columns {
if d2col.Name.Label == srcID[len(srcID)-1] {
if d2col.Name.Label == srcID[len(srcID)-1].ScalarString() {
d2col.Reference = dst.AbsID()
e.SrcTableColumnIndex = new(int)
*e.SrcTableColumnIndex = i
@ -1287,7 +1361,7 @@ func addSQLTableColumnIndices(e *Edge, srcID, dstID []string, obj, src, dst *Obj
dstAbsID := dst.AbsIDArray()
if len(objAbsID)+len(dstID) > len(dstAbsID) {
for i, d2col := range dst.SQLTable.Columns {
if d2col.Name.Label == dstID[len(dstID)-1] {
if d2col.Name.Label == dstID[len(dstID)-1].ScalarString() {
d2col.Reference = dst.AbsID()
e.DstTableColumnIndex = new(int)
*e.DstTableColumnIndex = i
@ -1428,12 +1502,12 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
// user-specified label/icon positions
if obj.HasLabel() && obj.Attributes.LabelPosition != nil {
scalar := *obj.Attributes.LabelPosition
position := LabelPositionsMapping[scalar.Value]
position := d2ast.LabelPositionsMapping[scalar.Value]
obj.LabelPosition = go2.Pointer(position.String())
}
if obj.Icon != nil && obj.Attributes.IconPosition != nil {
scalar := *obj.Attributes.IconPosition
position := LabelPositionsMapping[scalar.Value]
position := d2ast.LabelPositionsMapping[scalar.Value]
obj.IconPosition = go2.Pointer(position.String())
}
@ -1538,11 +1612,8 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
switch shapeType {
case shape.TABLE_TYPE, shape.CLASS_TYPE, shape.CODE_TYPE:
default:
if obj.Link != nil {
paddingX += 32
}
if obj.Tooltip != nil {
paddingX += 32
if obj.Link != nil && obj.Tooltip != nil {
paddingX += 64
}
}
}
@ -1673,205 +1744,6 @@ func Key(k *d2ast.KeyPath) []string {
return d2format.KeyPath(k)
}
// All reserved keywords. See init below.
var ReservedKeywords map[string]struct{}
// Non Style/Holder keywords.
var SimpleReservedKeywords = map[string]struct{}{
"label": {},
"desc": {},
"shape": {},
"icon": {},
"constraint": {},
"tooltip": {},
"link": {},
"near": {},
"width": {},
"height": {},
"direction": {},
"top": {},
"left": {},
"grid-rows": {},
"grid-columns": {},
"grid-gap": {},
"vertical-gap": {},
"horizontal-gap": {},
"class": {},
"vars": {},
}
// ReservedKeywordHolders are reserved keywords that are meaningless on its own and must hold composites
var ReservedKeywordHolders = map[string]struct{}{
"style": {},
"source-arrowhead": {},
"target-arrowhead": {},
}
// CompositeReservedKeywords are reserved keywords that can hold composites
var CompositeReservedKeywords = map[string]struct{}{
"classes": {},
"constraint": {},
"label": {},
"icon": {},
}
// StyleKeywords are reserved keywords which cannot exist outside of the "style" keyword
var StyleKeywords = map[string]struct{}{
"opacity": {},
"stroke": {},
"fill": {},
"fill-pattern": {},
"stroke-width": {},
"stroke-dash": {},
"border-radius": {},
// Only for text
"font": {},
"font-size": {},
"font-color": {},
"bold": {},
"italic": {},
"underline": {},
"text-transform": {},
// Only for shapes
"shadow": {},
"multiple": {},
"double-border": {},
// Only for squares
"3d": {},
// Only for edges
"animated": {},
"filled": {},
}
// TODO maybe autofmt should allow other values, and transform them to conform
// e.g. left-center becomes center-left
var NearConstantsArray = []string{
"top-left",
"top-center",
"top-right",
"center-left",
"center-right",
"bottom-left",
"bottom-center",
"bottom-right",
}
var NearConstants map[string]struct{}
// LabelPositionsArray are the values that labels and icons can set `near` to
var LabelPositionsArray = []string{
"top-left",
"top-center",
"top-right",
"center-left",
"center-center",
"center-right",
"bottom-left",
"bottom-center",
"bottom-right",
"outside-top-left",
"outside-top-center",
"outside-top-right",
"outside-left-top",
"outside-left-center",
"outside-left-bottom",
"outside-right-top",
"outside-right-center",
"outside-right-bottom",
"outside-bottom-left",
"outside-bottom-center",
"outside-bottom-right",
}
var LabelPositions map[string]struct{}
// convert to label.Position
var LabelPositionsMapping = map[string]label.Position{
"top-left": label.InsideTopLeft,
"top-center": label.InsideTopCenter,
"top-right": label.InsideTopRight,
"center-left": label.InsideMiddleLeft,
"center-center": label.InsideMiddleCenter,
"center-right": label.InsideMiddleRight,
"bottom-left": label.InsideBottomLeft,
"bottom-center": label.InsideBottomCenter,
"bottom-right": label.InsideBottomRight,
"outside-top-left": label.OutsideTopLeft,
"outside-top-center": label.OutsideTopCenter,
"outside-top-right": label.OutsideTopRight,
"outside-left-top": label.OutsideLeftTop,
"outside-left-center": label.OutsideLeftMiddle,
"outside-left-bottom": label.OutsideLeftBottom,
"outside-right-top": label.OutsideRightTop,
"outside-right-center": label.OutsideRightMiddle,
"outside-right-bottom": label.OutsideRightBottom,
"outside-bottom-left": label.OutsideBottomLeft,
"outside-bottom-center": label.OutsideBottomCenter,
"outside-bottom-right": label.OutsideBottomRight,
}
var FillPatterns = []string{
"none",
"dots",
"lines",
"grain",
"paper",
}
var textTransforms = []string{"none", "uppercase", "lowercase", "capitalize"}
// BoardKeywords contains the keywords that create new boards.
var BoardKeywords = map[string]struct{}{
"layers": {},
"scenarios": {},
"steps": {},
}
func init() {
ReservedKeywords = make(map[string]struct{})
for k, v := range SimpleReservedKeywords {
ReservedKeywords[k] = v
}
for k, v := range StyleKeywords {
ReservedKeywords[k] = v
}
for k, v := range ReservedKeywordHolders {
CompositeReservedKeywords[k] = v
}
for k, v := range BoardKeywords {
CompositeReservedKeywords[k] = v
}
for k, v := range CompositeReservedKeywords {
ReservedKeywords[k] = v
}
NearConstants = make(map[string]struct{}, len(NearConstantsArray))
for _, k := range NearConstantsArray {
NearConstants[k] = struct{}{}
}
LabelPositions = make(map[string]struct{}, len(LabelPositionsArray))
for _, k := range LabelPositionsArray {
LabelPositions[k] = struct{}{}
}
}
func (g *Graph) GetBoard(name string) *Graph {
for _, l := range g.Layers {
if l.Name == name {
@ -1901,6 +1773,11 @@ func (g *Graph) SortObjectsByAST() {
}
r1 := o1.References[0]
r2 := o2.References[0]
// If they are variable substitutions, leave them alone, as their
// references reflect where the variable is, not where the substitution is
if r1.IsVar || r2.IsVar {
return i < j
}
return r1.Key.Path[r1.KeyPathIndex].Unbox().GetRange().Before(r2.Key.Path[r2.KeyPathIndex].Unbox().GetRange())
})
g.Objects = objects

View file

@ -1,6 +1,8 @@
package d2graph
import "oss.terrastruct.com/d2/d2target"
import (
"oss.terrastruct.com/d2/d2target"
)
func (obj *Object) IsSequenceDiagram() bool {
return obj != nil && obj.Shape.Value == d2target.ShapeSequenceDiagram

View file

@ -10,10 +10,11 @@ import (
)
type SerializedGraph struct {
Root SerializedObject `json:"root"`
Edges []SerializedEdge `json:"edges"`
Objects []SerializedObject `json:"objects"`
RootLevel int `json:"rootLevel"`
Root SerializedObject `json:"root"`
Edges []SerializedEdge `json:"edges"`
Objects []SerializedObject `json:"objects"`
RootLevel int `json:"rootLevel"`
Data map[string]interface{} `json:"data,omitempty"`
}
type SerializedObject map[string]interface{}
@ -27,6 +28,7 @@ func DeserializeGraph(bytes []byte, g *Graph) error {
return err
}
g.Data = sg.Data
var root Object
Convert(sg.Root, &root)
g.Root = &root
@ -95,6 +97,7 @@ func SerializeGraph(g *Graph) ([]byte, error) {
}
sg.Root = root
sg.RootLevel = g.RootLevel
sg.Data = g.Data
var sobjects []SerializedObject
for _, o := range g.Objects {

View file

@ -1,7 +1,10 @@
package d2ir
import (
"html"
"io/fs"
"net/url"
"path"
"strconv"
"strings"
@ -12,6 +15,7 @@ import (
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/textmeasure"
)
type globContext struct {
@ -84,12 +88,12 @@ func Compile(ast *d2ast.Map, opts *CompileOptions) (*Map, []string, error) {
}
func (c *compiler) overlayClasses(m *Map) {
classes := m.GetField("classes")
classes := m.GetField(d2ast.FlatUnquotedString("classes"))
if classes == nil || classes.Map() == nil {
return
}
layersField := m.GetField("layers")
layersField := m.GetField(d2ast.FlatUnquotedString("layers"))
if layersField == nil {
return
}
@ -100,11 +104,10 @@ func (c *compiler) overlayClasses(m *Map) {
for _, lf := range layers.Fields {
if lf.Map() == nil || lf.Primary() != nil {
c.errorf(lf.References[0].Context_.Key, "invalid layer")
continue
}
l := lf.Map()
lClasses := l.GetField("classes")
lClasses := l.GetField(d2ast.FlatUnquotedString("classes"))
if lClasses == nil {
lClasses = classes.Copy(l).(*Field)
@ -122,7 +125,10 @@ func (c *compiler) overlayClasses(m *Map) {
func (c *compiler) compileSubstitutions(m *Map, varsStack []*Map) {
for _, f := range m.Fields {
if f.Name == "vars" && f.Map() != nil {
if f.Name == nil {
continue
}
if f.Name.ScalarString() == "vars" && f.Name.IsUnquoted() && f.Map() != nil {
varsStack = append([]*Map{f.Map()}, varsStack...)
}
}
@ -144,10 +150,9 @@ func (c *compiler) compileSubstitutions(m *Map, varsStack []*Map) {
}
}
} else if f.Map() != nil {
// don't resolve substitutions in vars with the current scope of vars
if f.Name == "vars" {
c.compileSubstitutions(f.Map(), varsStack[1:])
c.validateConfigs(f.Map().GetField("d2-config"))
if f.Name != nil && f.Name.ScalarString() == "vars" && f.Name.IsUnquoted() {
c.compileSubstitutions(f.Map(), varsStack)
c.validateConfigs(f.Map().GetField(d2ast.FlatUnquotedString("d2-config")))
} else {
c.compileSubstitutions(f.Map(), varsStack)
}
@ -169,37 +174,37 @@ func (c *compiler) validateConfigs(configs *Field) {
}
if NodeBoardKind(ParentMap(ParentMap(configs))) == "" {
c.errorf(configs.LastRef().AST(), `"%s" can only appear at root vars`, configs.Name)
c.errorf(configs.LastRef().AST(), `"%s" can only appear at root vars`, configs.Name.ScalarString())
return
}
for _, f := range configs.Map().Fields {
var val string
if f.Primary() == nil {
if f.Name != "theme-overrides" && f.Name != "dark-theme-overrides" {
c.errorf(f.LastRef().AST(), `"%s" needs a value`, f.Name)
if f.Name.ScalarString() != "theme-overrides" && f.Name.ScalarString() != "dark-theme-overrides" && f.Name.ScalarString() != "data" {
c.errorf(f.LastRef().AST(), `"%s" needs a value`, f.Name.ScalarString())
continue
}
} else {
val = f.Primary().Value.ScalarString()
}
switch f.Name {
switch f.Name.ScalarString() {
case "sketch", "center":
_, err := strconv.ParseBool(val)
if err != nil {
c.errorf(f.LastRef().AST(), `expected a boolean for "%s", got "%s"`, f.Name, val)
c.errorf(f.LastRef().AST(), `expected a boolean for "%s", got "%s"`, f.Name.ScalarString(), val)
continue
}
case "theme-overrides", "dark-theme-overrides":
case "theme-overrides", "dark-theme-overrides", "data":
if f.Map() == nil {
c.errorf(f.LastRef().AST(), `"%s" needs a map`, f.Name)
c.errorf(f.LastRef().AST(), `"%s" needs a map`, f.Name.ScalarString())
continue
}
case "theme-id", "dark-theme-id":
valInt, err := strconv.Atoi(val)
if err != nil {
c.errorf(f.LastRef().AST(), `expected an integer for "%s", got "%s"`, f.Name, val)
c.errorf(f.LastRef().AST(), `expected an integer for "%s", got "%s"`, f.Name.ScalarString(), val)
continue
}
if d2themescatalog.Find(int64(valInt)) == (d2themes.Theme{}) {
@ -209,12 +214,12 @@ func (c *compiler) validateConfigs(configs *Field) {
case "pad":
_, err := strconv.Atoi(val)
if err != nil {
c.errorf(f.LastRef().AST(), `expected an integer for "%s", got "%s"`, f.Name, val)
c.errorf(f.LastRef().AST(), `expected an integer for "%s", got "%s"`, f.Name.ScalarString(), val)
continue
}
case "layout-engine":
default:
c.errorf(f.LastRef().AST(), `"%s" is not a valid config`, f.Name)
c.errorf(f.LastRef().AST(), `"%s" is not a valid config`, f.Name.ScalarString())
}
}
}
@ -227,8 +232,8 @@ func (c *compiler) resolveSubstitutions(varsStack []*Map, node Node) (removedFie
case *d2ast.UnquotedString:
for i, box := range s.Value {
if box.Substitution != nil {
for _, vars := range varsStack {
resolvedField = c.resolveSubstitution(vars, box.Substitution)
for i, vars := range varsStack {
resolvedField = c.resolveSubstitution(vars, node, box.Substitution, i == 0)
if resolvedField != nil {
if resolvedField.Primary() != nil {
if _, ok := resolvedField.Primary().Value.(*d2ast.Null); ok {
@ -262,11 +267,11 @@ func (c *compiler) resolveSubstitutions(varsStack []*Map, node Node) (removedFie
}
}
case *Field:
m := ParentMap(n)
if resolvedField.Map() != nil {
OverlayMap(ParentMap(n), resolvedField.Map())
ExpandSubstitution(m, resolvedField.Map(), n)
}
// Remove the placeholder field
m := n.parent.(*Map)
for i, f2 := range m.Fields {
if n == f2 {
m.Fields = append(m.Fields[:i], m.Fields[i+1:]...)
@ -319,8 +324,8 @@ func (c *compiler) resolveSubstitutions(varsStack []*Map, node Node) (removedFie
case *d2ast.DoubleQuotedString:
for i, box := range s.Value {
if box.Substitution != nil {
for _, vars := range varsStack {
resolvedField = c.resolveSubstitution(vars, box.Substitution)
for i, vars := range varsStack {
resolvedField = c.resolveSubstitution(vars, node, box.Substitution, i == 0)
if resolvedField != nil {
break
}
@ -340,20 +345,64 @@ func (c *compiler) resolveSubstitutions(varsStack []*Map, node Node) (removedFie
if subbed {
s.Coalesce()
}
case *d2ast.BlockString:
variables := make(map[string]string)
for _, vars := range varsStack {
c.collectVariables(vars, variables)
}
preprocessedValue := textmeasure.ReplaceSubstitutionsMarkdown(s.Value, variables)
// Update the block string value
s.Value = preprocessedValue
}
return removedField
}
func (c *compiler) resolveSubstitution(vars *Map, substitution *d2ast.Substitution) *Field {
func (c *compiler) collectVariables(vars *Map, variables map[string]string) {
if vars == nil {
return
}
for _, f := range vars.Fields {
if f.Primary() != nil {
variables[f.Name.ScalarString()] = f.Primary().Value.ScalarString()
} else if f.Map() != nil {
c.collectVariables(f.Map(), variables)
}
}
}
func (c *compiler) resolveSubstitution(vars *Map, node Node, substitution *d2ast.Substitution, isCurrentScopeVars bool) *Field {
if vars == nil {
return nil
}
fieldNode, fok := node.(*Field)
parent := ParentField(node)
for i, p := range substitution.Path {
f := vars.GetField(p.Unbox().ScalarString())
f := vars.GetField(p.Unbox())
if f == nil {
return nil
}
// Consider this case:
//
// ```
// vars: {
// x: a
// }
// hi: {
// vars: {
// x: ${x}-b
// }
// yo: ${x}
// }
// ```
//
// When resolving hi.vars.x, the vars stack includes itself.
// So this next if clause says, "ignore if we're using the current scope's vars to try to resolve a substitution that requires a var from further in the stack"
if fok && fieldNode.Name != nil && fieldNode.Name.ScalarString() == p.Unbox().ScalarString() && isCurrentScopeVars && parent.Name.ScalarString() == "vars" && parent.Name.IsUnquoted() {
return nil
}
if i == len(substitution.Path)-1 {
return f
@ -382,17 +431,15 @@ func (g *globContext) copy() *globContext {
return &g2
}
func (g *globContext) prefixed(dst *Map) *globContext {
g2 := g.copy()
prefix := d2ast.MakeKeyPath(RelIDA(g2.refctx.ScopeMap, dst))
g2.refctx.Key = g2.refctx.Key.Copy()
if g2.refctx.Key.Key != nil {
prefix.Path = append(prefix.Path, g2.refctx.Key.Key.Path...)
func (g *globContext) copyApplied(from *globContext) {
g.appliedFields = make(map[string]struct{})
for k, v := range from.appliedFields {
g.appliedFields[k] = v
}
if len(prefix.Path) > 0 {
g2.refctx.Key.Key = prefix
g.appliedEdges = make(map[string]struct{})
for k, v := range from.appliedEdges {
g.appliedEdges[k] = v
}
return g2
}
func (c *compiler) ampersandFilterMap(dst *Map, ast, scopeAST *d2ast.Map) bool {
@ -405,6 +452,9 @@ func (c *compiler) ampersandFilterMap(dst *Map, ast, scopeAST *d2ast.Map) bool {
ScopeMap: dst,
ScopeAST: scopeAST,
})
if n.MapKey.NotAmpersand {
ok = !ok
}
if !ok {
if len(c.mapRefContextStack) == 0 {
return false
@ -415,10 +465,10 @@ func (c *compiler) ampersandFilterMap(dst *Map, ast, scopeAST *d2ast.Map) bool {
return false
}
var ks string
if gctx.refctx.Key.HasTripleGlob() {
ks = d2format.Format(d2ast.MakeKeyPath(IDA(dst)))
if gctx.refctx.Key.HasMultiGlob() {
ks = d2format.Format(d2ast.MakeKeyPathString(IDA(dst)))
} else {
ks = d2format.Format(d2ast.MakeKeyPath(BoardIDA(dst)))
ks = d2format.Format(d2ast.MakeKeyPathString(BoardIDA(dst)))
}
delete(gctx.appliedFields, ks)
delete(gctx.appliedEdges, ks)
@ -438,13 +488,38 @@ func (c *compiler) compileMap(dst *Map, ast, scopeAST *d2ast.Map) {
if NodeBoardKind(dst) == BoardLayer && !dst.Root() {
for _, g := range previousGlobs {
if g.refctx.Key.HasTripleGlob() {
globs = append(globs, g.prefixed(dst))
gctx2 := g.copy()
gctx2.refctx.ScopeMap = dst
globs = append(globs, gctx2)
}
}
} else if NodeBoardKind(dst) != "" {
// Make all globs relative to the scenario or step.
} else if NodeBoardKind(dst) == BoardScenario {
for _, g := range previousGlobs {
globs = append(globs, g.prefixed(dst))
gctx2 := g.copy()
gctx2.refctx.ScopeMap = dst
if !g.refctx.Key.HasMultiGlob() {
// Triple globs already apply independently to each board
gctx2.copyApplied(g)
}
globs = append(globs, gctx2)
}
for _, g := range previousGlobs {
g2 := g.copy()
g2.refctx.ScopeMap = dst
// We don't want globs applied in a given scenario to affect future boards
// Copying the applied fields and edges keeps the applications scoped to this board
// Note that this is different from steps, where applications carry over
if !g.refctx.Key.HasMultiGlob() {
// Triple globs already apply independently to each board
g2.copyApplied(g)
}
globs = append(globs, g2)
}
} else if NodeBoardKind(dst) == BoardStep {
for _, g := range previousGlobs {
gctx2 := g.copy()
gctx2.refctx.ScopeMap = dst
globs = append(globs, gctx2)
}
} else {
globs = append(globs, previousGlobs...)
@ -489,6 +564,7 @@ func (c *compiler) compileMap(dst *Map, ast, scopeAST *d2ast.Map) {
}
dst.Fields = append(dst.Fields, f)
case n.Import != nil:
// Spread import
impn, ok := c._import(n.Import)
if !ok {
continue
@ -497,6 +573,7 @@ func (c *compiler) compileMap(dst *Map, ast, scopeAST *d2ast.Map) {
c.errorf(n.Import, "cannot spread import non map into map")
continue
}
impn.(Importable).SetImportAST(n.Import)
for _, gctx := range impn.Map().globs {
if !gctx.refctx.Key.HasTripleGlob() {
@ -509,6 +586,8 @@ func (c *compiler) compileMap(dst *Map, ast, scopeAST *d2ast.Map) {
}
OverlayMap(dst, impn.Map())
impDir := n.Import.Dir()
c.extendLinks(dst, ParentField(dst), impDir)
if impnf, ok := impn.(*Field); ok {
if impnf.Primary_ != nil {
@ -587,7 +666,7 @@ func (c *compiler) compileKey(refctx *RefContext) {
}
func (c *compiler) compileField(dst *Map, kp *d2ast.KeyPath, refctx *RefContext) {
if refctx.Key.Ampersand {
if refctx.Key.Ampersand || refctx.Key.NotAmpersand {
return
}
@ -603,7 +682,7 @@ func (c *compiler) compileField(dst *Map, kp *d2ast.KeyPath, refctx *RefContext)
}
func (c *compiler) ampersandFilter(refctx *RefContext) bool {
if !refctx.Key.Ampersand {
if !refctx.Key.Ampersand && !refctx.Key.NotAmpersand {
return true
}
if len(c.mapRefContextStack) == 0 || !c.mapRefContextStack[len(c.mapRefContextStack)-1].Key.SupportsGlobFilters() {
@ -620,44 +699,105 @@ func (c *compiler) ampersandFilter(refctx *RefContext) bool {
return false
}
if len(fa) == 0 {
if refctx.Key.Key.Last().ScalarString() != "label" {
if refctx.Key.Value.ScalarBox().Unbox().ScalarString() == "*" {
return false
}
kp := refctx.Key.Key.Copy()
kp.Path = kp.Path[:len(kp.Path)-1]
if len(kp.Path) == 0 {
n := refctx.ScopeMap.Parent()
switch n := n.(type) {
case *Field:
fa = append(fa, n)
case *Edge:
if n.Primary_ == nil {
if refctx.Key.Value.ScalarBox().Unbox().ScalarString() == "" {
return true
}
return false
}
if n.Primary_.Value.ScalarString() != refctx.Key.Value.ScalarBox().Unbox().ScalarString() {
return false
}
// The field/edge has no value for this filter
// But the filter might still match default, e.g. opacity 1
// So we make a fake field for the default
// NOTE: this does not apply to things that themes control, like stroke and fill
// Nor does it apply to layout things like width and height
switch refctx.Key.Key.Last().ScalarString() {
case "shape":
f := &Field{
Primary_: &Scalar{
Value: d2ast.FlatUnquotedString("rectangle"),
},
}
} else {
fa, err = refctx.ScopeMap.EnsureField(kp, refctx, false, c)
return c._ampersandFilter(f, refctx)
case "border-radius", "stroke-dash":
f := &Field{
Primary_: &Scalar{
Value: d2ast.FlatUnquotedString("0"),
},
}
return c._ampersandFilter(f, refctx)
case "opacity":
f := &Field{
Primary_: &Scalar{
Value: d2ast.FlatUnquotedString("1"),
},
}
return c._ampersandFilter(f, refctx)
case "stroke-width":
f := &Field{
Primary_: &Scalar{
Value: d2ast.FlatUnquotedString("2"),
},
}
return c._ampersandFilter(f, refctx)
case "icon", "tooltip", "link":
f := &Field{
Primary_: &Scalar{
Value: d2ast.FlatUnquotedString(""),
},
}
return c._ampersandFilter(f, refctx)
case "shadow", "multiple", "3d", "animated", "filled":
f := &Field{
Primary_: &Scalar{
Value: d2ast.FlatUnquotedString("false"),
},
}
return c._ampersandFilter(f, refctx)
case "leaf":
raw := refctx.Key.Value.ScalarBox().Unbox().ScalarString()
boolVal, err := strconv.ParseBool(raw)
if err != nil {
c.err.Errors = append(c.err.Errors, err.(d2ast.Error))
c.errorf(refctx.Key, `&leaf must be "true" or "false", got %q`, raw)
return false
}
}
for _, f := range fa {
label := f.Name
if f.Primary_ != nil {
label = f.Primary_.Value.ScalarString()
}
if label != refctx.Key.Value.ScalarBox().Unbox().ScalarString() {
f := refctx.ScopeMap.Parent().(*Field)
isLeaf := f.Map() == nil || !f.Map().IsContainer()
return isLeaf == boolVal
case "connected":
raw := refctx.Key.Value.ScalarBox().Unbox().ScalarString()
boolVal, err := strconv.ParseBool(raw)
if err != nil {
c.errorf(refctx.Key, `&connected must be "true" or "false", got %q`, raw)
return false
}
f := refctx.ScopeMap.Parent().(*Field)
isConnected := false
for _, r := range f.References {
if r.InEdge() {
isConnected = true
break
}
}
return isConnected == boolVal
case "label":
f := &Field{}
n := refctx.ScopeMap.Parent()
if n.Primary() == nil {
switch n := n.(type) {
case *Field:
// The label value for fields is their key value
f.Primary_ = &Scalar{
Value: n.Name,
}
case *Edge:
// But for edges, it's nothing
return false
}
} else {
f.Primary_ = n.Primary()
}
return c._ampersandFilter(f, refctx)
default:
return false
}
return true
}
for _, f := range fa {
ok := c._ampersandFilter(f, refctx)
@ -688,8 +828,14 @@ func (c *compiler) _ampersandFilter(f *Field, refctx *RefContext) bool {
return false
}
if refctx.Key.Value.ScalarBox().Unbox().ScalarString() != f.Primary_.Value.ScalarString() {
return false
us, ok := refctx.Key.Value.ScalarBox().Unbox().(*d2ast.UnquotedString)
if ok && us.Pattern != nil {
return matchPattern(f.Primary_.Value.ScalarString(), us.Pattern)
} else {
if refctx.Key.Value.ScalarBox().Unbox().ScalarString() != f.Primary_.Value.ScalarString() {
return false
}
}
return true
@ -715,7 +861,7 @@ func (c *compiler) _compileField(f *Field, refctx *RefContext) {
// For vars, if we delete the field, it may just resolve to an outer scope var of the same name
// Instead we keep it around, so that resolveSubstitutions can find it
if !IsVar(ParentMap(f)) {
ParentMap(f).DeleteField(f.Name)
ParentMap(f).DeleteField(f.Name.ScalarString())
return
}
}
@ -773,10 +919,12 @@ func (c *compiler) _compileField(f *Field, refctx *RefContext) {
c.overlayClasses(f.Map())
}
} else if refctx.Key.Value.Import != nil {
// Non-spread import
n, ok := c._import(refctx.Key.Value.Import)
if !ok {
return
}
n.(Importable).SetImportAST(refctx.Key.Value.Import)
switch n := n.(type) {
case *Field:
if n.Primary_ != nil {
@ -806,7 +954,8 @@ func (c *compiler) _compileField(f *Field, refctx *RefContext) {
}
}
OverlayMap(f.Map(), n)
c.updateLinks(f.Map())
impDir := refctx.Key.Value.Import.Dir()
c.extendLinks(f.Map(), f, impDir)
switch NodeBoardKind(f) {
case BoardScenario, BoardStep:
c.overlayClasses(f.Map())
@ -821,7 +970,7 @@ func (c *compiler) _compileField(f *Field, refctx *RefContext) {
Value: refctx.Key.Value.ScalarBox().Unbox(),
}
// If the link is a board, we need to transform it into an absolute path.
if f.Name == "link" {
if f.Name.ScalarString() == "link" && f.Name.IsUnquoted() {
c.compileLink(f, refctx)
}
}
@ -839,62 +988,66 @@ func (c *compiler) ignoreLazyGlob(n Node) bool {
return false
}
func (c *compiler) updateLinks(m *Map) {
// When importing a file, all of its board and icon links need to be extended to reflect their new path
func (c *compiler) extendLinks(m *Map, importF *Field, importDir string) {
nodeBoardKind := NodeBoardKind(m)
importIDA := IDA(importF)
for _, f := range m.Fields {
if f.Name == "link" {
if f.Name.ScalarString() == "link" && f.Name.IsUnquoted() {
if nodeBoardKind != "" {
c.errorf(f.LastRef().AST(), "a board itself cannot be linked; only objects within a board can be linked")
continue
}
val := f.Primary().Value.ScalarString()
u, err := url.Parse(html.UnescapeString(val))
isRemote := err == nil && u.Scheme != ""
if isRemote {
continue
}
link, err := d2parser.ParseKey(val)
if err != nil {
continue
}
linkIDA := link.IDA()
if len(linkIDA) == 0 {
continue
}
// When updateLinks is called, all valid board links are already compiled and changed to the qualified path beginning with "root"
if linkIDA[0] != "root" {
for _, id := range linkIDA[1:] {
if id.ScalarString() == "_" && id.IsUnquoted() {
if len(linkIDA) < 2 || len(importIDA) < 2 {
break
}
linkIDA = append([]d2ast.String{linkIDA[0]}, linkIDA[2:]...)
importIDA = importIDA[:len(importIDA)-2]
} else {
break
}
}
extendedIDA := append(importIDA, linkIDA[1:]...)
kp := d2ast.MakeKeyPathString(extendedIDA)
s := d2format.Format(kp)
f.Primary_.Value = d2ast.MakeValueBox(d2ast.FlatUnquotedString(s)).ScalarBox().Unbox()
}
if f.Name.ScalarString() == "icon" && f.Name.IsUnquoted() && f.Primary() != nil {
val := f.Primary().Value.ScalarString()
// It's likely a substitution
if val == "" {
continue
}
bida := BoardIDA(f)
aida := IDA(f)
if len(bida) != len(aida) {
prependIDA := aida[:len(aida)-len(bida)]
fullIDA := []string{"root"}
// With nested imports, a value may already have been updated with part of the absolute path
// E.g.,
// The import prepends path a b c
// The existing path is b c d
// So the new path is
// a b c
// b c d
// -------
// a b c d
OUTER:
for i := 1; i < len(prependIDA); i += 2 {
for j := 0; i+j < len(prependIDA); j++ {
if prependIDA[i+j] != linkIDA[1+j] {
break
}
// Reached the end and all common
if i+j == len(prependIDA)-1 {
break OUTER
}
}
fullIDA = append(fullIDA, prependIDA[i])
fullIDA = append(fullIDA, prependIDA[i+1])
}
// Chop off "root"
fullIDA = append(fullIDA, linkIDA[1:]...)
kp := d2ast.MakeKeyPath(fullIDA)
s := d2format.Format(kp)
f.Primary_.Value = d2ast.MakeValueBox(d2ast.FlatUnquotedString(s)).ScalarBox().Unbox()
u, err := url.Parse(html.UnescapeString(val))
isRemoteImg := err == nil && u.Scheme != ""
if isRemoteImg {
continue
}
val = path.Join(importDir, val)
f.Primary_.Value = d2ast.MakeValueBox(d2ast.FlatUnquotedString(val)).ScalarBox().Unbox()
}
if f.Map() != nil {
c.updateLinks(f.Map())
c.extendLinks(f.Map(), importF, importDir)
}
}
}
@ -917,47 +1070,50 @@ func (c *compiler) compileLink(f *Field, refctx *RefContext) {
return
}
if linkIDA[0] == "root" {
if linkIDA[0].ScalarString() == "root" && linkIDA[0].IsUnquoted() {
c.errorf(refctx.Key.Key, "cannot refer to root in link")
return
}
if !linkIDA[0].IsUnquoted() {
return
}
// If it doesn't start with one of these reserved words, the link is definitely not a board link.
if !strings.EqualFold(linkIDA[0], "layers") && !strings.EqualFold(linkIDA[0], "scenarios") && !strings.EqualFold(linkIDA[0], "steps") && linkIDA[0] != "_" {
if !strings.EqualFold(linkIDA[0].ScalarString(), "layers") && !strings.EqualFold(linkIDA[0].ScalarString(), "scenarios") && !strings.EqualFold(linkIDA[0].ScalarString(), "steps") && linkIDA[0].ScalarString() != "_" {
return
}
// Chop off the non-board portion of the scope, like if this is being defined on a nested object (e.g. `x.y.z`)
for i := len(scopeIDA) - 1; i > 0; i-- {
if strings.EqualFold(scopeIDA[i-1], "layers") || strings.EqualFold(scopeIDA[i-1], "scenarios") || strings.EqualFold(scopeIDA[i-1], "steps") {
if scopeIDA[i-1].IsUnquoted() && (strings.EqualFold(scopeIDA[i-1].ScalarString(), "layers") || strings.EqualFold(scopeIDA[i-1].ScalarString(), "scenarios") || strings.EqualFold(scopeIDA[i-1].ScalarString(), "steps")) {
scopeIDA = scopeIDA[:i+1]
break
}
if scopeIDA[i-1] == "root" {
if scopeIDA[i-1].ScalarString() == "root" && scopeIDA[i-1].IsUnquoted() {
scopeIDA = scopeIDA[:i]
break
}
}
// Resolve underscores
for len(linkIDA) > 0 && linkIDA[0] == "_" {
for len(linkIDA) > 0 && linkIDA[0].ScalarString() == "_" && linkIDA[0].IsUnquoted() {
if len(scopeIDA) < 2 {
// IR compiler only validates bad underscore usage
// The compiler will validate if the target board actually exists
c.errorf(refctx.Key.Key, "invalid underscore usage")
return
// Leave the underscore. It will fail in compiler as a standalone board,
// but if imported, will get further resolved in extendLinks
break
}
// pop 2 off path per one underscore
scopeIDA = scopeIDA[:len(scopeIDA)-2]
linkIDA = linkIDA[1:]
}
if len(scopeIDA) == 0 {
scopeIDA = []string{"root"}
scopeIDA = []d2ast.String{d2ast.FlatUnquotedString("root")}
}
// Create the absolute path by appending scope path with value specified
scopeIDA = append(scopeIDA, linkIDA...)
kp := d2ast.MakeKeyPath(scopeIDA)
kp := d2ast.MakeKeyPathString(scopeIDA)
f.Primary_.Value = d2ast.FlatUnquotedString(d2format.Format(kp))
}
@ -991,7 +1147,7 @@ func (c *compiler) compileEdges(refctx *RefContext) {
func (c *compiler) _compileEdges(refctx *RefContext) {
eida := NewEdgeIDs(refctx.Key)
for i, eid := range eida {
if refctx.Key != nil && refctx.Key.Value.Null != nil {
if !eid.Glob && (refctx.Key.Primary.Null != nil || refctx.Key.Value.Null != nil) {
refctx.ScopeMap.DeleteEdge(eid)
continue
}
@ -1009,6 +1165,10 @@ func (c *compiler) _compileEdges(refctx *RefContext) {
continue
}
for _, e := range ea {
if refctx.Key.Primary.Null != nil || refctx.Key.Value.Null != nil {
refctx.ScopeMap.DeleteEdge(e.ID)
continue
}
e.References = append(e.References, &EdgeReference{
Context_: refctx,
DueToGlob_: len(c.globRefContextStack) > 0,
@ -1096,6 +1256,7 @@ func (c *compiler) compileArray(dst *Array, a *d2ast.Array, scopeAST *d2ast.Map)
if !ok {
continue
}
n.(Importable).SetImportAST(v)
switch n := n.(type) {
case *Field:
if v.Spread {

View file

@ -28,6 +28,7 @@ func TestCompile(t *testing.T) {
t.Run("imports", testCompileImports)
t.Run("patterns", testCompilePatterns)
t.Run("filters", testCompileFilters)
t.Run("vars", testCompileVars)
}
type testCase struct {
@ -194,6 +195,23 @@ func testCompileFields(t *testing.T) {
assert.String(t, `[1; 2; 3; 4]`, f.Composite.String())
},
},
{
name: "quoted",
run: func(t testing.TB) {
m, err := compile(t, `my_table: {
shape: sql_table
width: 200
height: 200
"shape": string
"icon": string
"width": int
"height": int
}`)
assert.Success(t, err)
assertQuery(t, m, 0, 0, "sql_table", "my_table.shape")
assertQuery(t, m, 0, 0, "string", `my_table."shape"`)
},
},
{
name: "null",
run: func(t testing.TB) {
@ -698,3 +716,32 @@ layers: {
}
runa(t, tca)
}
func testCompileVars(t *testing.T) {
t.Parallel()
tca := []testCase{
{
name: "spread-in-place",
run: func(t testing.TB) {
m, err := compile(t, `vars: {
person-shape: {
grid-columns: 1
grid-rows: 2
grid-gap: 0
head
body
}
}
dora: {
...${person-shape}
body
}
`)
assert.Success(t, err)
assert.Equal(t, "grid-columns", m.Fields[1].Map().Fields[0].Name.ScalarString())
},
},
}
runa(t, tca)
}

View file

@ -11,7 +11,6 @@ import (
"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"
)
@ -167,16 +166,17 @@ func (s *Scalar) Equal(n2 Node) bool {
}
type Map struct {
parent Node
Fields []*Field `json:"fields"`
Edges []*Edge `json:"edges"`
parent Node
importAST d2ast.Node
Fields []*Field `json:"fields"`
Edges []*Edge `json:"edges"`
globs []*globContext
}
func (m *Map) initRoot() {
m.parent = &Field{
Name: "root",
Name: d2ast.FlatUnquotedString("root"),
References: []*FieldReference{{
Context_: &RefContext{
ScopeMap: m,
@ -185,6 +185,20 @@ func (m *Map) initRoot() {
}
}
func (m *Map) ImportAST() d2ast.Node {
return m.importAST
}
func (m *Map) SetImportAST(node d2ast.Node) {
m.importAST = node
for _, f := range m.Fields {
f.SetImportAST(node)
}
for _, e := range m.Edges {
e.SetImportAST(node)
}
}
func (m *Map) Copy(newParent Node) Node {
tmp := *m
m = &tmp
@ -263,6 +277,9 @@ func NodeBoardKind(n Node) BoardKind {
}
f = ParentField(n)
case *Map:
if n == nil {
return ""
}
var ok bool
f, ok = n.parent.(*Field)
if !ok {
@ -276,7 +293,7 @@ func NodeBoardKind(n Node) BoardKind {
if f == nil {
return ""
}
switch f.Name {
switch f.Name.ScalarString() {
case "layers":
return BoardLayer
case "scenarios":
@ -288,11 +305,21 @@ func NodeBoardKind(n Node) BoardKind {
}
}
type Importable interface {
ImportAST() d2ast.Node
SetImportAST(d2ast.Node)
}
var _ Importable = &Edge{}
var _ Importable = &Field{}
var _ Importable = &Map{}
type Field struct {
// *Map.
parent Node
parent Node
importAST d2ast.Node
Name string `json:"name"`
Name d2ast.String `json:"name"`
// Primary_ to avoid clashing with Primary(). We need to keep it exported for
// encoding/json to marshal it so cannot prefix _ instead.
@ -302,6 +329,17 @@ type Field struct {
References []*FieldReference `json:"references,omitempty"`
}
func (f *Field) ImportAST() d2ast.Node {
return f.importAST
}
func (f *Field) SetImportAST(node d2ast.Node) {
f.importAST = node
if f.Map() != nil {
f.Map().SetImportAST(node)
}
}
func (f *Field) Copy(newParent Node) Node {
tmp := *f
f = &tmp
@ -339,11 +377,11 @@ func (f *Field) LastRef() Reference {
}
type EdgeID struct {
SrcPath []string `json:"src_path"`
SrcArrow bool `json:"src_arrow"`
SrcPath []d2ast.String `json:"src_path"`
SrcArrow bool `json:"src_arrow"`
DstPath []string `json:"dst_path"`
DstArrow bool `json:"dst_arrow"`
DstPath []d2ast.String `json:"dst_path"`
DstArrow bool `json:"dst_arrow"`
// If nil, then any EdgeID with equal src/dst/arrows matches.
Index *int `json:"index"`
@ -371,8 +409,8 @@ func (eid *EdgeID) Copy() *EdgeID {
tmp := *eid
eid = &tmp
eid.SrcPath = append([]string(nil), eid.SrcPath...)
eid.DstPath = append([]string(nil), eid.DstPath...)
eid.SrcPath = append([]d2ast.String(nil), eid.SrcPath...)
eid.DstPath = append([]d2ast.String(nil), eid.DstPath...)
return eid
}
@ -390,7 +428,7 @@ func (eid *EdgeID) Match(eid2 *EdgeID) bool {
return false
}
for i, s := range eid.SrcPath {
if !strings.EqualFold(s, eid2.SrcPath[i]) {
if !strings.EqualFold(s.ScalarString(), eid2.SrcPath[i].ScalarString()) {
return false
}
}
@ -402,7 +440,7 @@ func (eid *EdgeID) Match(eid2 *EdgeID) bool {
return false
}
for i, s := range eid.DstPath {
if !strings.EqualFold(s, eid2.DstPath[i]) {
if !strings.EqualFold(s.ScalarString(), eid2.DstPath[i].ScalarString()) {
return false
}
}
@ -412,21 +450,21 @@ func (eid *EdgeID) Match(eid2 *EdgeID) bool {
// resolve resolves both underscores and commons in eid.
// It returns the new eid, containing map adjusted for underscores and common ida.
func (eid *EdgeID) resolve(m *Map) (_ *EdgeID, _ *Map, common []string, _ error) {
func (eid *EdgeID) resolve(m *Map) (_ *EdgeID, _ *Map, common []d2ast.String, _ error) {
eid = eid.Copy()
maxUnderscores := go2.Max(countUnderscores(eid.SrcPath), countUnderscores(eid.DstPath))
for i := 0; i < maxUnderscores; i++ {
if eid.SrcPath[0] == "_" {
if eid.SrcPath[0].ScalarString() == "_" && eid.SrcPath[0].IsUnquoted() {
eid.SrcPath = eid.SrcPath[1:]
} else {
mf := ParentField(m)
eid.SrcPath = append([]string{mf.Name}, eid.SrcPath...)
eid.SrcPath = append([]d2ast.String{mf.Name}, eid.SrcPath...)
}
if eid.DstPath[0] == "_" {
if eid.DstPath[0].ScalarString() == "_" && eid.DstPath[0].IsUnquoted() {
eid.DstPath = eid.DstPath[1:]
} else {
mf := ParentField(m)
eid.DstPath = append([]string{mf.Name}, eid.DstPath...)
eid.DstPath = append([]d2ast.String{mf.Name}, eid.DstPath...)
}
m = ParentMap(m)
if m == nil {
@ -435,7 +473,7 @@ func (eid *EdgeID) resolve(m *Map) (_ *EdgeID, _ *Map, common []string, _ error)
}
for len(eid.SrcPath) > 1 && len(eid.DstPath) > 1 {
if !strings.EqualFold(eid.SrcPath[0], eid.DstPath[0]) || eid.SrcPath[0] == "*" {
if !strings.EqualFold(eid.SrcPath[0].ScalarString(), eid.DstPath[0].ScalarString()) || strings.Contains(eid.SrcPath[0].ScalarString(), "*") {
return eid, m, common, nil
}
common = append(common, eid.SrcPath[0])
@ -448,7 +486,8 @@ func (eid *EdgeID) resolve(m *Map) (_ *EdgeID, _ *Map, common []string, _ error)
type Edge struct {
// *Map
parent Node
parent Node
importAST d2ast.Node
ID *EdgeID `json:"edge_id"`
@ -458,6 +497,17 @@ type Edge struct {
References []*EdgeReference `json:"references,omitempty"`
}
func (e *Edge) ImportAST() d2ast.Node {
return e.importAST
}
func (e *Edge) SetImportAST(node d2ast.Node) {
e.importAST = node
if e.Map() != nil {
e.Map().SetImportAST(node)
}
}
func (e *Edge) Copy(newParent Node) Node {
tmp := *e
e = &tmp
@ -624,8 +674,8 @@ func (m *Map) IsContainer() bool {
return false
}
for _, f := range m.Fields {
_, isReserved := d2graph.ReservedKeywords[f.Name]
if !isReserved {
_, isReserved := d2ast.ReservedKeywords[f.Name.ScalarString()]
if !(isReserved && f.Name.IsUnquoted()) {
return true
}
}
@ -652,9 +702,9 @@ func (m *Map) EdgeCountRecursive() int {
func (m *Map) GetClassMap(name string) *Map {
root := RootMap(m)
classes := root.Map().GetField("classes")
classes := root.Map().GetField(d2ast.FlatUnquotedString("classes"))
if classes != nil && classes.Map() != nil {
class := classes.Map().GetField(name)
class := classes.Map().GetField(d2ast.FlatUnquotedString(name))
if class != nil && class.Map() != nil {
return class.Map()
}
@ -662,8 +712,8 @@ func (m *Map) GetClassMap(name string) *Map {
return nil
}
func (m *Map) GetField(ida ...string) *Field {
for len(ida) > 0 && ida[0] == "_" {
func (m *Map) GetField(ida ...d2ast.String) *Field {
for len(ida) > 0 && ida[0].ScalarString() == "_" && ida[0].IsUnquoted() {
m = ParentMap(m)
if m == nil {
return nil
@ -672,7 +722,7 @@ func (m *Map) GetField(ida ...string) *Field {
return m.getField(ida)
}
func (m *Map) getField(ida []string) *Field {
func (m *Map) getField(ida []d2ast.String) *Field {
if len(ida) == 0 {
return nil
}
@ -680,12 +730,18 @@ func (m *Map) getField(ida []string) *Field {
s := ida[0]
rest := ida[1:]
if s == "_" {
if s.ScalarString() == "_" && s.IsUnquoted() {
return nil
}
for _, f := range m.Fields {
if !strings.EqualFold(f.Name, s) {
if f.Name == nil {
continue
}
if !strings.EqualFold(f.Name.ScalarString(), s.ScalarString()) {
continue
}
if f.Name.IsUnquoted() != s.IsUnquoted() {
continue
}
if len(rest) == 0 {
@ -701,7 +757,7 @@ func (m *Map) getField(ida []string) *Field {
// EnsureField is a bit of a misnomer. It's more of a Query/Ensure combination function at this point.
func (m *Map) EnsureField(kp *d2ast.KeyPath, refctx *RefContext, create bool, c *compiler) ([]*Field, error) {
i := 0
for kp.Path[i].Unbox().ScalarString() == "_" {
for kp.Path[i].Unbox().ScalarString() == "_" && kp.Path[i].Unbox().IsUnquoted() {
m = ParentMap(m)
if m == nil {
return nil, d2parser.Errorf(kp.Path[i].Unbox(), "invalid underscore: no parent")
@ -734,10 +790,10 @@ func (m *Map) ensureField(i int, kp *d2ast.KeyPath, refctx *RefContext, create b
filter := func(f *Field, passthrough bool) bool {
if gctx != nil {
var ks string
if refctx.Key.HasTripleGlob() {
ks = d2format.Format(d2ast.MakeKeyPath(IDA(f)))
if refctx.Key.HasMultiGlob() {
ks = d2format.Format(d2ast.MakeKeyPathString(IDA(f)))
} else {
ks = d2format.Format(d2ast.MakeKeyPath(BoardIDA(f)))
ks = d2format.Format(d2ast.MakeKeyPathString(BoardIDA(f)))
}
if !kp.HasGlob() {
if !passthrough {
@ -791,7 +847,10 @@ func (m *Map) ensureField(i int, kp *d2ast.KeyPath, refctx *RefContext, create b
return nil
}
for _, f := range m.Fields {
if matchPattern(f.Name, us.Pattern) {
if f.Name == nil {
continue
}
if matchPattern(f.Name.ScalarString(), us.Pattern) {
if i == len(kp.Path)-1 {
faAppend(f)
} else {
@ -813,29 +872,30 @@ func (m *Map) ensureField(i int, kp *d2ast.KeyPath, refctx *RefContext, create b
return nil
}
head := kp.Path[i].Unbox().ScalarString()
head := kp.Path[i].Unbox()
headString := head.ScalarString()
if _, ok := d2graph.ReservedKeywords[strings.ToLower(head)]; ok {
head = strings.ToLower(head)
if _, ok := d2graph.CompositeReservedKeywords[head]; !ok && i < len(kp.Path)-1 {
return d2parser.Errorf(kp.Path[i].Unbox(), fmt.Sprintf(`"%s" must be the last part of the key`, head))
if _, ok := d2ast.ReservedKeywords[strings.ToLower(head.ScalarString())]; ok && head.IsUnquoted() {
headString = strings.ToLower(head.ScalarString())
if _, ok := d2ast.CompositeReservedKeywords[headString]; !ok && i < len(kp.Path)-1 {
return d2parser.Errorf(kp.Path[i].Unbox(), fmt.Sprintf(`"%s" must be the last part of the key`, headString))
}
}
if head == "_" {
if headString == "_" && head.IsUnquoted() {
return d2parser.Errorf(kp.Path[i].Unbox(), `parent "_" can only be used in the beginning of paths, e.g. "_.x"`)
}
if head == "classes" && NodeBoardKind(m) == "" {
return d2parser.Errorf(kp.Path[i].Unbox(), "%s is only allowed at a board root", head)
if headString == "classes" && head.IsUnquoted() && NodeBoardKind(m) == "" {
return d2parser.Errorf(kp.Path[i].Unbox(), "%s is only allowed at a board root", headString)
}
if findBoardKeyword(head) != -1 && NodeBoardKind(m) == "" {
return d2parser.Errorf(kp.Path[i].Unbox(), "%s is only allowed at a board root", head)
if findBoardKeyword(head) != -1 && head.IsUnquoted() && NodeBoardKind(m) == "" {
return d2parser.Errorf(kp.Path[i].Unbox(), "%s is only allowed at a board root", headString)
}
for _, f := range m.Fields {
if !strings.EqualFold(f.Name, head) {
if !(f.Name != nil && strings.EqualFold(f.Name.ScalarString(), head.ScalarString()) && f.Name.IsUnquoted() == head.IsUnquoted()) {
continue
}
@ -872,14 +932,14 @@ func (m *Map) ensureField(i int, kp *d2ast.KeyPath, refctx *RefContext, create b
return nil
}
shape := ParentShape(m)
if _, ok := d2graph.ReservedKeywords[strings.ToLower(head)]; !ok && len(c.globRefContextStack) > 0 {
if _, ok := d2ast.ReservedKeywords[strings.ToLower(head.ScalarString())]; !(ok && head.IsUnquoted()) && len(c.globRefContextStack) > 0 {
if shape == d2target.ShapeClass || shape == d2target.ShapeSQLTable {
return nil
}
}
f := &Field{
parent: m,
Name: head,
Name: kp.Path[i].Unbox(),
}
defer func() {
if i < kp.FirstGlob() {
@ -887,10 +947,10 @@ func (m *Map) ensureField(i int, kp *d2ast.KeyPath, refctx *RefContext, create b
}
for _, grefctx := range c.globRefContextStack {
var ks string
if grefctx.Key.HasTripleGlob() {
ks = d2format.Format(d2ast.MakeKeyPath(IDA(f)))
if grefctx.Key.HasMultiGlob() {
ks = d2format.Format(d2ast.MakeKeyPathString(IDA(f)))
} else {
ks = d2format.Format(d2ast.MakeKeyPath(BoardIDA(f)))
ks = d2format.Format(d2ast.MakeKeyPathString(BoardIDA(f)))
}
gctx2 := c.getGlobContext(grefctx)
gctx2.appliedFields[ks] = struct{}{}
@ -927,9 +987,25 @@ func (m *Map) DeleteEdge(eid *EdgeID) *Edge {
return nil
}
for i, e := range m.Edges {
if e.ID.Match(eid) {
m.Edges = append(m.Edges[:i], m.Edges[i+1:]...)
resolvedEID, resolvedM, common, err := eid.resolve(m)
if err != nil {
return nil
}
if len(common) > 0 {
f := resolvedM.GetField(common...)
if f == nil {
return nil
}
if f.Map() == nil {
return nil
}
return f.Map().DeleteEdge(resolvedEID)
}
for i, e := range resolvedM.Edges {
if e.ID.Match(resolvedEID) {
resolvedM.Edges = append(resolvedM.Edges[:i], resolvedM.Edges[i+1:]...)
return e
}
}
@ -945,7 +1021,7 @@ func (m *Map) DeleteField(ida ...string) *Field {
rest := ida[1:]
for i, f := range m.Fields {
if !strings.EqualFold(f.Name, s) {
if !strings.EqualFold(f.Name.ScalarString(), s) {
continue
}
if len(rest) == 0 {
@ -971,11 +1047,11 @@ func (m *Map) DeleteField(ida ...string) *Field {
// If a field was deleted from a keyword-holder keyword and that holder is empty,
// then that holder becomes meaningless and should be deleted too
parent := ParentField(f)
for keywordHolder := range d2graph.ReservedKeywordHolders {
if parent != nil && parent.Name == keywordHolder && len(parent.Map().Fields) == 0 {
for keywordHolder := range d2ast.ReservedKeywordHolders {
if parent != nil && parent.Name.ScalarString() == keywordHolder && parent.Name.IsUnquoted() && len(parent.Map().Fields) == 0 {
keywordHolderParentMap := ParentMap(parent)
for i, f := range keywordHolderParentMap.Fields {
if f.Name == keywordHolder {
if f.Name.ScalarString() == keywordHolder && f.Name.IsUnquoted() {
keywordHolderParentMap.Fields = append(keywordHolderParentMap.Fields[:i], keywordHolderParentMap.Fields[i+1:]...)
break
}
@ -1033,7 +1109,7 @@ func (m *Map) getEdges(eid *EdgeID, refctx *RefContext, gctx *globContext, ea *[
}
if len(common) > 0 {
commonKP := d2ast.MakeKeyPath(common)
commonKP := d2ast.MakeKeyPathString(common)
lastMatch := 0
for i, el := range commonKP.Path {
for j := lastMatch; j < len(refctx.Edge.Src.Path); j++ {
@ -1084,10 +1160,10 @@ func (m *Map) getEdges(eid *EdgeID, refctx *RefContext, gctx *globContext, ea *[
for _, e := range ea2 {
if gctx != nil {
var ks string
if refctx.Key.HasTripleGlob() {
ks = d2format.Format(d2ast.MakeKeyPath(IDA(e)))
if refctx.Key.HasMultiGlob() {
ks = d2format.Format(d2ast.MakeKeyPathString(IDA(e)))
} else {
ks = d2format.Format(d2ast.MakeKeyPath(BoardIDA(e)))
ks = d2format.Format(d2ast.MakeKeyPathString(BoardIDA(e)))
}
if _, ok := gctx.appliedEdges[ks]; ok {
continue
@ -1129,7 +1205,7 @@ func (m *Map) createEdge(eid *EdgeID, refctx *RefContext, gctx *globContext, c *
return d2parser.Errorf(refctx.Edge, err.Error())
}
if len(common) > 0 {
commonKP := d2ast.MakeKeyPath(common)
commonKP := d2ast.MakeKeyPathString(common)
lastMatch := 0
for i, el := range commonKP.Path {
for j := lastMatch; j < len(refctx.Edge.Src.Path); j++ {
@ -1218,11 +1294,11 @@ func (m *Map) createEdge(eid *EdgeID, refctx *RefContext, gctx *globContext, c *
eid2.SrcPath = RelIDA(m, src)
eid2.DstPath = RelIDA(m, dst)
e, err := m.createEdge2(eid2, refctx, gctx, c, src, dst)
es, err := m.createEdge2(eid2, refctx, gctx, c, src, dst)
if err != nil {
return err
}
if e != nil {
for _, e := range es {
*ea = append(*ea, e)
}
}
@ -1230,7 +1306,7 @@ func (m *Map) createEdge(eid *EdgeID, refctx *RefContext, gctx *globContext, c *
return nil
}
func (m *Map) createEdge2(eid *EdgeID, refctx *RefContext, gctx *globContext, c *compiler, src, dst *Field) (*Edge, error) {
func (m *Map) createEdge2(eid *EdgeID, refctx *RefContext, gctx *globContext, c *compiler, src, dst *Field) ([]*Edge, error) {
if NodeBoardKind(src) != "" {
return nil, d2parser.Errorf(refctx.Edge.Src, "cannot create edges between boards")
}
@ -1241,6 +1317,45 @@ func (m *Map) createEdge2(eid *EdgeID, refctx *RefContext, gctx *globContext, c
return nil, d2parser.Errorf(refctx.Edge, "cannot create edges between boards")
}
eid, m, common, err := eid.resolve(m)
if err != nil {
return nil, d2parser.Errorf(refctx.Edge, err.Error())
}
if len(common) > 0 {
commonKP := d2ast.MakeKeyPathString(common)
lastMatch := 0
for i, el := range commonKP.Path {
for j := lastMatch; j < len(refctx.Edge.Src.Path); j++ {
realEl := refctx.Edge.Src.Path[j]
if el.ScalarString() == realEl.ScalarString() {
commonKP.Path[i] = realEl
lastMatch += j + 1
}
}
}
fa, err := m.EnsureField(commonKP, nil, true, c)
if err != nil {
return nil, err
}
var edges []*Edge
for _, f := range fa {
if _, ok := f.Composite.(*Array); ok {
return nil, d2parser.Errorf(refctx.Edge.Src, "cannot index into array")
}
if f.Map() == nil {
f.Composite = &Map{
parent: f,
}
}
edges2, err := f.Map().createEdge2(eid, refctx, gctx, c, src, dst)
if err != nil {
return nil, err
}
edges = append(edges, edges2...)
}
return edges, nil
}
eid.Index = nil
eid.Glob = true
ea := m.GetEdges(eid, nil, nil)
@ -1263,10 +1378,10 @@ func (m *Map) createEdge2(eid *EdgeID, refctx *RefContext, gctx *globContext, c
e2 := e.Copy(e.Parent()).(*Edge)
e2.ID = e2.ID.Copy()
e2.ID.Index = nil
if refctx.Key.HasTripleGlob() {
ks = d2format.Format(d2ast.MakeKeyPath(IDA(e2)))
if refctx.Key.HasMultiGlob() {
ks = d2format.Format(d2ast.MakeKeyPathString(IDA(e2)))
} else {
ks = d2format.Format(d2ast.MakeKeyPath(BoardIDA(e2)))
ks = d2format.Format(d2ast.MakeKeyPathString(BoardIDA(e2)))
}
if _, ok := gctx.appliedEdges[ks]; ok {
return nil, nil
@ -1276,7 +1391,7 @@ func (m *Map) createEdge2(eid *EdgeID, refctx *RefContext, gctx *globContext, c
m.Edges = append(m.Edges, e)
return e, nil
return []*Edge{e}, nil
}
func (s *Scalar) AST() d2ast.Node {
@ -1287,7 +1402,7 @@ func (f *Field) AST() d2ast.Node {
k := &d2ast.Key{
Key: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
d2ast.MakeValueBox(d2ast.RawString(f.Name, true)).StringBox(),
d2ast.MakeValueBox(f.Name).StringBox(),
},
},
}
@ -1296,7 +1411,14 @@ func (f *Field) AST() d2ast.Node {
k.Primary = d2ast.MakeValueBox(f.Primary_.AST().(d2ast.Value)).ScalarBox()
}
if f.Composite != nil {
k.Value = d2ast.MakeValueBox(f.Composite.AST().(d2ast.Value))
value := f.Composite.AST().(d2ast.Value)
if m, ok := value.(*d2ast.Map); ok {
path := m.Range.Path
// Treat it as multi-line, but not file-map (line 0)
m.Range = d2ast.MakeRange(",1:0:0-2:0:0")
m.Range.Path = path
}
k.Value = d2ast.MakeValueBox(value)
}
return k
@ -1305,11 +1427,11 @@ func (f *Field) AST() d2ast.Node {
func (e *Edge) AST() d2ast.Node {
astEdge := &d2ast.Edge{}
astEdge.Src = d2ast.MakeKeyPath(e.ID.SrcPath)
astEdge.Src = d2ast.MakeKeyPathString(e.ID.SrcPath)
if e.ID.SrcArrow {
astEdge.SrcArrow = "<"
}
astEdge.Dst = d2ast.MakeKeyPath(e.ID.DstPath)
astEdge.Dst = d2ast.MakeKeyPathString(e.ID.DstPath)
if e.ID.DstArrow {
astEdge.DstArrow = ">"
}
@ -1328,7 +1450,7 @@ func (e *Edge) AST() d2ast.Node {
return k
}
func (e *Edge) IDString() string {
func (e *Edge) IDString() d2ast.String {
ast := e.AST().(*d2ast.Key)
if e.ID.Index != nil {
ast.EdgeIndex = &d2ast.EdgeIndex{
@ -1337,7 +1459,8 @@ func (e *Edge) IDString() string {
}
ast.Primary = d2ast.ScalarBox{}
ast.Value = d2ast.ValueBox{}
return d2format.Format(ast)
formatted := d2format.Format(ast)
return d2ast.FlatUnquotedString(formatted)
}
func (a *Array) AST() d2ast.Node {
@ -1355,11 +1478,14 @@ func (m *Map) AST() d2ast.Node {
if m == nil {
return nil
}
astMap := &d2ast.Map{}
if m.Root() {
astMap.Range = d2ast.MakeRange(",0:0:0-1:0:0")
} else {
astMap.Range = d2ast.MakeRange(",1:0:0-2:0:0")
astMap := &d2ast.Map{
Range: d2ast.MakeRange(",0:0:0-1:0:0"),
}
if m.parent != nil && NodeBoardKind(m) != "" {
f, ok := m.parent.(*Field)
if ok {
astMap.Range.Path = f.Name.GetRange().Path
}
}
for _, f := range m.Fields {
astMap.Nodes = append(astMap.Nodes, d2ast.MakeMapNodeBox(f.AST().(d2ast.MapNode)))
@ -1372,7 +1498,7 @@ func (m *Map) AST() d2ast.Node {
func (m *Map) appendFieldReferences(i int, kp *d2ast.KeyPath, refctx *RefContext, c *compiler) {
sb := kp.Path[i]
f := m.GetField(sb.Unbox().ScalarString())
f := m.GetField(sb.Unbox())
if f == nil {
return
}
@ -1431,9 +1557,12 @@ func IsVar(n Node) bool {
if NodeBoardKind(n) != "" {
return false
}
if f, ok := n.(*Field); ok && f.Name == "vars" {
if f, ok := n.(*Field); ok && f.Name.ScalarString() == "vars" && f.Name.IsUnquoted() {
return true
}
if n == (*Map)(nil) {
return false
}
n = n.Parent()
}
}
@ -1467,7 +1596,7 @@ func ParentShape(n Node) string {
f, ok := n.(*Field)
if ok {
if f.Map() != nil {
shapef := f.Map().GetField("shape")
shapef := f.Map().GetField(d2ast.FlatUnquotedString("shape"))
if shapef != nil && shapef.Primary() != nil {
return shapef.Primary().Value.ScalarString()
}
@ -1480,30 +1609,30 @@ func ParentShape(n Node) string {
}
}
func countUnderscores(p []string) int {
func countUnderscores(p []d2ast.String) int {
for i, el := range p {
if el != "_" {
if el.ScalarString() != "_" || !el.IsUnquoted() {
return i
}
}
return 0
}
func findBoardKeyword(ida ...string) int {
func findBoardKeyword(ida ...d2ast.String) int {
for i := range ida {
if _, ok := d2graph.BoardKeywords[ida[i]]; ok {
if _, ok := d2ast.BoardKeywords[strings.ToLower(ida[i].ScalarString())]; ok && ida[i].IsUnquoted() {
return i
}
}
return -1
}
func findProhibitedEdgeKeyword(ida ...string) int {
func findProhibitedEdgeKeyword(ida ...d2ast.String) int {
for i := range ida {
if _, ok := d2graph.SimpleReservedKeywords[ida[i]]; ok {
if _, ok := d2ast.SimpleReservedKeywords[ida[i].ScalarString()]; ok && ida[i].IsUnquoted() {
return i
}
if _, ok := d2graph.ReservedKeywordHolders[ida[i]]; ok {
if _, ok := d2ast.ReservedKeywordHolders[ida[i].ScalarString()]; ok && ida[i].IsUnquoted() {
return i
}
}
@ -1547,7 +1676,7 @@ func parentPrimaryKey(n Node) *d2ast.Key {
}
// BoardIDA returns the absolute path to n from the nearest board root.
func BoardIDA(n Node) (ida []string) {
func BoardIDA(n Node) (ida []d2ast.String) {
for {
switch n := n.(type) {
case *Field:
@ -1568,7 +1697,7 @@ func BoardIDA(n Node) (ida []string) {
}
// IDA returns the absolute path to n.
func IDA(n Node) (ida []string) {
func IDA(n Node) (ida []d2ast.String) {
for {
switch n := n.(type) {
case *Field:
@ -1589,7 +1718,7 @@ func IDA(n Node) (ida []string) {
}
// RelIDA returns the path to n relative to p.
func RelIDA(p, n Node) (ida []string) {
func RelIDA(p, n Node) (ida []d2ast.String) {
for {
switch n := n.(type) {
case *Field:
@ -1599,7 +1728,7 @@ func RelIDA(p, n Node) (ida []string) {
return ida
}
case *Edge:
ida = append(ida, n.String())
ida = append(ida, d2ast.FlatUnquotedString(n.String()))
}
n = n.Parent()
f, fok := n.(*Field)
@ -1611,11 +1740,11 @@ func RelIDA(p, n Node) (ida []string) {
}
}
func reverseIDA(ida []string) {
for i := 0; i < len(ida)/2; i++ {
tmp := ida[i]
ida[i] = ida[len(ida)-i-1]
ida[len(ida)-i-1] = tmp
func reverseIDA[T any](slice []T) {
for i := 0; i < len(slice)/2; i++ {
tmp := slice[i]
slice[i] = slice[len(slice)-i-1]
slice[len(slice)-i-1] = tmp
}
}
@ -1690,7 +1819,7 @@ func (m *Map) Equal(n2 Node) bool {
}
func (m *Map) InClass(key *d2ast.Key) bool {
classes := m.Map().GetField("classes")
classes := m.Map().GetField(d2ast.FlatUnquotedString("classes"))
if classes == nil || classes.Map() == nil {
return false
}
@ -1718,7 +1847,7 @@ func (m *Map) IsClass() bool {
if parentBoard.Map() == nil {
return false
}
classes := parentBoard.Map().GetField("classes")
classes := parentBoard.Map().GetField(d2ast.FlatUnquotedString("classes"))
if classes == nil || classes.Map() == nil {
return false
}
@ -1730,3 +1859,51 @@ func (m *Map) IsClass() bool {
}
return false
}
func (m *Map) FindBoardRoot(path []string) *Map {
if m == nil {
return nil
}
if len(path) == 0 {
return m
}
layersf := m.GetField(d2ast.FlatUnquotedString("layers"))
scenariosf := m.GetField(d2ast.FlatUnquotedString("scenarios"))
stepsf := m.GetField(d2ast.FlatUnquotedString("steps"))
if layersf != nil && layersf.Map() != nil {
for _, f := range layersf.Map().Fields {
if f.Name.ScalarString() == path[0] {
if len(path) == 1 {
return f.Map()
}
return f.Map().FindBoardRoot(path[1:])
}
}
}
if scenariosf != nil && scenariosf.Map() != nil {
for _, f := range scenariosf.Map().Fields {
if f.Name.ScalarString() == path[0] {
if len(path) == 1 {
return f.Map()
}
return f.Map().FindBoardRoot(path[1:])
}
}
}
if stepsf != nil && stepsf.Map() != nil {
for _, f := range stepsf.Map().Fields {
if f.Name.ScalarString() == path[0] {
if len(path) == 1 {
return f.Map()
}
return f.Map().FindBoardRoot(path[1:])
}
}
}
return nil
}

View file

@ -33,7 +33,7 @@ func TestCopy(t *testing.T) {
const keyStr = `Absence makes the heart grow frantic.`
f := &d2ir.Field{
Name: keyStr,
Name: d2ast.FlatUnquotedString(keyStr),
Primary_: s,
Composite: a,
@ -48,10 +48,10 @@ func TestCopy(t *testing.T) {
}
m = m.Copy(nil).(*d2ir.Map)
f.Name = `Many a wife thinks her husband is the world's greatest lover.`
f.Name = d2ast.FlatUnquotedString(`Many a wife thinks her husband is the world's greatest lover.`)
assert.Equal(t, m, m.Fields[0].Parent())
assert.Equal(t, keyStr, m.Fields[0].Name)
assert.Equal(t, keyStr, m.Fields[0].Name.ScalarString())
assert.Equal(t, m.Fields[0], m.Fields[0].Primary_.Parent())
assert.Equal(t, m.Fields[0], m.Fields[0].Composite.(*d2ir.Array).Parent())

View file

@ -175,7 +175,6 @@ x -> y: hi
}
a
# if i remove this line, the glob applies as expected
b
b.label: a
`)
@ -225,6 +224,25 @@ classes: {
assertQuery(t, m, 9, 3, nil, "")
},
},
{
name: "not-basic",
run: func(t testing.TB) {
m, err := compile(t, `jacob: {
shape: circle
}
jeremy: {
shape: rectangle
}
*: {
!&shape: rectangle
label: I'm not a rectangle
}`)
assert.Success(t, err)
assertQuery(t, m, 2, 0, nil, "jacob")
assertQuery(t, m, 1, 0, nil, "jeremy")
assertQuery(t, m, 0, 0, "I'm not a rectangle", "jacob.label")
},
},
}
runa(t, tca)

View file

@ -4,6 +4,7 @@ import (
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"oss.terrastruct.com/d2/d2ast"
@ -17,17 +18,13 @@ func (c *compiler) pushImportStack(imp *d2ast.Import) (string, bool) {
return "", false
}
if len(c.importStack) > 0 {
if path.IsAbs(impPath) {
c.errorf(imp, "import paths must be relative")
return "", false
}
if path.Ext(impPath) != ".d2" {
impPath += ".d2"
}
// Imports are always relative to the importing file.
impPath = path.Join(path.Dir(c.importStack[len(c.importStack)-1]), impPath)
if !filepath.IsAbs(impPath) {
impPath = path.Join(path.Dir(c.importStack[len(c.importStack)-1]), impPath)
}
}
for i, p := range c.importStack {

View file

@ -211,6 +211,23 @@ label: meow`,
assert.Success(t, err)
},
},
{
name: "nested-scope",
run: func(t testing.TB) {
m, err := compileFS(t, "index.d2", map[string]string{
"index.d2": `...@second
`,
"second.d2": `elem: {
...@third
}`,
"third.d2": `third: {
elem
}`,
})
assert.Success(t, err)
assertQuery(t, m, 3, 0, nil, "")
},
},
}
runa(t, tca)
@ -235,15 +252,6 @@ label: meow`,
assert.ErrorString(t, err, `index.d2:1:1: failed to import "../x.d2": open ../x.d2: invalid argument`)
},
},
{
name: "absolute",
run: func(t testing.TB) {
_, err := compileFS(t, "index.d2", map[string]string{
"index.d2": "...@/x.d2",
})
assert.ErrorString(t, err, `index.d2:1:1: import paths must be relative`)
},
},
{
name: "parse",
run: func(t testing.TB) {

View file

@ -21,6 +21,39 @@ func OverlayMap(base, overlay *Map) {
}
}
func ExpandSubstitution(m, resolved *Map, placeholder *Field) {
fi := -1
for i := 0; i < len(m.Fields); i++ {
if m.Fields[i] == placeholder {
fi = i
break
}
}
for _, of := range resolved.Fields {
bf := m.GetField(of.Name)
if bf == nil {
m.Fields = append(m.Fields[:fi], append([]*Field{of.Copy(m).(*Field)}, m.Fields[fi:]...)...)
fi++
continue
}
OverlayField(bf, of)
}
// NOTE this doesn't expand edges in place, and just appends
// I suppose to do this, there needs to be an edge placeholder too on top of the field placeholder
// Will wait to see if a problem
for _, oe := range resolved.Edges {
bea := m.GetEdges(oe.ID, nil, nil)
if len(bea) == 0 {
m.Edges = append(m.Edges, oe.Copy(m).(*Edge))
continue
}
be := bea[0]
OverlayEdge(be, oe)
}
}
func OverlayField(bf, of *Field) {
if of.Primary_ != nil {
bf.Primary_ = of.Primary_.Copy(bf).(*Scalar)

View file

@ -4,7 +4,6 @@ import (
"strings"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2graph"
)
func (m *Map) multiGlob(pattern []string) ([]*Field, bool) {
@ -22,11 +21,14 @@ func (m *Map) multiGlob(pattern []string) ([]*Field, bool) {
func (m *Map) _doubleGlob(fa *[]*Field) {
for _, f := range m.Fields {
if _, ok := d2graph.ReservedKeywords[f.Name]; ok {
if f.Name == "layers" {
if f.Name == nil {
continue
}
if _, ok := d2ast.ReservedKeywords[f.Name.ScalarString()]; ok && f.Name.IsUnquoted() {
if f.Name.ScalarString() == "layers" {
continue
}
if _, ok := d2graph.BoardKeywords[f.Name]; !ok {
if _, ok := d2ast.BoardKeywords[f.Name.ScalarString()]; !ok {
continue
}
// We don't ever want to append layers, scenarios or steps directly.
@ -46,8 +48,8 @@ func (m *Map) _doubleGlob(fa *[]*Field) {
func (m *Map) _tripleGlob(fa *[]*Field) {
for _, f := range m.Fields {
if _, ok := d2graph.ReservedKeywords[f.Name]; ok {
if _, ok := d2graph.BoardKeywords[f.Name]; !ok {
if _, ok := d2ast.ReservedKeywords[f.Name.ScalarString()]; ok && f.Name.IsUnquoted() {
if _, ok := d2ast.BoardKeywords[f.Name.ScalarString()]; !ok {
continue
}
// We don't ever want to append layers, scenarios or steps directly.
@ -69,7 +71,7 @@ func matchPattern(s string, pattern []string) bool {
if len(pattern) == 0 {
return true
}
if _, ok := d2graph.ReservedKeywords[s]; ok {
if _, ok := d2ast.ReservedKeywords[s]; ok {
return false
}

View file

@ -157,6 +157,24 @@ sh*.an* -> sh*.an*`)
assertQuery(t, m, 0, 0, nil, "shared.(animal -> animate)[0]")
},
},
{
name: "edge/4",
run: func(t testing.TB) {
m, err := compile(t, `app_a: {
x
}
app_b: {
y
}
app_*.x -> app_*.y`)
assert.Success(t, err)
assertQuery(t, m, 6, 4, nil, "")
assertQuery(t, m, 2, 1, nil, "app_a")
assertQuery(t, m, 2, 1, nil, "app_b")
},
},
{
name: "edge-glob-index",
run: func(t testing.TB) {
@ -310,6 +328,79 @@ layers.x: { wrapper.p }
assertQuery(t, m, 0, 0, nil, "layers.x.wrapper.p")
},
},
{
name: "edge-glob-null",
run: func(t testing.TB) {
m, err := compile(t, `a -> b
(* -> *)[*]: null
x -> y
`)
assert.Success(t, err)
// 4 fields and 0 edges
assertQuery(t, m, 4, 0, nil, "")
},
},
{
name: "field-glob-style-inherit",
run: func(t testing.TB) {
m, err := compile(t, `*.style.opacity: 0
x: {
style.opacity: 1
}
scenarios: {
1: {
x
}
}
`)
assert.Success(t, err)
assertQuery(t, m, 0, 0, 1, "x.style.opacity")
assertQuery(t, m, 0, 0, 1, "scenarios.1.x.style.opacity")
},
},
{
name: "edge-glob-style-inherit/1",
run: func(t testing.TB) {
m, err := compile(t, `(* -> *)[*].style.opacity: 0
x -> y: {
style.opacity: 1
}
scenarios: {
1: {
x
}
}
`)
assert.Success(t, err)
assertQuery(t, m, 0, 0, 1, "(x -> y)[0].style.opacity")
assertQuery(t, m, 0, 0, 1, "scenarios.1.(x -> y)[0].style.opacity")
},
},
{
name: "edge-glob-style-inherit/2",
run: func(t testing.TB) {
m, err := compile(t, `*.style.opacity: 0
(* -> *)[*].style.opacity: 0
x -> y
steps: {
1: {
x.style.opacity: 1
}
2: {
(x -> y)[0].style.opacity: 1
}
3: {
y.style.opacity: 1
}
}
`)
assert.Success(t, err)
assertQuery(t, m, 0, 0, 1, "steps.3.(x -> y)[0].style.opacity")
},
},
{
name: "double-glob/edge/1",
run: func(t testing.TB) {

71
d2js/d2wasm/api.go Normal file
View file

@ -0,0 +1,71 @@
//go:build js && wasm
package d2wasm
import (
"encoding/json"
"fmt"
"runtime/debug"
"syscall/js"
)
type D2API struct {
exports map[string]js.Func
}
func NewD2API() *D2API {
return &D2API{
exports: make(map[string]js.Func),
}
}
func (api *D2API) Register(name string, fn func(args []js.Value) (interface{}, error)) {
api.exports[name] = wrapWASMCall(fn)
}
func (api *D2API) ExportTo(target js.Value) {
d2Namespace := make(map[string]interface{})
for name, fn := range api.exports {
d2Namespace[name] = fn
}
target.Set("d2", js.ValueOf(d2Namespace))
}
func wrapWASMCall(fn func(args []js.Value) (interface{}, error)) js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) (result any) {
defer func() {
if r := recover(); r != nil {
resp := WASMResponse{
Error: &WASMError{
Message: fmt.Sprintf("panic recovered: %v\n%s", r, debug.Stack()),
Code: 500,
},
}
jsonResp, _ := json.Marshal(resp)
result = string(jsonResp)
}
}()
data, err := fn(args)
if err != nil {
wasmErr, ok := err.(*WASMError)
if !ok {
wasmErr = &WASMError{
Message: err.Error(),
Code: 500,
}
}
resp := WASMResponse{
Error: wasmErr,
}
jsonResp, _ := json.Marshal(resp)
return string(jsonResp)
}
resp := WASMResponse{
Data: data,
}
jsonResp, _ := json.Marshal(resp)
return string(jsonResp)
})
}

338
d2js/d2wasm/functions.go Normal file
View file

@ -0,0 +1,338 @@
//go:build js && wasm
package d2wasm
import (
"context"
"encoding/json"
"fmt"
"strings"
"syscall/js"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2layouts/d2elklayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2lsp"
"oss.terrastruct.com/d2/d2oracle"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/memfs"
"oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/d2/lib/urlenc"
"oss.terrastruct.com/d2/lib/version"
"oss.terrastruct.com/util-go/go2"
)
func GetParentID(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing id argument", Code: 400}
}
id := args[0].String()
mk, err := d2parser.ParseMapKey(id)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 400}
}
if len(mk.Edges) > 0 {
return "", nil
}
if mk.Key != nil {
if len(mk.Key.Path) == 1 {
return "root", nil
}
mk.Key.Path = mk.Key.Path[:len(mk.Key.Path)-1]
return strings.Join(mk.Key.StringIDA(), "."), nil
}
return "", nil
}
func GetObjOrder(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing dsl argument", Code: 400}
}
dsl := args[0].String()
g, _, err := d2compiler.Compile("", strings.NewReader(dsl), &d2compiler.CompileOptions{
UTF16Pos: true,
})
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 400}
}
objOrder, err := d2oracle.GetObjOrder(g, nil)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 500}
}
return map[string]interface{}{
"order": objOrder,
}, nil
}
func GetRefRanges(args []js.Value) (interface{}, error) {
if len(args) < 4 {
return nil, &WASMError{Message: "missing required arguments", Code: 400}
}
var fs map[string]string
if err := json.Unmarshal([]byte(args[0].String()), &fs); err != nil {
return nil, &WASMError{Message: "invalid fs argument", Code: 400}
}
file := args[1].String()
key := args[2].String()
var boardPath []string
if err := json.Unmarshal([]byte(args[3].String()), &boardPath); err != nil {
return nil, &WASMError{Message: "invalid boardPath argument", Code: 400}
}
ranges, importRanges, err := d2lsp.GetRefRanges(file, fs, boardPath, key)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 500}
}
return RefRangesResponse{
Ranges: ranges,
ImportRanges: importRanges,
}, nil
}
func GetELKGraph(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
}
var input CompileRequest
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
}
if input.FS == nil {
return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
}
if _, ok := input.FS["index"]; !ok {
return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
}
fs, err := memfs.New(input.FS)
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
}
g, _, err := d2compiler.Compile("", strings.NewReader(input.FS["index"]), &d2compiler.CompileOptions{
UTF16Pos: true,
FS: fs,
})
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 400}
}
ruler, err := textmeasure.NewRuler()
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
}
err = g.SetDimensions(nil, ruler, nil)
if err != nil {
return nil, err
}
elk, err := d2elklayout.ConvertGraph(context.Background(), g, nil)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 400}
}
return elk, nil
}
func Compile(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
}
var input CompileRequest
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
}
if input.FS == nil {
return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
}
if _, ok := input.FS["index"]; !ok {
return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
}
fs, err := memfs.New(input.FS)
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
}
ruler, err := textmeasure.NewRuler()
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
}
ctx := log.WithDefault(context.Background())
layoutFunc := d2dagrelayout.DefaultLayout
if input.Opts != nil && input.Opts.Layout != nil {
switch *input.Opts.Layout {
case "dagre":
layoutFunc = d2dagrelayout.DefaultLayout
case "elk":
layoutFunc = d2elklayout.DefaultLayout
default:
return nil, &WASMError{Message: fmt.Sprintf("layout option '%s' not recognized", *input.Opts.Layout), Code: 400}
}
}
layoutResolver := func(engine string) (d2graph.LayoutGraph, error) {
return layoutFunc, nil
}
renderOpts := &d2svg.RenderOpts{}
var fontFamily *d2fonts.FontFamily
if input.Opts != nil && input.Opts.Sketch != nil && *input.Opts.Sketch {
fontFamily = go2.Pointer(d2fonts.HandDrawn)
renderOpts.Sketch = input.Opts.Sketch
}
if input.Opts != nil && input.Opts.ThemeID != nil {
renderOpts.ThemeID = input.Opts.ThemeID
}
diagram, g, err := d2lib.Compile(ctx, input.FS["index"], &d2lib.CompileOptions{
UTF16Pos: true,
FS: fs,
Ruler: ruler,
LayoutResolver: layoutResolver,
FontFamily: fontFamily,
}, renderOpts)
if err != nil {
if pe, ok := err.(*d2parser.ParseError); ok {
return nil, &WASMError{Message: pe.Error(), Code: 400}
}
return nil, &WASMError{Message: err.Error(), Code: 500}
}
input.FS["index"] = d2format.Format(g.AST)
return CompileResponse{
FS: input.FS,
Diagram: *diagram,
Graph: *g,
}, nil
}
func Render(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
}
var input RenderRequest
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
}
if input.Diagram == nil {
return nil, &WASMError{Message: "missing 'diagram' field in input JSON", Code: 400}
}
renderOpts := &d2svg.RenderOpts{}
if input.Opts != nil && input.Opts.Sketch != nil {
renderOpts.Sketch = input.Opts.Sketch
}
if input.Opts != nil && input.Opts.ThemeID != nil {
renderOpts.ThemeID = input.Opts.ThemeID
}
out, err := d2svg.Render(input.Diagram, renderOpts)
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("render failed: %s", err.Error()), Code: 500}
}
return out, nil
}
func GetBoardAtPosition(args []js.Value) (interface{}, error) {
if len(args) < 3 {
return nil, &WASMError{Message: "missing required arguments", Code: 400}
}
dsl := args[0].String()
line := args[1].Int()
column := args[2].Int()
boardPath, err := d2lsp.GetBoardAtPosition(dsl, d2ast.Position{
Line: line,
Column: column,
})
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 500}
}
return BoardPositionResponse{BoardPath: boardPath}, nil
}
func Encode(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing script argument", Code: 400}
}
script := args[0].String()
encoded, err := urlenc.Encode(script)
// should never happen
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 500}
}
return map[string]string{"result": encoded}, nil
}
func Decode(args []js.Value) (interface{}, error) {
if len(args) < 1 {
return nil, &WASMError{Message: "missing script argument", Code: 400}
}
script := args[0].String()
script, err := urlenc.Decode(script)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 500}
}
return map[string]string{"result": script}, nil
}
func GetVersion(args []js.Value) (interface{}, error) {
return version.Version, nil
}
func GetCompletions(args []js.Value) (interface{}, error) {
if len(args) < 3 {
return nil, &WASMError{Message: "missing required arguments", Code: 400}
}
text := args[0].String()
line := args[1].Int()
column := args[2].Int()
completions, err := d2lsp.GetCompletionItems(text, line, column)
if err != nil {
return nil, &WASMError{Message: err.Error(), Code: 500}
}
// Convert to map for JSON serialization
items := make([]map[string]interface{}, len(completions))
for i, completion := range completions {
items[i] = map[string]interface{}{
"label": completion.Label,
"kind": int(completion.Kind),
"detail": completion.Detail,
"insertText": completion.InsertText,
}
}
return CompletionResponse{
Items: items,
}, nil
}

58
d2js/d2wasm/types.go Normal file
View file

@ -0,0 +1,58 @@
//go:build js && wasm
package d2wasm
import (
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
)
type WASMResponse struct {
Data interface{} `json:"data,omitempty"`
Error *WASMError `json:"error,omitempty"`
}
type WASMError struct {
Message string `json:"message"`
Code int `json:"code"`
}
func (e *WASMError) Error() string {
return e.Message
}
type RefRangesResponse struct {
Ranges []d2ast.Range `json:"ranges"`
ImportRanges []d2ast.Range `json:"importRanges"`
}
type BoardPositionResponse struct {
BoardPath []string `json:"boardPath"`
}
type CompileRequest struct {
FS map[string]string `json:"fs"`
Opts *RenderOptions `json:"options"`
}
type RenderOptions struct {
Layout *string `json:"layout"`
Sketch *bool `json:"sketch"`
ThemeID *int64 `json:"themeID"`
}
type CompileResponse struct {
FS map[string]string `json:"fs"`
Diagram d2target.Diagram `json:"diagram"`
Graph d2graph.Graph `json:"graph"`
}
type CompletionResponse struct {
Items []map[string]interface{} `json:"items"`
}
type RenderRequest struct {
Diagram *d2target.Diagram `json:"diagram"`
Opts *RenderOptions `json:"options"`
}

32
d2js/js.go Normal file
View file

@ -0,0 +1,32 @@
//go:build js && wasm
package main
import (
"syscall/js"
"oss.terrastruct.com/d2/d2js/d2wasm"
)
func main() {
api := d2wasm.NewD2API()
api.Register("getCompletions", d2wasm.GetCompletions)
api.Register("getParentID", d2wasm.GetParentID)
api.Register("getObjOrder", d2wasm.GetObjOrder)
api.Register("getRefRanges", d2wasm.GetRefRanges)
api.Register("getELKGraph", d2wasm.GetELKGraph)
api.Register("compile", d2wasm.Compile)
api.Register("render", d2wasm.Render)
api.Register("getBoardAtPosition", d2wasm.GetBoardAtPosition)
api.Register("encode", d2wasm.Encode)
api.Register("decode", d2wasm.Decode)
api.Register("version", d2wasm.GetVersion)
api.ExportTo(js.Global())
if cb := js.Global().Get("onWasmInitialized"); !cb.IsUndefined() {
cb.Invoke()
}
select {}
}

28
d2js/js/.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
node_modules
.npm
bun.lockb
src/wasm-loader.browser.js
wasm/d2.wasm
dist/
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
logs
*.log
npm-debug.log*
coverage/
.env
.env.local
.env.*.local
*.tmp
*.temp
.cache/

1
d2js/js/.prettierignore Normal file
View file

@ -0,0 +1 @@
src/platform.browser.js

8
d2js/js/CHANGELOG.md Normal file
View file

@ -0,0 +1,8 @@
# Changelog
All notable changes to only the d2.js package will be documented in this file. **Does not
include changes to the main d2 project.**
## [0.1.0] - 2025-01-12
First public release

29
d2js/js/Makefile Normal file
View file

@ -0,0 +1,29 @@
.POSIX:
.PHONY: all
all: fmt build test cleanup
.PHONY: fmt
fmt: node_modules
prefix "$@" ../../ci/sub/bin/fmt.sh
prefix "$@" rm -f yarn.lock
.PHONY: build
build: fmt
prefix "$@" ./ci/build.sh
.PHONY: dev
dev: build
prefix "$@" git checkout -- src/platform.js src/worker.js
prefix "$@" bun run dev
.PHONY: test
test: build
prefix "$@" bun test:all
.PHONY: node_modules
node_modules:
prefix "$@" bun install $${CI:+--frozen-lockfile}
.PHONY: cleanup
cleanup: test
prefix "$@" git checkout -- src/platform.js src/worker.js

96
d2js/js/README.md Normal file
View file

@ -0,0 +1,96 @@
# D2.js
[![npm version](https://badge.fury.io/js/%40terrastruct%2Fd2.svg)](https://www.npmjs.com/package/@terrastruct/d2)
[![License: MPL-2.0](https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg)](https://mozilla.org/MPL/2.0/)
D2.js is a JavaScript wrapper around D2, the modern diagram scripting language. It enables running D2 directly in browsers and Node environments through WebAssembly.
## Features
- 🌐 **Universal** - Works in both browser and Node environments
- 🚀 **Modern** - Built with ESM modules, with CJS fallback
- 🔄 **Isomorphic** - Same API everywhere
- ⚡ **Fast** - Powered by WebAssembly for near-native performance
- 📦 **Lightweight** - Minimal wrapper around the core D2 engine
## Installation
```bash
# npm
npm install @terrastruct/d2
# yarn
yarn add @terrastruct/d2
# pnpm
pnpm add @terrastruct/d2
# bun
bun add @terrastruct/d2
```
## Usage
D2.js uses webworkers to call a WASM file.
```javascript
// Same for Node or browser
import { D2 } from '@terrastruct/d2';
// Or using a CDN
// import { D2 } from 'https://esm.sh/@terrastruct/d2';
const d2 = new D2();
const result = await d2.compile('x -> y');
const svg = await d2.render(result.diagram);
```
## API Reference
### `new D2()`
Creates a new D2 instance.
### `compile(input: string, options?: CompileOptions): Promise<CompileResult>`
Compiles D2 markup into an intermediate representation.
Options:
- `layout`: Layout engine to use ('dagre' | 'elk') [default: 'dagre']
- `sketch`: Enable sketch mode [default: false]
### `render(diagram: Diagram, options?: RenderOptions): Promise<string>`
Renders a compiled diagram to SVG.
## Development
D2.js uses Bun, so install this first.
### Building from source
```bash
git clone https://github.com/terrastruct/d2.git
cd d2/d2js/js
./make.sh all
```
If you change the main D2 source code, you should regenerate the WASM file:
```bash
./make.sh build
```
### Running the dev server
You can browse the examples by running the dev server:
```bash
./make.sh dev
```
Visit `http://localhost:3000` to see the example page.
## Contributing
Contributions are welcome!
## License
This project is licensed under the Mozilla Public License Version 2.0.

107
d2js/js/build.js Normal file
View file

@ -0,0 +1,107 @@
import { build } from "bun";
import { copyFile, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
const __dirname = new URL(".", import.meta.url).pathname;
const ROOT_DIR = resolve(__dirname);
const SRC_DIR = resolve(ROOT_DIR, "src");
await rm("./dist", { recursive: true, force: true });
await mkdir("./dist/browser", { recursive: true });
await mkdir("./dist/node-esm", { recursive: true });
await mkdir("./dist/node-cjs", { recursive: true });
const wasmBinary = await readFile("./wasm/d2.wasm");
const wasmExecJs = await readFile("./wasm/wasm_exec.js", "utf8");
await writeFile(
join(SRC_DIR, "wasm-loader.browser.js"),
`export const wasmBinary = Uint8Array.from(atob("${Buffer.from(wasmBinary).toString(
"base64"
)}"), c => c.charCodeAt(0));
export const wasmExecJs = ${JSON.stringify(wasmExecJs)};`
);
const commonConfig = {
minify: true,
};
async function buildDynamicFiles(platform) {
const platformContent =
platform === "node"
? `export * from "./platform.node.js";`
: `export * from "./platform.browser.js";`;
const platformPath = join(SRC_DIR, "platform.js");
await writeFile(platformPath, platformContent);
const workerSource =
platform === "node"
? join(SRC_DIR, "worker.node.js")
: join(SRC_DIR, "worker.browser.js");
const workerTarget = join(SRC_DIR, "worker.js");
const workerContent = await readFile(workerSource, "utf8");
await writeFile(workerTarget, workerContent);
}
async function buildAndCopy(buildType) {
const configs = {
browser: {
outdir: resolve(ROOT_DIR, "dist/browser"),
splitting: false,
format: "esm",
target: "browser",
platform: "browser",
entrypoints: [resolve(SRC_DIR, "index.js")],
},
"node-esm": {
outdir: resolve(ROOT_DIR, "dist/node-esm"),
splitting: true,
format: "esm",
target: "node",
platform: "node",
entrypoints: [resolve(SRC_DIR, "index.js"), resolve(SRC_DIR, "worker.js")],
},
"node-cjs": {
outdir: resolve(ROOT_DIR, "dist/node-cjs"),
splitting: false,
format: "cjs",
target: "node",
platform: "node",
entrypoints: [resolve(SRC_DIR, "index.js"), resolve(SRC_DIR, "worker.js")],
},
};
const config = configs[buildType];
await buildDynamicFiles(config.platform);
const result = await build({
...commonConfig,
...config,
});
if (!result.outputs || result.outputs.length === 0) {
throw new Error(
`No outputs generated for ${buildType} build. Result: ${JSON.stringify(result)}`
);
}
if (buildType !== "browser") {
await copyFile(resolve(ROOT_DIR, "wasm/d2.wasm"), join(config.outdir, "d2.wasm"));
await copyFile(
resolve(ROOT_DIR, "wasm/wasm_exec.js"),
join(config.outdir, "wasm_exec.js")
);
await copyFile(resolve(ROOT_DIR, "src/elk.js"), join(config.outdir, "elk.js"));
}
}
try {
await buildAndCopy("browser");
await buildAndCopy("node-esm");
await buildAndCopy("node-cjs");
} catch (error) {
console.error("Build failed:", error);
process.exit(1);
}

BIN
d2js/js/bun.lockb Executable file

Binary file not shown.

19
d2js/js/ci/build.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../../../ci/sub/lib.sh"
cd -- "$(dirname "$0")/.."
cd ../..
sh_c "GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o main.wasm ./d2js"
sh_c "mv main.wasm ./d2js/js/wasm/d2.wasm"
if [ ! -f ./d2js/js/wasm/d2.wasm ]; then
echoerr "Error: d2.wasm is missing"
exit 1
else
echo "d2.wasm exists. Size:"
ls -lh ./d2js/js/wasm/d2.wasm | awk '{print $5}'
fi
cd d2js/js
sh_c bun build.js

72
d2js/js/dev-server.js Normal file
View file

@ -0,0 +1,72 @@
const fs = require("fs/promises");
const path = require("path");
const MIME_TYPES = {
".html": "text/html",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".wasm": "application/wasm",
".svg": "image/svg+xml",
};
const server = Bun.serve({
port: 3000,
async fetch(request) {
const url = new URL(request.url);
let filePath = url.pathname.slice(1); // Remove leading "/"
if (filePath === "") {
filePath = "examples/";
}
try {
const fullPath = path.join(process.cwd(), filePath);
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
const entries = await fs.readdir(fullPath);
const links = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(fullPath, entry);
const isDir = (await fs.stat(entryPath)).isDirectory();
const slash = isDir ? "/" : "";
return `<li><a href="${filePath}${entry}${slash}">${entry}${slash}</a></li>`;
})
);
const html = `
<html>
<body>
<h1>Examples</h1>
<ul>
${links.join("")}
</ul>
</body>
</html>
`;
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
} else {
const ext = path.extname(filePath);
const mimeType = MIME_TYPES[ext] || "application/octet-stream";
const file = Bun.file(filePath);
return new Response(file, {
headers: {
"Content-Type": mimeType,
"Access-Control-Allow-Origin": "*",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
});
}
} catch (err) {
console.error(`Error serving ${filePath}:`, err);
return new Response(`File not found: ${filePath}`, { status: 404 });
}
},
});
console.log(`Server running at http://localhost:3000`);

View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
display: flex;
gap: 20px;
padding: 20px;
height: 100vh;
margin: 0;
}
textarea {
width: 400px;
height: 300px;
}
#output {
flex: 1;
overflow: auto;
}
#output svg {
max-width: 100%;
max-height: 90vh;
}
</style>
</head>
<body>
<div>
<textarea id="input">x -> y</textarea>
<button onclick="compile()">Compile</button>
</div>
<div id="output"></div>
<script type="module">
import { D2 } from "../dist/browser/index.js";
const d2 = new D2();
window.compile = async () => {
const input = document.getElementById("input").value;
try {
const result = await d2.compile(input);
const svg = await d2.render(result.diagram);
document.getElementById("output").innerHTML = svg;
} catch (err) {
console.error(err);
document.getElementById("output").textContent = err.message;
}
};
compile();
</script>
</body>
</html>

View file

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
display: flex;
gap: 20px;
padding: 20px;
height: 100vh;
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
}
.controls {
display: flex;
flex-direction: column;
gap: 12px;
width: 400px;
}
textarea {
width: 100%;
height: 300px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
}
.options-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid #eee;
border-radius: 4px;
}
.layout-toggle,
.sketch-toggle {
display: flex;
gap: 16px;
align-items: center;
}
.radio-group {
display: flex;
gap: 12px;
}
.radio-label,
.checkbox-label {
display: flex;
gap: 4px;
align-items: center;
cursor: pointer;
}
button {
padding: 8px 16px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0052a3;
}
#output {
flex: 1;
overflow: auto;
border: 1px solid #eee;
border-radius: 4px;
padding: 16px;
}
#output svg {
max-width: 100%;
max-height: 90vh;
}
</style>
</head>
<body>
<div class="controls">
<textarea id="input">x -> y</textarea>
<div class="options-group">
<div class="layout-toggle">
<span>Layout:</span>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="layout" value="dagre" checked />
Dagre
</label>
<label class="radio-label">
<input type="radio" name="layout" value="elk" />
ELK
</label>
</div>
</div>
<div class="sketch-toggle">
<label class="checkbox-label">
<input type="checkbox" id="sketch" />
Sketch mode
</label>
</div>
</div>
<button onclick="compile()">Compile</button>
</div>
<div id="output"></div>
<script type="module">
import { D2 } from "../dist/browser/index.js";
const d2 = new D2();
window.compile = async () => {
const input = document.getElementById("input").value;
const layout = document.querySelector('input[name="layout"]:checked').value;
const sketch = document.getElementById("sketch").checked;
try {
const result = await d2.compile(input, { layout, sketch });
const svg = await d2.render(result.diagram, { sketch });
document.getElementById("output").innerHTML = svg;
} catch (err) {
console.error(err);
document.getElementById("output").textContent = err.message;
}
};
compile();
</script>
</body>
</html>

23
d2js/js/make.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
set -eu
if [ ! -e "$(dirname "$0")/../../ci/sub/.git" ]; then
set -x
git submodule update --init
set +x
fi
. "$(dirname "$0")/../../ci/sub/lib.sh"
PATH="$(cd -- "$(dirname "$0")" && pwd)/../../ci/sub/bin:$PATH"
cd -- "$(dirname "$0")"
if ! command -v bun >/dev/null 2>&1; then
if [ -n "${CI-}" ]; then
echo "Bun is not installed. Installing Bun..."
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
else
echoerr "You need bun to build d2.js: curl -fsSL https://bun.sh/install | bash"
exit 1
fi
fi
_make "$@"

56
d2js/js/package.json Normal file
View file

@ -0,0 +1,56 @@
{
"name": "@terrastruct/d2",
"author": "Terrastruct, Inc.",
"description": "D2.js is a wrapper around the WASM build of D2, the modern text-to-diagram language.",
"version": "0.1.21",
"repository": {
"type": "git",
"url": "git+https://github.com/terrastruct/d2.git",
"directory": "d2js/js"
},
"bugs": {
"url": "https://github.com/terrastruct/d2/issues"
},
"homepage": "https://github.com/terrastruct/d2/tree/master/d2js/js#readme",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"exports": {
".": {
"browser": "./dist/browser/index.js",
"import": {
"browser": "./dist/browser/index.js",
"default": "./dist/node-esm/index.js"
},
"require": "./dist/node-cjs/index.js",
"default": "./dist/node-esm/index.js"
},
"./worker": "./dist/browser/worker.js"
},
"files": [
"dist"
],
"scripts": {
"build": "./make.sh build",
"test": "bun test test/unit",
"test:integration": "bun test test/integration",
"test:all": "bun run test && bun run test:integration",
"dev": "bun --watch dev-server.js",
"prepublishOnly": "./make.sh all"
},
"keywords": [
"d2",
"d2lang",
"diagram",
"wasm",
"text-to-diagram",
"go"
],
"license": "MPL-2.0",
"devDependencies": {
"bun": "latest"
}
}

105805
d2js/js/src/elk.js Normal file

File diff suppressed because it is too large Load diff

109
d2js/js/src/index.js Normal file
View file

@ -0,0 +1,109 @@
import { createWorker, loadFile } from "./platform.js";
const DEFAULT_OPTIONS = {
layout: "dagre",
sketch: false,
};
export class D2 {
constructor() {
this.ready = this.init();
}
setupMessageHandler() {
const isNode = typeof window === "undefined";
return new Promise((resolve, reject) => {
if (isNode) {
this.worker.on("message", (data) => {
if (data.type === "ready") resolve();
if (data.type === "error") reject(new Error(data.error));
if (data.type === "result" && this.currentResolve) {
this.currentResolve(data.data);
}
if (data.type === "error" && this.currentReject) {
this.currentReject(new Error(data.error));
}
});
} else {
this.worker.onmessage = (e) => {
if (e.data.type === "ready") resolve();
if (e.data.type === "error") reject(new Error(e.data.error));
if (e.data.type === "result" && this.currentResolve) {
this.currentResolve(e.data.data);
}
if (e.data.type === "error" && this.currentReject) {
this.currentReject(new Error(e.data.error));
}
};
}
});
}
async init() {
this.worker = await createWorker();
const elkContent = await loadFile("./elk.js");
const wasmExecContent = await loadFile("./wasm_exec.js");
const wasmBinary = await loadFile("./d2.wasm");
const isNode = typeof window === "undefined";
const messageHandler = this.setupMessageHandler();
if (isNode) {
this.worker.on("error", (error) => {
console.error("Worker (node) encountered an error:", error.message || error);
});
} else {
this.worker.onerror = (error) => {
console.error("Worker encountered an error:", error.message || error);
};
}
this.worker.postMessage({
type: "init",
data: {
wasm: wasmBinary,
wasmExecContent: isNode ? wasmExecContent.toString() : null,
elkContent: isNode ? elkContent.toString() : null,
wasmExecUrl: isNode
? null
: URL.createObjectURL(
new Blob([wasmExecContent], { type: "application/javascript" })
),
},
});
return messageHandler;
}
async sendMessage(type, data) {
await this.ready;
return new Promise((resolve, reject) => {
this.currentResolve = resolve;
this.currentReject = reject;
this.worker.postMessage({ type, data });
});
}
async compile(input, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options };
const request =
typeof input === "string"
? { fs: { index: input }, options: opts }
: { ...input, options: { ...opts, ...input.options } };
return this.sendMessage("compile", request);
}
async render(diagram, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options };
return this.sendMessage("render", { diagram, options: opts });
}
async encode(script) {
return this.sendMessage("encode", script);
}
async decode(encoded) {
return this.sendMessage("decode", encoded);
}
}

View file

@ -0,0 +1,26 @@
import { wasmBinary, wasmExecJs } from "./wasm-loader.browser.js";
import workerScript from "./worker.js" with { type: "text" };
import elkScript from "./elk.js" with { type: "text" };
// For the browser version, we build the wasm files into a file (wasm-loader.browser.js)
// and loading a file just reads the text, so there's no external dependency calls
export async function loadFile(path) {
if (path === "./d2.wasm") {
return wasmBinary.buffer;
}
if (path === "./wasm_exec.js") {
return new TextEncoder().encode(wasmExecJs).buffer;
}
return null;
}
export async function createWorker() {
let blob = new Blob([wasmExecJs, elkScript, workerScript], {
type: "text/javascript;charset=utf-8",
});
const worker = new Worker(URL.createObjectURL(blob), {
type: "module",
});
return worker;
}

1
d2js/js/src/platform.js Normal file
View file

@ -0,0 +1 @@
export * from "./platform.node.js";

View file

@ -0,0 +1,40 @@
let nodeModules = null;
async function loadNodeModules() {
if (!nodeModules) {
nodeModules = {
fs: await import("fs/promises"),
path: await import("path"),
url: await import("url"),
worker: await import("worker_threads"),
};
}
return nodeModules;
}
export async function loadFile(path) {
const modules = await loadNodeModules();
const readFile = modules.fs.readFile;
const { join, dirname } = modules.path;
const { fileURLToPath } = modules.url;
const __dirname = dirname(fileURLToPath(import.meta.url));
try {
return await readFile(join(__dirname, path));
} catch (err) {
if (err.code === "ENOENT") {
return await readFile(join(__dirname, "../../../wasm", path.replace("./", "")));
}
throw err;
}
}
export async function createWorker() {
const modules = await loadNodeModules();
const { Worker } = modules.worker;
const { join, dirname } = modules.path;
const { fileURLToPath } = modules.url;
const __dirname = dirname(fileURLToPath(import.meta.url));
const workerPath = join(__dirname, "worker.js");
return new Worker(workerPath);
}

View file

@ -0,0 +1,8 @@
import { readFile } from "fs/promises";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
export async function getWasmBinary() {
return readFile(resolve(__dirname, "./d2.wasm"));
}

View file

@ -0,0 +1,76 @@
let currentPort;
let d2;
let elk;
export function setupMessageHandler(isNode, port, initWasm) {
currentPort = port;
const handleMessage = async (e) => {
const { type, data } = e;
switch (type) {
case "init":
try {
if (isNode) {
eval(data.wasmExecContent);
}
d2 = await initWasm(data.wasm);
elk = new ELK();
currentPort.postMessage({ type: "ready" });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "compile":
try {
// We use Go to get the intermediate ELK graph
// Then natively run elk layout
// This is due to elk.layout being an async function, which a
// single-threaded WASM call cannot complete without giving control back
// So we compute it, store it here, then during elk layout, instead
// of computing again, we use this variable (and unset it for next call)
if (data.options.layout === "elk") {
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
const elkGraph2 = JSON.parse(elkGraph).data;
const layout = await elk.layout(elkGraph2);
globalThis.elkResult = layout;
}
const result = await d2.compile(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: response.data });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "render":
try {
const result = await d2.render(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: atob(response.data) });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
}
};
if (isNode) {
port.on("message", handleMessage);
} else {
port.onmessage = (e) => handleMessage(e.data);
}
}
async function initWasmBrowser(wasmBinary) {
const go = new Go();
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
go.run(result.instance);
return self.d2;
}
setupMessageHandler(false, self, initWasmBrowser);

72
d2js/js/src/worker.js Normal file
View file

@ -0,0 +1,72 @@
import { parentPort } from "node:worker_threads";
let currentPort;
let d2;
let elk;
export function setupMessageHandler(isNode, port, initWasm) {
currentPort = port;
const handleMessage = async (e) => {
const { type, data } = e;
switch (type) {
case "init":
try {
if (isNode) {
eval(data.wasmExecContent);
eval(data.elkContent);
}
d2 = await initWasm(data.wasm);
elk = new ELK();
currentPort.postMessage({ type: "ready" });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "compile":
try {
if (data.options.layout === "elk") {
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
const elkGraph2 = JSON.parse(elkGraph).data;
const layout = await elk.layout(elkGraph2);
globalThis.elkResult = layout;
}
const result = await d2.compile(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: response.data });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "render":
try {
const result = await d2.render(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: atob(response.data) });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
}
};
if (isNode) {
port.on("message", handleMessage);
} else {
port.onmessage = (e) => handleMessage(e.data);
}
}
async function initWasmNode(wasmBinary) {
const go = new Go();
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
go.run(result.instance);
return global.d2;
}
setupMessageHandler(true, parentPort, initWasmNode);

View file

@ -0,0 +1,72 @@
import { parentPort } from "node:worker_threads";
let currentPort;
let d2;
let elk;
export function setupMessageHandler(isNode, port, initWasm) {
currentPort = port;
const handleMessage = async (e) => {
const { type, data } = e;
switch (type) {
case "init":
try {
if (isNode) {
eval(data.wasmExecContent);
eval(data.elkContent);
}
d2 = await initWasm(data.wasm);
elk = new ELK();
currentPort.postMessage({ type: "ready" });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "compile":
try {
if (data.options.layout === "elk") {
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
const elkGraph2 = JSON.parse(elkGraph).data;
const layout = await elk.layout(elkGraph2);
globalThis.elkResult = layout;
}
const result = await d2.compile(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: response.data });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
case "render":
try {
const result = await d2.render(JSON.stringify(data));
const response = JSON.parse(result);
if (response.error) throw new Error(response.error.message);
currentPort.postMessage({ type: "result", data: atob(response.data) });
} catch (err) {
currentPort.postMessage({ type: "error", error: err.message });
}
break;
}
};
if (isNode) {
port.on("message", handleMessage);
} else {
port.onmessage = (e) => handleMessage(e.data);
}
}
async function initWasmNode(wasmBinary) {
const go = new Go();
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
go.run(result.instance);
return global.d2;
}
setupMessageHandler(true, parentPort, initWasmNode);

View file

@ -0,0 +1,11 @@
import { expect, test, describe } from "bun:test";
describe("D2 CJS Integration", () => {
test("can require and use CJS build", async () => {
const { D2 } = require("../../dist/node-cjs/index.js");
const d2 = new D2();
const result = await d2.compile("x -> y");
expect(result.diagram).toBeDefined();
await d2.worker.terminate();
}, 20000);
});

View file

@ -0,0 +1,11 @@
import { expect, test, describe } from "bun:test";
import { D2 } from "../../dist/node-esm/index.js";
describe("D2 ESM Integration", () => {
test("can import and use ESM build", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y");
expect(result.diagram).toBeDefined();
await d2.worker.terminate();
}, 20000);
});

View file

@ -0,0 +1,58 @@
import { expect, test, describe } from "bun:test";
import { D2 } from "../../dist/node-esm/index.js";
describe("D2 Unit Tests", () => {
test("basic compilation works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y");
expect(result.diagram).toBeDefined();
await d2.worker.terminate();
}, 20000);
test("elk layout works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y", { layout: "elk" });
expect(result.diagram).toBeDefined();
await d2.worker.terminate();
}, 20000);
test("render works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y");
const svg = await d2.render(result.diagram);
expect(svg).toContain("<svg");
expect(svg).toContain("</svg>");
await d2.worker.terminate();
}, 20000);
test("sketch render works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y", { sketch: true });
const svg = await d2.render(result.diagram, { sketch: true });
expect(svg).toContain("<svg");
expect(svg).toContain("</svg>");
expect(svg).toContain("sketch-overlay");
await d2.worker.terminate();
}, 20000);
test("latex works", async () => {
const d2 = new D2();
const result = await d2.compile("x: |latex \\frac{f(x+h)-f(x)}{h} |");
const svg = await d2.render(result.diagram);
expect(svg).toContain("<svg");
expect(svg).toContain("</svg>");
await d2.worker.terminate();
}, 20000);
test("handles syntax errors correctly", async () => {
const d2 = new D2();
try {
await d2.compile("invalid -> -> syntax");
throw new Error("Should have thrown syntax error");
} catch (err) {
expect(err).toBeDefined();
expect(err.message).not.toContain("Should have thrown syntax error");
}
await d2.worker.terminate();
}, 20000);
});

477
d2js/js/wasm/wasm_exec.js Normal file
View file

@ -0,0 +1,477 @@
"use strict";
(() => {
const o = () => {
const h = new Error("not implemented");
return (h.code = "ENOSYS"), h;
};
if (!globalThis.fs) {
let h = "";
globalThis.fs = {
constants: {
O_WRONLY: -1,
O_RDWR: -1,
O_CREAT: -1,
O_TRUNC: -1,
O_APPEND: -1,
O_EXCL: -1,
},
writeSync(n, s) {
h += y.decode(s);
const i = h.lastIndexOf(`
`);
return (
i != -1 && (console.log(h.substring(0, i)), (h = h.substring(i + 1))), s.length
);
},
write(n, s, i, r, f, u) {
if (i !== 0 || r !== s.length || f !== null) {
u(o());
return;
}
const d = this.writeSync(n, s);
u(null, d);
},
chmod(n, s, i) {
i(o());
},
chown(n, s, i, r) {
r(o());
},
close(n, s) {
s(o());
},
fchmod(n, s, i) {
i(o());
},
fchown(n, s, i, r) {
r(o());
},
fstat(n, s) {
s(o());
},
fsync(n, s) {
s(null);
},
ftruncate(n, s, i) {
i(o());
},
lchown(n, s, i, r) {
r(o());
},
link(n, s, i) {
i(o());
},
lstat(n, s) {
s(o());
},
mkdir(n, s, i) {
i(o());
},
open(n, s, i, r) {
r(o());
},
read(n, s, i, r, f, u) {
u(o());
},
readdir(n, s) {
s(o());
},
readlink(n, s) {
s(o());
},
rename(n, s, i) {
i(o());
},
rmdir(n, s) {
s(o());
},
stat(n, s) {
s(o());
},
symlink(n, s, i) {
i(o());
},
truncate(n, s, i) {
i(o());
},
unlink(n, s) {
s(o());
},
utimes(n, s, i, r) {
r(o());
},
};
}
if (
(globalThis.process ||
(globalThis.process = {
getuid() {
return -1;
},
getgid() {
return -1;
},
geteuid() {
return -1;
},
getegid() {
return -1;
},
getgroups() {
throw o();
},
pid: -1,
ppid: -1,
umask() {
throw o();
},
cwd() {
throw o();
},
chdir() {
throw o();
},
}),
!globalThis.crypto)
)
throw new Error(
"globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"
);
if (!globalThis.performance)
throw new Error(
"globalThis.performance is not available, polyfill required (performance.now only)"
);
if (!globalThis.TextEncoder)
throw new Error("globalThis.TextEncoder is not available, polyfill required");
if (!globalThis.TextDecoder)
throw new Error("globalThis.TextDecoder is not available, polyfill required");
const g = new TextEncoder("utf-8"),
y = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
(this.argv = ["js"]),
(this.env = {}),
(this.exit = (t) => {
t !== 0 && console.warn("exit code:", t);
}),
(this._exitPromise = new Promise((t) => {
this._resolveExitPromise = t;
})),
(this._pendingEvent = null),
(this._scheduledTimeouts = new Map()),
(this._nextCallbackTimeoutID = 1);
const h = (t, e) => {
this.mem.setUint32(t + 0, e, !0),
this.mem.setUint32(t + 4, Math.floor(e / 4294967296), !0);
},
n = (t, e) => {
this.mem.setUint32(t + 0, e, !0);
},
s = (t) => {
const e = this.mem.getUint32(t + 0, !0),
l = this.mem.getInt32(t + 4, !0);
return e + l * 4294967296;
},
i = (t) => {
const e = this.mem.getFloat64(t, !0);
if (e === 0) return;
if (!isNaN(e)) return e;
const l = this.mem.getUint32(t, !0);
return this._values[l];
},
r = (t, e) => {
if (typeof e == "number" && e !== 0) {
if (isNaN(e)) {
this.mem.setUint32(t + 4, 2146959360, !0), this.mem.setUint32(t, 0, !0);
return;
}
this.mem.setFloat64(t, e, !0);
return;
}
if (e === void 0) {
this.mem.setFloat64(t, 0, !0);
return;
}
let a = this._ids.get(e);
a === void 0 &&
((a = this._idPool.pop()),
a === void 0 && (a = this._values.length),
(this._values[a] = e),
(this._goRefCounts[a] = 0),
this._ids.set(e, a)),
this._goRefCounts[a]++;
let c = 0;
switch (typeof e) {
case "object":
e !== null && (c = 1);
break;
case "string":
c = 2;
break;
case "symbol":
c = 3;
break;
case "function":
c = 4;
break;
}
this.mem.setUint32(t + 4, 2146959360 | c, !0), this.mem.setUint32(t, a, !0);
},
f = (t) => {
const e = s(t + 0),
l = s(t + 8);
return new Uint8Array(this._inst.exports.mem.buffer, e, l);
},
u = (t) => {
const e = s(t + 0),
l = s(t + 8),
a = new Array(l);
for (let c = 0; c < l; c++) a[c] = i(e + c * 8);
return a;
},
d = (t) => {
const e = s(t + 0),
l = s(t + 8);
return y.decode(new DataView(this._inst.exports.mem.buffer, e, l));
},
m = Date.now() - performance.now();
this.importObject = {
_gotest: { add: (t, e) => t + e },
gojs: {
"runtime.wasmExit": (t) => {
t >>>= 0;
const e = this.mem.getInt32(t + 8, !0);
(this.exited = !0),
delete this._inst,
delete this._values,
delete this._goRefCounts,
delete this._ids,
delete this._idPool,
this.exit(e);
},
"runtime.wasmWrite": (t) => {
t >>>= 0;
const e = s(t + 8),
l = s(t + 16),
a = this.mem.getInt32(t + 24, !0);
fs.writeSync(e, new Uint8Array(this._inst.exports.mem.buffer, l, a));
},
"runtime.resetMemoryDataView": (t) => {
(t >>>= 0), (this.mem = new DataView(this._inst.exports.mem.buffer));
},
"runtime.nanotime1": (t) => {
(t >>>= 0), h(t + 8, (m + performance.now()) * 1e6);
},
"runtime.walltime": (t) => {
t >>>= 0;
const e = new Date().getTime();
h(t + 8, e / 1e3), this.mem.setInt32(t + 16, (e % 1e3) * 1e6, !0);
},
"runtime.scheduleTimeoutEvent": (t) => {
t >>>= 0;
const e = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++,
this._scheduledTimeouts.set(
e,
setTimeout(() => {
for (this._resume(); this._scheduledTimeouts.has(e); )
console.warn("scheduleTimeoutEvent: missed timeout event"),
this._resume();
}, s(t + 8))
),
this.mem.setInt32(t + 16, e, !0);
},
"runtime.clearTimeoutEvent": (t) => {
t >>>= 0;
const e = this.mem.getInt32(t + 8, !0);
clearTimeout(this._scheduledTimeouts.get(e)),
this._scheduledTimeouts.delete(e);
},
"runtime.getRandomData": (t) => {
(t >>>= 0), crypto.getRandomValues(f(t + 8));
},
"syscall/js.finalizeRef": (t) => {
t >>>= 0;
const e = this.mem.getUint32(t + 8, !0);
if ((this._goRefCounts[e]--, this._goRefCounts[e] === 0)) {
const l = this._values[e];
(this._values[e] = null), this._ids.delete(l), this._idPool.push(e);
}
},
"syscall/js.stringVal": (t) => {
(t >>>= 0), r(t + 24, d(t + 8));
},
"syscall/js.valueGet": (t) => {
t >>>= 0;
const e = Reflect.get(i(t + 8), d(t + 16));
(t = this._inst.exports.getsp() >>> 0), r(t + 32, e);
},
"syscall/js.valueSet": (t) => {
(t >>>= 0), Reflect.set(i(t + 8), d(t + 16), i(t + 32));
},
"syscall/js.valueDelete": (t) => {
(t >>>= 0), Reflect.deleteProperty(i(t + 8), d(t + 16));
},
"syscall/js.valueIndex": (t) => {
(t >>>= 0), r(t + 24, Reflect.get(i(t + 8), s(t + 16)));
},
"syscall/js.valueSetIndex": (t) => {
(t >>>= 0), Reflect.set(i(t + 8), s(t + 16), i(t + 24));
},
"syscall/js.valueCall": (t) => {
t >>>= 0;
try {
const e = i(t + 8),
l = Reflect.get(e, d(t + 16)),
a = u(t + 32),
c = Reflect.apply(l, e, a);
(t = this._inst.exports.getsp() >>> 0),
r(t + 56, c),
this.mem.setUint8(t + 64, 1);
} catch (e) {
(t = this._inst.exports.getsp() >>> 0),
r(t + 56, e),
this.mem.setUint8(t + 64, 0);
}
},
"syscall/js.valueInvoke": (t) => {
t >>>= 0;
try {
const e = i(t + 8),
l = u(t + 16),
a = Reflect.apply(e, void 0, l);
(t = this._inst.exports.getsp() >>> 0),
r(t + 40, a),
this.mem.setUint8(t + 48, 1);
} catch (e) {
(t = this._inst.exports.getsp() >>> 0),
r(t + 40, e),
this.mem.setUint8(t + 48, 0);
}
},
"syscall/js.valueNew": (t) => {
t >>>= 0;
try {
const e = i(t + 8),
l = u(t + 16),
a = Reflect.construct(e, l);
(t = this._inst.exports.getsp() >>> 0),
r(t + 40, a),
this.mem.setUint8(t + 48, 1);
} catch (e) {
(t = this._inst.exports.getsp() >>> 0),
r(t + 40, e),
this.mem.setUint8(t + 48, 0);
}
},
"syscall/js.valueLength": (t) => {
(t >>>= 0), h(t + 16, parseInt(i(t + 8).length));
},
"syscall/js.valuePrepareString": (t) => {
t >>>= 0;
const e = g.encode(String(i(t + 8)));
r(t + 16, e), h(t + 24, e.length);
},
"syscall/js.valueLoadString": (t) => {
t >>>= 0;
const e = i(t + 8);
f(t + 16).set(e);
},
"syscall/js.valueInstanceOf": (t) => {
(t >>>= 0), this.mem.setUint8(t + 24, i(t + 8) instanceof i(t + 16) ? 1 : 0);
},
"syscall/js.copyBytesToGo": (t) => {
t >>>= 0;
const e = f(t + 8),
l = i(t + 32);
if (!(l instanceof Uint8Array || l instanceof Uint8ClampedArray)) {
this.mem.setUint8(t + 48, 0);
return;
}
const a = l.subarray(0, e.length);
e.set(a), h(t + 40, a.length), this.mem.setUint8(t + 48, 1);
},
"syscall/js.copyBytesToJS": (t) => {
t >>>= 0;
const e = i(t + 8),
l = f(t + 16);
if (!(e instanceof Uint8Array || e instanceof Uint8ClampedArray)) {
this.mem.setUint8(t + 48, 0);
return;
}
const a = l.subarray(0, e.length);
e.set(a), h(t + 40, a.length), this.mem.setUint8(t + 48, 1);
},
debug: (t) => {
console.log(t);
},
},
};
}
async run(h) {
if (!(h instanceof WebAssembly.Instance))
throw new Error("Go.run: WebAssembly.Instance expected");
(this._inst = h),
(this.mem = new DataView(this._inst.exports.mem.buffer)),
(this._values = [NaN, 0, null, !0, !1, globalThis, this]),
(this._goRefCounts = new Array(this._values.length).fill(1 / 0)),
(this._ids = new Map([
[0, 1],
[null, 2],
[!0, 3],
[!1, 4],
[globalThis, 5],
[this, 6],
])),
(this._idPool = []),
(this.exited = !1);
let n = 4096;
const s = (m) => {
const t = n,
e = g.encode(m + "\0");
return (
new Uint8Array(this.mem.buffer, n, e.length).set(e),
(n += e.length),
n % 8 !== 0 && (n += 8 - (n % 8)),
t
);
},
i = this.argv.length,
r = [];
this.argv.forEach((m) => {
r.push(s(m));
}),
r.push(0),
Object.keys(this.env)
.sort()
.forEach((m) => {
r.push(s(`${m}=${this.env[m]}`));
}),
r.push(0);
const u = n;
if (
(r.forEach((m) => {
this.mem.setUint32(n, m, !0), this.mem.setUint32(n + 4, 0, !0), (n += 8);
}),
n >= 12288)
)
throw new Error(
"total length of command line and environment variables exceeds limit"
);
this._inst.exports.run(i, u),
this.exited && this._resolveExitPromise(),
await this._exitPromise;
}
_resume() {
if (this.exited) throw new Error("Go program has already exited");
this._inst.exports.resume(), this.exited && this._resolveExitPromise();
}
_makeFuncWrapper(h) {
const n = this;
return function () {
const s = { id: h, this: this, args: arguments };
return (n._pendingEvent = s), n._resume(), s.result;
};
}
};
})();

View file

@ -9,8 +9,7 @@ import (
"sort"
"strings"
"cdr.dev/slog"
"github.com/dop251/goja"
"log/slog"
"oss.terrastruct.com/util-go/xdefer"
@ -19,6 +18,7 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/jsrunner"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/shape"
@ -79,11 +79,11 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
defer xdefer.Errorf(&err, "failed to dagre layout")
debugJS := false
vm := goja.New()
if _, err := vm.RunString(dagreJS); err != nil {
runner := jsrunner.NewJSRunner()
if _, err := runner.RunString(dagreJS); err != nil {
return err
}
if _, err := vm.RunString(setupJS); err != nil {
if _, err := runner.RunString(setupJS); err != nil {
return err
}
@ -135,7 +135,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
configJS := setGraphAttrs(rootAttrs)
if _, err := vm.RunString(configJS); err != nil {
if _, err := runner.RunString(configJS); err != nil {
return err
}
@ -179,22 +179,22 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
if debugJS {
log.Debug(ctx, "script", slog.F("all", setupJS+configJS+loadScript))
log.Debug(ctx, "script", slog.Any("all", setupJS+configJS+loadScript))
}
if _, err := vm.RunString(loadScript); err != nil {
if _, err := runner.RunString(loadScript); err != nil {
return err
}
if _, err := vm.RunString(`dagre.layout(g)`); err != nil {
if _, err := runner.RunString(`dagre.layout(g)`); err != nil {
if debugJS {
log.Warn(ctx, "layout error", slog.F("err", err))
log.Warn(ctx, "layout error", slog.Any("err", err))
}
return err
}
for i := range g.Objects {
val, err := vm.RunString(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i))
val, err := runner.RunString(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i))
if err != nil {
return err
}
@ -203,7 +203,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
return err
}
if debugJS {
log.Debug(ctx, "graph", slog.F("json", dn))
log.Debug(ctx, "graph", slog.Any("json", dn))
}
obj := mapper.ToObj(dn.ID)
@ -215,7 +215,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
for i, edge := range g.Edges {
val, err := vm.RunString(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i))
val, err := runner.RunString(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i))
if err != nil {
return err
}
@ -224,7 +224,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
return err
}
if debugJS {
log.Debug(ctx, "graph", slog.F("json", de))
log.Debug(ctx, "graph", slog.Any("json", de))
}
points := make([]*geo.Point, len(de.Points))
@ -570,13 +570,12 @@ func positionLabelsIcons(obj *d2graph.Object) {
} else {
obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
}
if float64(obj.LabelDimensions.Width) > obj.Width || float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
if float64(obj.LabelDimensions.Width) > obj.Width || float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
}
}
}
}

View file

@ -0,0 +1,10 @@
//go:build !js && !wasm
package d2elklayout
import (
_ "embed"
)
//go:embed elk.js
var elkJS string

View file

@ -5,15 +5,7 @@
define([], f);
} else {
var g;
if (typeof window !== "undefined") {
g = window;
} else if (typeof global !== "undefined") {
g = global;
} else if (typeof self !== "undefined") {
g = self;
} else {
g = this;
}
g = this;
g.ELK = f();
}
})(function () {
@ -337,9 +329,6 @@
// -------------- FAKE ELEMENTS GWT ASSUMES EXIST --------------
var $wnd = { Error: {} };
if (typeof window !== "undefined") $wnd = window;
else if (typeof global !== "undefined") $wnd = global; // nodejs
else if (typeof self !== "undefined") $wnd = self; // web worker
var $moduleName, $moduleBase;
@ -59795,13 +59784,8 @@
}, 0);
};
}
if (typeof document === uke && typeof self !== uke) {
var i = new h(self);
self.onmessage = i.saveDispatch;
} else if (typeof module !== uke && module.exports) {
Object.defineProperty(exports, "__esModule", { value: true });
module.exports = { default: j, Worker: j };
}
Object.defineProperty(exports, "__esModule", { value: true });
module.exports = { default: j, Worker: j };
}
function aae(a) {
if (a.N) return;
@ -105682,16 +105666,7 @@
// -------------- RUN GWT INITIALIZATION CODE --------------
gwtOnLoad(null, "elk", null);
}.call(this));
}.call(
this,
typeof global !== "undefined"
? global
: typeof self !== "undefined"
? self
: typeof window !== "undefined"
? window
: {}
));
}.call(this, {}));
},
{},
],

View file

@ -0,0 +1,6 @@
//go:build js && wasm
package d2elklayout
// No embed, since this is already bundled in the js worker
var elkJS string

View file

@ -8,15 +8,12 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"github.com/dop251/goja"
"oss.terrastruct.com/util-go/xdefer"
"oss.terrastruct.com/util-go/go2"
@ -24,13 +21,11 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/jsrunner"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
)
//go:embed elk.js
var elkJS string
//go:embed setup.js
var setupJS string
@ -162,18 +157,20 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
defer xdefer.Errorf(&err, "failed to ELK layout")
vm := goja.New()
runner := jsrunner.NewJSRunner()
console := vm.NewObject()
if err := vm.Set("console", console); err != nil {
return err
}
if runner.Engine() == jsrunner.Goja {
console := runner.NewObject()
if err := runner.Set("console", console); err != nil {
return err
}
if _, err := vm.RunString(elkJS); err != nil {
return err
}
if _, err := vm.RunString(setupJS); err != nil {
return err
if _, err := runner.RunString(elkJS); err != nil {
return err
}
if _, err := runner.RunString(setupJS); err != nil {
return err
}
}
elkGraph := &ELKGraph{
@ -443,41 +440,30 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
return err
}
loadScript := fmt.Sprintf(`var graph = %s`, raw)
var val jsrunner.JSValue
if runner.Engine() == jsrunner.Goja {
loadScript := fmt.Sprintf(`var graph = %s`, raw)
if _, err := vm.RunString(loadScript); err != nil {
return err
}
if _, err := runner.RunString(loadScript); err != nil {
return err
}
val, err := vm.RunString(`elk.layout(graph)
val, err = runner.RunString(`elk.layout(graph)
.then(s => s)
.catch(err => err.message)
`)
} else {
val, err = runner.MustGet("elkResult")
}
if err != nil {
return err
}
p := val.Export()
result, err := runner.WaitPromise(ctx, val)
if err != nil {
return err
return fmt.Errorf("ELK layout error: %v", err)
}
promise := p.(*goja.Promise)
for promise.State() == goja.PromiseStatePending {
if err := ctx.Err(); err != nil {
return err
}
continue
}
if promise.State() == goja.PromiseStateRejected {
return errors.New("ELK: something went wrong")
}
result := promise.Result().Export()
var jsonOut map[string]interface{}
switch out := result.(type) {
case string:
@ -1148,13 +1134,12 @@ func positionLabelsIcons(obj *d2graph.Object) {
} else {
obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
}
if float64(obj.LabelDimensions.Width) > obj.Width || float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
if float64(obj.LabelDimensions.Width) > obj.Width || float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
}
}
}
}

View file

@ -0,0 +1,286 @@
//go:build js && wasm
package d2elklayout
import (
"context"
"fmt"
"math"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/util-go/xdefer"
)
// This is mostly copy paste from Layout until elk.layout step
func ConvertGraph(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (_ *ELKGraph, err error) {
if opts == nil {
opts = &DefaultOpts
}
defer xdefer.Errorf(&err, "failed to ELK layout")
elkGraph := &ELKGraph{
ID: "",
LayoutOptions: &elkOpts{
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
EdgeNode: edge_node_spacing,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
Algorithm: opts.Algorithm,
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
},
},
}
if elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
// +5 for a tiny bit of padding
elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(g.Root, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch g.Root.Direction.Value {
case "down":
elkGraph.LayoutOptions.Direction = Down
case "up":
elkGraph.LayoutOptions.Direction = Up
case "right":
elkGraph.LayoutOptions.Direction = Right
case "left":
elkGraph.LayoutOptions.Direction = Left
default:
elkGraph.LayoutOptions.Direction = Down
}
// set label and icon positions for ELK
for _, obj := range g.Objects {
positionLabelsIcons(obj)
}
adjustments := make(map[*d2graph.Object]geo.Spacing)
elkNodes := make(map[*d2graph.Object]*ELKNode)
elkEdges := make(map[*d2graph.Edge]*ELKEdge)
// BFS
var walk func(*d2graph.Object, *d2graph.Object, func(*d2graph.Object, *d2graph.Object))
walk = func(obj, parent *d2graph.Object, fn func(*d2graph.Object, *d2graph.Object)) {
if obj.Parent != nil {
fn(obj, parent)
}
for _, ch := range obj.ChildrenArray {
walk(ch, obj, fn)
}
}
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
incoming := 0.
outgoing := 0.
for _, e := range g.Edges {
if e.Src == obj {
outgoing++
}
if e.Dst == obj {
incoming++
}
}
if incoming >= 2 || outgoing >= 2 {
switch g.Root.Direction.Value {
case "right", "left":
if obj.Attributes.HeightAttr == nil {
obj.Height = math.Max(obj.Height, math.Max(incoming, outgoing)*port_spacing)
}
default:
if obj.Attributes.WidthAttr == nil {
obj.Width = math.Max(obj.Width, math.Max(incoming, outgoing)*port_spacing)
}
}
}
if obj.HasLabel() && obj.HasIcon() {
// this gives shapes extra height for their label if they also have an icon
obj.Height += float64(obj.LabelDimensions.Height + label.PADDING)
}
margin, _ := obj.SpacingOpt(label.PADDING, label.PADDING, false)
width := margin.Left + obj.Width + margin.Right
height := margin.Top + obj.Height + margin.Bottom
adjustments[obj] = margin
n := &ELKNode{
ID: obj.AbsID(),
Width: width,
Height: height,
}
if len(obj.ChildrenArray) > 0 {
n.LayoutOptions = &elkOpts{
ForceNodeModelOrder: true,
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
EdgeNode: edge_node_spacing,
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
Padding: opts.Padding,
},
}
if n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(obj, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch elkGraph.LayoutOptions.Direction {
case Down, Up:
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(height)), int(math.Ceil(width)))
case Right, Left:
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(width)), int(math.Ceil(height)))
}
} else {
n.LayoutOptions = &elkOpts{
SelfLoopDistribution: "EQUALLY",
}
}
if obj.IsContainer() {
padding := parsePadding(opts.Padding)
padding = adjustPadding(obj, width, height, padding)
n.LayoutOptions.Padding = padding.String()
}
if obj.HasLabel() {
n.Labels = append(n.Labels, &ELKLabel{
Text: obj.Label.Value,
Width: float64(obj.LabelDimensions.Width),
Height: float64(obj.LabelDimensions.Height),
})
}
if parent == g.Root {
elkGraph.Children = append(elkGraph.Children, n)
} else {
elkNodes[parent].Children = append(elkNodes[parent].Children, n)
}
if obj.SQLTable != nil {
n.LayoutOptions.PortConstraints = "FIXED_POS"
columns := obj.SQLTable.Columns
colHeight := n.Height / float64(len(columns)+1)
n.Ports = make([]*ELKPort, 0, len(columns)*2)
var srcSide, dstSide PortSide
switch elkGraph.LayoutOptions.Direction {
case Left:
srcSide, dstSide = West, East
default:
srcSide, dstSide = East, West
}
for i, col := range columns {
n.Ports = append(n.Ports, &ELKPort{
ID: srcPortID(obj, col.Name.Label),
Y: float64(i+1)*colHeight + colHeight/2,
LayoutOptions: &elkOpts{PortSide: srcSide},
})
n.Ports = append(n.Ports, &ELKPort{
ID: dstPortID(obj, col.Name.Label),
Y: float64(i+1)*colHeight + colHeight/2,
LayoutOptions: &elkOpts{PortSide: dstSide},
})
}
}
elkNodes[obj] = n
})
var srcSide, dstSide PortSide
switch elkGraph.LayoutOptions.Direction {
case Up:
srcSide, dstSide = North, South
default:
srcSide, dstSide = South, North
}
ports := map[struct {
obj *d2graph.Object
side PortSide
}][]*ELKPort{}
for ei, edge := range g.Edges {
var src, dst string
switch {
case edge.SrcTableColumnIndex != nil:
src = srcPortID(edge.Src, edge.Src.SQLTable.Columns[*edge.SrcTableColumnIndex].Name.Label)
case edge.Src.SQLTable != nil:
p := &ELKPort{
ID: fmt.Sprintf("%s.%d", srcPortID(edge.Src, "__root__"), ei),
LayoutOptions: &elkOpts{PortSide: srcSide},
}
src = p.ID
elkNodes[edge.Src].Ports = append(elkNodes[edge.Src].Ports, p)
k := struct {
obj *d2graph.Object
side PortSide
}{edge.Src, srcSide}
ports[k] = append(ports[k], p)
default:
src = edge.Src.AbsID()
}
switch {
case edge.DstTableColumnIndex != nil:
dst = dstPortID(edge.Dst, edge.Dst.SQLTable.Columns[*edge.DstTableColumnIndex].Name.Label)
case edge.Dst.SQLTable != nil:
p := &ELKPort{
ID: fmt.Sprintf("%s.%d", dstPortID(edge.Dst, "__root__"), ei),
LayoutOptions: &elkOpts{PortSide: dstSide},
}
dst = p.ID
elkNodes[edge.Dst].Ports = append(elkNodes[edge.Dst].Ports, p)
k := struct {
obj *d2graph.Object
side PortSide
}{edge.Dst, dstSide}
ports[k] = append(ports[k], p)
default:
dst = edge.Dst.AbsID()
}
e := &ELKEdge{
ID: edge.AbsID(),
Sources: []string{src},
Targets: []string{dst},
}
if edge.Label.Value != "" {
e.Labels = append(e.Labels, &ELKLabel{
Text: edge.Label.Value,
Width: float64(edge.LabelDimensions.Width),
Height: float64(edge.LabelDimensions.Height),
LayoutOptions: &elkOpts{
InlineEdgeLabels: true,
},
})
}
elkGraph.Edges = append(elkGraph.Edges, e)
elkEdges[edge] = e
}
for k, ports := range ports {
width := elkNodes[k.obj].Width
spacing := width / float64(len(ports)+1)
for i, p := range ports {
p.X = float64(i+1) * spacing
}
}
return elkGraph, nil
}

View file

@ -3,12 +3,11 @@ package d2layouts
import (
"context"
"fmt"
"log/slog"
"math"
"sort"
"strings"
"cdr.dev/slog"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2grid"
"oss.terrastruct.com/d2/d2layouts/d2near"
@ -203,7 +202,7 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co
extractedEdges = append(extractedEdges, externalEdges...)
extractedEdgeIDs = append(extractedEdgeIDs, externalEdgeIDs...)
log.Info(ctx, "layout nested", slog.F("level", curr.Level()), slog.F("child", curr.AbsID()), slog.F("gi", gi))
log.Debug(ctx, "layout nested", slog.Any("level", curr.Level()), slog.Any("child", curr.AbsID()), slog.Any("gi", gi))
nestedInfo := gi
nearKey := curr.NearKey
if gi.IsConstantNear {
@ -250,19 +249,19 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co
if len(g.Objects) > 0 {
switch graphInfo.DiagramType {
case GridDiagram:
log.Debug(ctx, "layout grid", slog.F("rootlevel", g.RootLevel), slog.F("shapes", g.PrintString()))
log.Debug(ctx, "layout grid", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
if err = d2grid.Layout(ctx, g); err != nil {
return err
}
case SequenceDiagram:
log.Debug(ctx, "layout sequence", slog.F("rootlevel", g.RootLevel), slog.F("shapes", g.PrintString()))
log.Debug(ctx, "layout sequence", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
err = d2sequence.Layout(ctx, g, coreLayout)
if err != nil {
return err
}
default:
log.Debug(ctx, "default layout", slog.F("rootlevel", g.RootLevel), slog.F("shapes", g.PrintString()))
log.Debug(ctx, "default layout", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
err := coreLayout(ctx, g)
if err != nil {
return err
@ -337,7 +336,7 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co
}
}
log.Debug(ctx, "done", slog.F("rootlevel", g.RootLevel), slog.F("shapes", g.PrintString()))
log.Debug(ctx, "done", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
return err
}

View file

@ -190,6 +190,20 @@ func boundingBox(g *d2graph.Graph) (tl, br *geo.Point) {
}
}
for _, edge := range g.Edges {
if edge.Src.OuterNearContainer() != nil || edge.Dst.OuterNearContainer() != nil {
continue
}
if edge.Route != nil {
for _, point := range edge.Route {
x1 = math.Min(x1, point.X)
y1 = math.Min(y1, point.Y)
x2 = math.Max(x2, point.X)
y2 = math.Max(y2, point.Y)
}
}
}
if math.IsInf(x1, 1) && math.IsInf(x2, -1) {
x1 = 0
x2 = 0

View file

@ -2,6 +2,7 @@ package d2sequence
// units of space on the left/right when computing the space required between actors
const HORIZONTAL_PAD = 40.
const LABEL_HORIZONTAL_PAD = 60.
// units of space on the top/bottom when computing the space required between messages
// TODO lower

View file

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
@ -50,7 +51,7 @@ n2 -> n1
nEdges := len(g.Edges)
ctx := log.WithTB(context.Background(), t, nil)
ctx := log.WithTB(context.Background(), t)
d2sequence.Layout(ctx, g, func(ctx context.Context, g *d2graph.Graph) error {
// just set some position as if it had been properly placed
for _, obj := range g.Objects {
@ -176,7 +177,7 @@ b.t1 -> a.t1
a.t2 -> b
b -> a.t2`
ctx := log.WithTB(context.Background(), t, nil)
ctx := log.WithTB(context.Background(), t)
g, _, err := d2compiler.Compile("", strings.NewReader(input), nil)
assert.Nil(t, err)
@ -297,7 +298,7 @@ func TestNestedSequenceDiagrams(t *testing.T) {
c
container -> c: edge 1
`
ctx := log.WithTB(context.Background(), t, nil)
ctx := log.WithTB(context.Background(), t)
g, _, err := d2compiler.Compile("", strings.NewReader(input), nil)
assert.Nil(t, err)
@ -321,7 +322,7 @@ container -> c: edge 1
assert.True(t, has)
b_t1.Box = geo.NewBox(nil, 100, 100)
c := g.Root.EnsureChild([]string{"c"})
c := g.Root.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString("c")})
c.Box = geo.NewBox(nil, 100, 100)
c.Shape = d2graph.Scalar{Value: d2target.ShapeSquare}
@ -379,7 +380,7 @@ container -> c: edge 1
func TestSelfEdges(t *testing.T) {
g := d2graph.NewGraph()
g.Root.Shape = d2graph.Scalar{Value: d2target.ShapeSequenceDiagram}
n1 := g.Root.EnsureChild([]string{"n1"})
n1 := g.Root.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString("n1")})
n1.Box = geo.NewBox(nil, 100, 100)
g.Edges = []*d2graph.Edge{
@ -393,7 +394,7 @@ func TestSelfEdges(t *testing.T) {
},
}
ctx := log.WithTB(context.Background(), t, nil)
ctx := log.WithTB(context.Background(), t)
d2sequence.Layout(ctx, g, func(ctx context.Context, g *d2graph.Graph) error {
return nil
})
@ -415,12 +416,12 @@ func TestSelfEdges(t *testing.T) {
func TestSequenceToDescendant(t *testing.T) {
g := d2graph.NewGraph()
g.Root.Shape = d2graph.Scalar{Value: d2target.ShapeSequenceDiagram}
a := g.Root.EnsureChild([]string{"a"})
a := g.Root.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString("a")})
a.Box = geo.NewBox(nil, 100, 100)
a.Attributes = d2graph.Attributes{
Shape: d2graph.Scalar{Value: shape.PERSON_TYPE},
}
a_t1 := a.EnsureChild([]string{"t1"})
a_t1 := a.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString("t1")})
a_t1.Box = geo.NewBox(nil, 16, 80)
g.Edges = []*d2graph.Edge{
@ -435,7 +436,7 @@ func TestSequenceToDescendant(t *testing.T) {
},
}
ctx := log.WithTB(context.Background(), t, nil)
ctx := log.WithTB(context.Background(), t)
d2sequence.Layout(ctx, g, func(ctx context.Context, g *d2graph.Graph) error {
return nil
})

View file

@ -1,9 +1,11 @@
package d2sequence
import (
"cmp"
"errors"
"fmt"
"math"
"slices"
"sort"
"strconv"
"strings"
@ -50,6 +52,9 @@ func getObjEarliestLineNum(o *d2graph.Object) int {
if ref.MapKey == nil {
continue
}
if ref.Key.HasGlob() {
continue
}
min = go2.IntMin(min, ref.MapKey.Range.Start.Line)
}
return min
@ -61,6 +66,9 @@ func getEdgeEarliestLineNum(e *d2graph.Edge) int {
if ref.MapKey == nil {
continue
}
if ref.Edge.Src.HasGlob() || ref.Edge.Dst.HasGlob() {
continue
}
min = go2.IntMin(min, ref.MapKey.Range.Start.Line)
}
return min
@ -70,6 +78,13 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) (*s
var actors []*d2graph.Object
var groups []*d2graph.Object
slices.SortFunc(objects, func(a, b *d2graph.Object) int {
return cmp.Compare(getObjEarliestLineNum(a), getObjEarliestLineNum(b))
})
slices.SortFunc(messages, func(a, b *d2graph.Edge) int {
return cmp.Compare(getEdgeEarliestLineNum(a), getEdgeEarliestLineNum(b))
})
for _, obj := range objects {
if obj.IsSequenceDiagramGroup() {
queue := []*d2graph.Object{obj}
@ -162,17 +177,21 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) (*s
for _, message := range sd.messages {
sd.verticalIndices[message.AbsID()] = getEdgeEarliestLineNum(message)
// TODO this should not be global yStep, only affect the neighbors
sd.yStep = math.Max(sd.yStep, float64(message.LabelDimensions.Height))
// ensures that long labels, spanning over multiple actors, don't make for large gaps between actors
// by distributing the label length across the actors rank difference
rankDiff := math.Abs(float64(sd.objectRank[message.Src]) - float64(sd.objectRank[message.Dst]))
if rankDiff != 0 {
// rankDiff = 0 for self edges
distributedLabelWidth := float64(message.LabelDimensions.Width) / rankDiff
for rank := go2.IntMin(sd.objectRank[message.Src], sd.objectRank[message.Dst]); rank <= go2.IntMax(sd.objectRank[message.Src], sd.objectRank[message.Dst])-1; rank++ {
sd.actorXStep[rank] = math.Max(sd.actorXStep[rank], distributedLabelWidth+HORIZONTAL_PAD)
sd.actorXStep[rank] = math.Max(sd.actorXStep[rank], distributedLabelWidth+LABEL_HORIZONTAL_PAD)
}
} else {
// self edge
nextRank := sd.objectRank[message.Src]
if nextRank < len(sd.actorXStep) {
labelAdjust := float64(message.LabelDimensions.Width) + label.PADDING*4
sd.actorXStep[nextRank] = math.Max(sd.actorXStep[nextRank], labelAdjust)
}
}
sd.lastMessage[message.Src] = message
@ -229,10 +248,12 @@ func (sd *sequenceDiagram) placeGroup(group *d2graph.Object) {
for _, m := range sd.messages {
if m.ContainedBy(group) {
for _, p := range m.Route {
labelHeight := float64(m.LabelDimensions.Height) / 2.
edgePad := math.Max(labelHeight+GROUP_CONTAINER_PADDING, MIN_MESSAGE_DISTANCE/2.)
minX = math.Min(minX, p.X-HORIZONTAL_PAD)
minY = math.Min(minY, p.Y-MIN_MESSAGE_DISTANCE/2.)
minY = math.Min(minY, p.Y-edgePad)
maxX = math.Max(maxX, p.X+HORIZONTAL_PAD)
maxY = math.Max(maxY, p.Y+MIN_MESSAGE_DISTANCE/2.)
maxY = math.Max(maxY, p.Y+edgePad)
}
}
}
@ -240,6 +261,9 @@ func (sd *sequenceDiagram) placeGroup(group *d2graph.Object) {
for _, n := range sd.notes {
inGroup := false
for _, ref := range n.References {
if ref.Key.HasGlob() {
continue
}
curr := ref.ScopeObj
for curr != nil {
if curr == group {
@ -287,8 +311,8 @@ func (sd *sequenceDiagram) adjustGroupLabel(group *d2graph.Object) {
return
}
heightAdd := (group.LabelDimensions.Height + EDGE_GROUP_LABEL_PADDING) - GROUP_CONTAINER_PADDING
if heightAdd < 0 {
heightAdd := (group.LabelDimensions.Height + EDGE_GROUP_LABEL_PADDING/2.)
if heightAdd < GROUP_CONTAINER_PADDING {
return
}
@ -329,7 +353,6 @@ func (sd *sequenceDiagram) adjustGroupLabel(group *d2graph.Object) {
n.TopLeft.Y += float64(heightAdd)
}
}
}
// placeActors places actors bottom aligned, side by side with centers spaced by sd.actorXStep
@ -443,7 +466,12 @@ func (sd *sequenceDiagram) placeNotes() {
for _, msg := range sd.messages {
if sd.verticalIndices[msg.AbsID()] < verticalIndex {
y += sd.yStep
if msg.Src == msg.Dst {
// For self-messages, account for the full vertical space they occupy
y += sd.yStep + math.Max(float64(msg.LabelDimensions.Height), MIN_MESSAGE_DISTANCE)*1.5
} else {
y += sd.yStep + float64(msg.LabelDimensions.Height)
}
}
}
for _, otherNote := range sd.notes {
@ -536,8 +564,6 @@ func (sd *sequenceDiagram) placeSpans() {
// routeMessages routes horizontal edges (messages) from Src to Dst lifeline (actor/span center)
// in another step, routes are adjusted to spans borders when necessary
func (sd *sequenceDiagram) routeMessages() error {
var prevIsLoop bool
var prevGroup *d2graph.Object
messageOffset := sd.maxActorHeight + sd.yStep
for _, message := range sd.messages {
message.ZIndex = MESSAGE_Z_INDEX
@ -548,15 +574,6 @@ func (sd *sequenceDiagram) routeMessages() error {
}
}
// we need extra space if the previous message is a loop in a different group
group := message.GetGroup()
if prevIsLoop && prevGroup != group {
messageOffset += MIN_MESSAGE_DISTANCE
}
prevGroup = group
startY := messageOffset + noteOffset
var startX, endX float64
if startCenter := getCenter(message.Src); startCenter != nil {
startX = startCenter.X
@ -583,23 +600,24 @@ func (sd *sequenceDiagram) routeMessages() error {
isToSibling := currSrc == currDst
if isSelfMessage || isToDescendant || isFromDescendant || isToSibling {
midX := startX + SELF_MESSAGE_HORIZONTAL_TRAVEL
endY := startY + MIN_MESSAGE_DISTANCE*1.5
midX := startX + math.Max(SELF_MESSAGE_HORIZONTAL_TRAVEL, float64(message.LabelDimensions.Width)/2.+label.PADDING*2)
startY := messageOffset + noteOffset
endY := startY + math.Max(float64(message.LabelDimensions.Height), MIN_MESSAGE_DISTANCE)*1.5
message.Route = []*geo.Point{
geo.NewPoint(startX, startY),
geo.NewPoint(midX, startY),
geo.NewPoint(midX, endY),
geo.NewPoint(endX, endY),
}
prevIsLoop = true
messageOffset = endY + sd.yStep - noteOffset
} else {
startY := messageOffset + noteOffset + float64(message.LabelDimensions.Height/2.)
message.Route = []*geo.Point{
geo.NewPoint(startX, startY),
geo.NewPoint(endX, startY),
}
prevIsLoop = false
messageOffset = startY + float64(message.LabelDimensions.Height/2.) + sd.yStep - noteOffset
}
messageOffset += sd.yStep
if message.Label.Value != "" {
message.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
@ -647,7 +665,19 @@ func (sd *sequenceDiagram) isActor(obj *d2graph.Object) bool {
func (sd *sequenceDiagram) getWidth() float64 {
// the layout is always placed starting at 0, so the width is just the last actor
lastActor := sd.actors[len(sd.actors)-1]
return lastActor.TopLeft.X + lastActor.Width
rightmost := lastActor.TopLeft.X + lastActor.Width
for _, m := range sd.messages {
for _, p := range m.Route {
rightmost = math.Max(rightmost, p.X)
}
// Self referential messages may have labels that extend further
if m.Src == m.Dst {
rightmost = math.Max(rightmost, m.Route[1].X+float64(m.LabelDimensions.Width)/2.)
}
}
return rightmost
}
func (sd *sequenceDiagram) getHeight() float64 {

View file

@ -71,9 +71,21 @@ func Compile(ctx context.Context, input string, compileOpts *CompileOptions, ren
applyConfigs(config, compileOpts, renderOpts)
applyDefaults(compileOpts, renderOpts)
if config != nil {
g.Data = config.Data
}
d, err := compile(ctx, g, compileOpts, renderOpts)
if d != nil {
if config == nil {
config = &d2target.Config{}
}
// These are fields that affect a diagram's appearance, so feed them back
// into diagram.Config to ensure the hash computed for CSS styling purposes
// is unique to its appearance
config.ThemeID = renderOpts.ThemeID
config.DarkThemeID = renderOpts.DarkThemeID
config.Sketch = renderOpts.Sketch
d.Config = config
}
return d, g, err

506
d2lsp/completion.go Normal file
View file

@ -0,0 +1,506 @@
// Completion implements lsp autocomplete features
// Currently handles:
// - Complete dot and inside maps for reserved keyword holders (style, labels, etc)
// - Complete discrete values for keywords like shape
// - Complete suggestions for formats for keywords like opacity
package d2lsp
import (
"strings"
"unicode"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2target"
)
type CompletionKind int
const (
KeywordCompletion CompletionKind = iota
StyleCompletion
ShapeCompletion
)
type CompletionItem struct {
Label string
Kind CompletionKind
Detail string
InsertText string
}
func GetCompletionItems(text string, line, column int) ([]CompletionItem, error) {
ast, err := d2parser.Parse("", strings.NewReader(text), nil)
if err != nil {
ast, _ = d2parser.Parse("", strings.NewReader(getTextUntilPosition(text, line, column)), nil)
}
keyword := getKeywordContext(text, ast, line, column)
switch keyword {
case "style", "style.":
return getStyleCompletions(), nil
case "shape", "shape:":
return getShapeCompletions(), nil
case "shadow", "3d", "multiple", "animated", "bold", "italic", "underline", "filled", "double-border",
"shadow:", "3d:", "multiple:", "animated:", "bold:", "italic:", "underline:", "filled:", "double-border:",
"style.shadow:", "style.3d:", "style.multiple:", "style.animated:", "style.bold:", "style.italic:", "style.underline:", "style.filled:", "style.double-border:":
return getBooleanCompletions(), nil
case "fill-pattern", "fill-pattern:", "style.fill-pattern:":
return getFillPatternCompletions(), nil
case "text-transform", "text-transform:", "style.text-transform:":
return getTextTransformCompletions(), nil
case "opacity", "stroke-width", "stroke-dash", "border-radius", "font-size",
"stroke", "fill", "font-color":
return getValueCompletions(keyword), nil
case "opacity:", "stroke-width:", "stroke-dash:", "border-radius:", "font-size:",
"stroke:", "fill:", "font-color:",
"style.opacity:", "style.stroke-width:", "style.stroke-dash:", "style.border-radius:", "style.font-size:",
"style.stroke:", "style.fill:", "style.font-color:":
return getValueCompletions(strings.TrimSuffix(strings.TrimPrefix(keyword, "style."), ":")), nil
case "width", "height", "top", "left":
return getValueCompletions(keyword), nil
case "width:", "height:", "top:", "left:":
return getValueCompletions(keyword[:len(keyword)-1]), nil
case "source-arrowhead", "target-arrowhead":
return getArrowheadCompletions(), nil
case "source-arrowhead.shape:", "target-arrowhead.shape:":
return getArrowheadShapeCompletions(), nil
case "label", "label.":
return getLabelCompletions(), nil
case "icon", "icon:":
return getIconCompletions(), nil
case "icon.":
return getLabelCompletions(), nil
case "near", "near:":
return getNearCompletions(), nil
case "tooltip:", "tooltip":
return getTooltipCompletions(), nil
case "direction:", "direction":
return getDirectionCompletions(), nil
default:
return nil, nil
}
}
func getTextUntilPosition(text string, line, column int) string {
lines := strings.Split(text, "\n")
if line >= len(lines) {
return text
}
result := strings.Join(lines[:line], "\n")
if len(result) > 0 {
result += "\n"
}
if column > len(lines[line]) {
result += lines[line]
} else {
result += lines[line][:column]
}
return result
}
func getKeywordContext(text string, m *d2ast.Map, line, column int) string {
if m == nil {
return ""
}
lines := strings.Split(text, "\n")
for _, n := range m.Nodes {
if n.MapKey == nil {
continue
}
var firstPart, lastPart string
var key *d2ast.KeyPath
if len(n.MapKey.Edges) > 0 {
key = n.MapKey.EdgeKey
} else {
key = n.MapKey.Key
}
if key != nil && len(key.Path) > 0 {
firstKey := key.Path[0].Unbox()
if !firstKey.IsUnquoted() {
continue
}
firstPart = firstKey.ScalarString()
pathLen := len(key.Path)
if pathLen > 1 {
lastKey := key.Path[pathLen-1].Unbox()
if lastKey.IsUnquoted() {
lastPart = lastKey.ScalarString()
_, isHolderLast := d2ast.ReservedKeywordHolders[lastPart]
if !isHolderLast {
_, isHolderLast = d2ast.CompositeReservedKeywords[lastPart]
}
keyRange := n.MapKey.Range
lineText := lines[keyRange.End.Line]
if isHolderLast && isAfterDot(lineText, column) {
return lastPart + "."
}
}
}
}
if _, isBoard := d2ast.BoardKeywords[firstPart]; isBoard {
firstPart = ""
}
if firstPart == "classes" {
firstPart = ""
}
_, isHolder := d2ast.ReservedKeywordHolders[firstPart]
if !isHolder {
_, isHolder = d2ast.CompositeReservedKeywords[firstPart]
}
// Check nested map
if n.MapKey.Value.Map != nil && isPositionInMap(line, column, n.MapKey.Value.Map) {
if nested := getKeywordContext(text, n.MapKey.Value.Map, line, column); nested != "" {
if isHolder {
// If we got a direct key completion from inside a holder's map,
// prefix it with the holder's name
if strings.HasSuffix(nested, ":") && !strings.Contains(nested, ".") {
return firstPart + "." + strings.TrimSuffix(nested, ":") + ":"
}
}
return nested
}
return firstPart
}
keyRange := n.MapKey.Range
if line != keyRange.End.Line {
continue
}
// 1) Skip if cursor is well above/below this key
if line < keyRange.Start.Line || line > keyRange.End.Line {
continue
}
// 2) If on the start line, skip if before the key
if line == keyRange.Start.Line && column < keyRange.Start.Column {
continue
}
// 3) If on the end line, allow up to keyRange.End.Column + 1
if line == keyRange.End.Line && column > keyRange.End.Column+1 {
continue
}
lineText := lines[keyRange.End.Line]
if isAfterColon(lineText, column) {
if key != nil && len(key.Path) > 1 {
if isHolder && (firstPart == "source-arrowhead" || firstPart == "target-arrowhead") {
return firstPart + "." + lastPart + ":"
}
_, isHolder := d2ast.ReservedKeywordHolders[lastPart]
if !isHolder {
return lastPart
}
}
return firstPart + ":"
}
if isAfterDot(lineText, column) && isHolder {
return firstPart
}
}
return ""
}
func isAfterDot(text string, pos int) bool {
return pos > 0 && pos <= len(text) && text[pos-1] == '.'
}
func isAfterColon(text string, pos int) bool {
if pos < 1 || pos > len(text) {
return false
}
i := pos - 1
for i >= 0 && unicode.IsSpace(rune(text[i])) {
i--
}
return i >= 0 && text[i] == ':'
}
func isPositionInMap(line, column int, m *d2ast.Map) bool {
if m == nil {
return false
}
mapRange := m.Range
if line < mapRange.Start.Line || line > mapRange.End.Line {
return false
}
if line == mapRange.Start.Line && column < mapRange.Start.Column {
return false
}
if line == mapRange.End.Line && column > mapRange.End.Column {
return false
}
return true
}
func getShapeCompletions() []CompletionItem {
items := make([]CompletionItem, 0, len(d2target.Shapes))
for _, shape := range d2target.Shapes {
item := CompletionItem{
Label: shape,
Kind: ShapeCompletion,
Detail: "shape",
InsertText: shape,
}
items = append(items, item)
}
return items
}
func getValueCompletions(property string) []CompletionItem {
switch property {
case "opacity":
return []CompletionItem{{
Label: "(number between 0.0 and 1.0)",
Kind: KeywordCompletion,
Detail: "e.g. 0.4",
InsertText: "",
}}
case "stroke-width":
return []CompletionItem{{
Label: "(number between 0 and 15)",
Kind: KeywordCompletion,
Detail: "e.g. 2",
InsertText: "",
}}
case "font-size":
return []CompletionItem{{
Label: "(number between 8 and 100)",
Kind: KeywordCompletion,
Detail: "e.g. 14",
InsertText: "",
}}
case "stroke-dash":
return []CompletionItem{{
Label: "(number between 0 and 10)",
Kind: KeywordCompletion,
Detail: "e.g. 5",
InsertText: "",
}}
case "border-radius":
return []CompletionItem{{
Label: "(number greater than or equal to 0)",
Kind: KeywordCompletion,
Detail: "e.g. 4",
InsertText: "",
}}
case "font-color", "stroke", "fill":
return []CompletionItem{{
Label: "(color name or hex code)",
Kind: KeywordCompletion,
Detail: "e.g. blue, #ff0000",
InsertText: "",
}}
case "width", "height", "top", "left":
return []CompletionItem{{
Label: "(pixels)",
Kind: KeywordCompletion,
Detail: "e.g. 400",
InsertText: "",
}}
}
return nil
}
func getStyleCompletions() []CompletionItem {
items := make([]CompletionItem, 0, len(d2ast.StyleKeywords))
for keyword := range d2ast.StyleKeywords {
item := CompletionItem{
Label: keyword,
Kind: StyleCompletion,
Detail: "style property",
InsertText: keyword + ": ",
}
items = append(items, item)
}
return items
}
func getBooleanCompletions() []CompletionItem {
return []CompletionItem{
{
Label: "true",
Kind: KeywordCompletion,
Detail: "boolean",
InsertText: "true",
},
{
Label: "false",
Kind: KeywordCompletion,
Detail: "boolean",
InsertText: "false",
},
}
}
func getFillPatternCompletions() []CompletionItem {
items := make([]CompletionItem, 0, len(d2ast.FillPatterns))
for _, pattern := range d2ast.FillPatterns {
item := CompletionItem{
Label: pattern,
Kind: KeywordCompletion,
Detail: "fill pattern",
InsertText: pattern,
}
items = append(items, item)
}
return items
}
func getTextTransformCompletions() []CompletionItem {
items := make([]CompletionItem, 0, len(d2ast.TextTransforms))
for _, transform := range d2ast.TextTransforms {
item := CompletionItem{
Label: transform,
Kind: KeywordCompletion,
Detail: "text transform",
InsertText: transform,
}
items = append(items, item)
}
return items
}
func isOnEmptyLine(text string, line int) bool {
lines := strings.Split(text, "\n")
if line >= len(lines) {
return true
}
return strings.TrimSpace(lines[line]) == ""
}
func getLabelCompletions() []CompletionItem {
return []CompletionItem{{
Label: "near",
Kind: StyleCompletion,
Detail: "label position",
InsertText: "near: ",
}}
}
func getNearCompletions() []CompletionItem {
items := make([]CompletionItem, 0, len(d2ast.LabelPositionsArray)+1)
items = append(items, CompletionItem{
Label: "(object ID)",
Kind: KeywordCompletion,
Detail: "e.g. container.inner_shape",
InsertText: "",
})
for _, pos := range d2ast.LabelPositionsArray {
item := CompletionItem{
Label: pos,
Kind: KeywordCompletion,
Detail: "label position",
InsertText: pos,
}
items = append(items, item)
}
return items
}
func getTooltipCompletions() []CompletionItem {
return []CompletionItem{
{
Label: "(markdown)",
Kind: KeywordCompletion,
Detail: "markdown formatted text",
InsertText: "|md\n # Tooltip\n Hello world\n|",
},
}
}
func getIconCompletions() []CompletionItem {
return []CompletionItem{
{
Label: "(URL, e.g. https://icons.terrastruct.com/xyz.svg)",
Kind: KeywordCompletion,
Detail: "icon URL",
InsertText: "https://icons.terrastruct.com/essentials%2F073-add.svg",
},
}
}
func getDirectionCompletions() []CompletionItem {
directions := []string{"up", "down", "right", "left"}
items := make([]CompletionItem, len(directions))
for i, dir := range directions {
items[i] = CompletionItem{
Label: dir,
Kind: KeywordCompletion,
Detail: "direction",
InsertText: dir,
}
}
return items
}
func getArrowheadShapeCompletions() []CompletionItem {
arrowheads := []string{
"triangle",
"arrow",
"diamond",
"circle",
"cf-one", "cf-one-required",
"cf-many", "cf-many-required",
}
items := make([]CompletionItem, len(arrowheads))
details := map[string]string{
"triangle": "default",
"arrow": "like triangle but pointier",
"cf-one": "crows foot one",
"cf-one-required": "crows foot one (required)",
"cf-many": "crows foot many",
"cf-many-required": "crows foot many (required)",
}
for i, shape := range arrowheads {
detail := details[shape]
if detail == "" {
detail = "arrowhead shape"
}
items[i] = CompletionItem{
Label: shape,
Kind: ShapeCompletion,
Detail: detail,
InsertText: shape,
}
}
return items
}
func getArrowheadCompletions() []CompletionItem {
completions := []string{
"shape",
"label",
"style.filled",
}
items := make([]CompletionItem, len(completions))
for i, shape := range completions {
items[i] = CompletionItem{
Label: shape,
Kind: ShapeCompletion,
InsertText: shape,
}
}
return items
}

454
d2lsp/completion_test.go Normal file
View file

@ -0,0 +1,454 @@
package d2lsp
import (
"testing"
)
func TestGetCompletionItems(t *testing.T) {
tests := []struct {
name string
text string
line int
column int
want []CompletionItem
wantErr bool
}{
{
name: "style dot suggestions",
text: "a.style.",
line: 0,
column: 8,
want: getStyleCompletions(),
},
{
name: "style map suggestions",
text: `a: {
style.
}
`,
line: 1,
column: 8,
want: getStyleCompletions(),
},
{
name: "classes shapes",
text: `classes: {
goal: {
shape:
}
}
`,
line: 2,
column: 10,
want: getShapeCompletions(),
},
{
name: "nested style map suggestions",
text: `a: {
style: {
3d:
}
}
`,
line: 2,
column: 7,
want: getBooleanCompletions(),
},
{
name: "3d style map suggestions",
text: `a.style: {
3d:
}
`,
line: 1,
column: 5,
want: getBooleanCompletions(),
},
{
name: "fill pattern style map suggestions",
text: `a.style: {
fill-pattern:
}
`,
line: 1,
column: 15,
want: getFillPatternCompletions(),
},
{
name: "opacity style map suggestions",
text: `a.style: {
opacity:
}
`,
line: 1,
column: 10,
want: getValueCompletions("opacity"),
},
{
name: "width dot",
text: `a.width:`,
line: 0,
column: 8,
want: getValueCompletions("width"),
},
{
name: "layer shape",
text: `a
layers: {
hey: {
go: {
shape:
}
}
}
`,
line: 5,
column: 12,
want: getShapeCompletions(),
},
{
name: "stroke width value",
text: `a.style.stroke-width: 1`,
line: 0,
column: 23,
want: nil,
},
{
name: "no style suggestions",
text: `a.style:
`,
line: 0,
column: 8,
want: nil,
},
{
name: "style property suggestions",
text: "a -> b: { style. }",
line: 0,
column: 16,
want: getStyleCompletions(),
},
{
name: "style.opacity value hint",
text: "a -> b: { style.opacity: }",
line: 0,
column: 24,
want: getValueCompletions("opacity"),
},
{
name: "fill pattern completions",
text: "a -> b: { style.fill-pattern: }",
line: 0,
column: 29,
want: getFillPatternCompletions(),
},
{
name: "text transform completions",
text: "a -> b: { style.text-transform: }",
line: 0,
column: 31,
want: getTextTransformCompletions(),
},
{
name: "boolean property completions",
text: "a -> b: { style.shadow: }",
line: 0,
column: 23,
want: getBooleanCompletions(),
},
{
name: "near position completions",
text: "a -> b: { label.near: }",
line: 0,
column: 21,
want: getNearCompletions(),
},
{
name: "direction completions",
text: "a -> b: { direction: }",
line: 0,
column: 20,
want: getDirectionCompletions(),
},
{
name: "icon url completions",
text: "a -> b: { icon: }",
line: 0,
column: 15,
want: getIconCompletions(),
},
{
name: "icon dot url completions",
text: "a.icon:",
line: 0,
column: 7,
want: getIconCompletions(),
},
{
name: "icon near completions",
text: "a -> b: { icon.near: }",
line: 0,
column: 20,
want: getNearCompletions(),
},
{
name: "icon map",
text: `a.icon: {
# here
}`,
line: 1,
column: 2,
want: nil,
},
{
name: "icon flat dot",
text: `a.icon.`,
line: 0,
column: 7,
want: getLabelCompletions(),
},
{
name: "label flat dot",
text: `a.label.`,
line: 0,
column: 8,
want: getLabelCompletions(),
},
{
name: "arrowhead completions - dot syntax",
text: "a -> b: { source-arrowhead. }",
line: 0,
column: 27,
want: getArrowheadCompletions(),
},
{
name: "arrowhead completions - colon syntax",
text: "a -> b: { source-arrowhead: }",
line: 0,
column: 27,
want: nil,
},
{
name: "arrowhead completions - map syntax",
text: `a -> b: {
source-arrowhead: {
# here
}
}`,
line: 2,
column: 4,
want: getArrowheadCompletions(),
},
{
name: "arrowhead shape completions - flat dot syntax",
text: "(a -> b)[0].source-arrowhead.shape:",
line: 0,
column: 35,
want: getArrowheadShapeCompletions(),
},
{
name: "arrowhead shape completions - dot syntax",
text: "a -> b: { source-arrowhead.shape: }",
line: 0,
column: 33,
want: getArrowheadShapeCompletions(),
},
{
name: "arrowhead shape completions - map syntax",
text: "a -> b: { source-arrowhead: { shape: } }",
line: 0,
column: 36,
want: getArrowheadShapeCompletions(),
},
{
name: "width value hint",
text: "a -> b: { width: }",
line: 0,
column: 16,
want: getValueCompletions("width"),
},
{
name: "height value hint",
text: "a -> b: { height: }",
line: 0,
column: 17,
want: getValueCompletions("height"),
},
{
name: "tooltip markdown template",
text: "a -> b: { tooltip: }",
line: 0,
column: 18,
want: getTooltipCompletions(),
},
{
name: "tooltip dot markdown template",
text: "a.tooltip:",
line: 0,
column: 10,
want: getTooltipCompletions(),
},
{
name: "shape dot suggestions",
text: "a.shape:",
line: 0,
column: 8,
want: getShapeCompletions(),
},
{
name: "shape suggestions",
text: "a -> b: { shape: }",
line: 0,
column: 16,
want: getShapeCompletions(),
},
{
name: "shape 2 suggestions",
text: `a: {
shape:
}`,
line: 1,
column: 8,
want: getShapeCompletions(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetCompletionItems(tt.text, tt.line, tt.column)
if (err != nil) != tt.wantErr {
t.Errorf("GetCompletionItems() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(got) != len(tt.want) {
t.Errorf("GetCompletionItems() got %d completions, want %d", len(got), len(tt.want))
return
}
// Create maps for easy comparison
gotMap := make(map[string]CompletionItem)
wantMap := make(map[string]CompletionItem)
for _, item := range got {
gotMap[item.Label] = item
}
for _, item := range tt.want {
wantMap[item.Label] = item
}
// Check that each completion exists and has correct properties
for label, wantItem := range wantMap {
gotItem, exists := gotMap[label]
if !exists {
t.Errorf("missing completion for %q", label)
continue
}
if gotItem.Kind != wantItem.Kind {
t.Errorf("completion %q Kind = %v, want %v", label, gotItem.Kind, wantItem.Kind)
}
if gotItem.Detail != wantItem.Detail {
t.Errorf("completion %q Detail = %v, want %v", label, gotItem.Detail, wantItem.Detail)
}
if gotItem.InsertText != wantItem.InsertText {
t.Errorf("completion %q InsertText = %v, want %v", label, gotItem.InsertText, wantItem.InsertText)
}
}
})
}
}
// Helper function to compare CompletionItem slices
func equalCompletions(a, b []CompletionItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Label != b[i].Label ||
a[i].Kind != b[i].Kind ||
a[i].Detail != b[i].Detail ||
a[i].InsertText != b[i].InsertText {
return false
}
}
return true
}
func TestGetArrowheadShapeCompletions(t *testing.T) {
got := getArrowheadShapeCompletions()
expectedLabels := []string{
"triangle", "arrow", "diamond", "circle",
"cf-one", "cf-one-required",
"cf-many", "cf-many-required",
}
if len(got) != len(expectedLabels) {
t.Errorf("getArrowheadShapeCompletions() returned %d items, want %d", len(got), len(expectedLabels))
return
}
for i, label := range expectedLabels {
if got[i].Label != label {
t.Errorf("completion[%d].Label = %v, want %v", i, got[i].Label, label)
}
if got[i].Kind != ShapeCompletion {
t.Errorf("completion[%d].Kind = %v, want ShapeCompletion", i, got[i].Kind)
}
if got[i].InsertText != label {
t.Errorf("completion[%d].InsertText = %v, want %v", i, got[i].InsertText, label)
}
}
}
func TestGetValueCompletions(t *testing.T) {
tests := []struct {
property string
wantLabel string
wantDetail string
}{
{
property: "opacity",
wantLabel: "(number between 0.0 and 1.0)",
wantDetail: "e.g. 0.4",
},
{
property: "stroke-width",
wantLabel: "(number between 0 and 15)",
wantDetail: "e.g. 2",
},
{
property: "font-size",
wantLabel: "(number between 8 and 100)",
wantDetail: "e.g. 14",
},
{
property: "width",
wantLabel: "(pixels)",
wantDetail: "e.g. 400",
},
{
property: "stroke",
wantLabel: "(color name or hex code)",
wantDetail: "e.g. blue, #ff0000",
},
}
for _, tt := range tests {
t.Run(tt.property, func(t *testing.T) {
got := getValueCompletions(tt.property)
if len(got) != 1 {
t.Fatalf("getValueCompletions(%s) returned %d items, want 1", tt.property, len(got))
}
if got[0].Label != tt.wantLabel {
t.Errorf("completion.Label = %v, want %v", got[0].Label, tt.wantLabel)
}
if got[0].Detail != tt.wantDetail {
t.Errorf("completion.Detail = %v, want %v", got[0].Detail, tt.wantDetail)
}
if got[0].InsertText != "" {
t.Errorf("completion.InsertText = %v, want empty string", got[0].InsertText)
}
})
}
}

157
d2lsp/d2lsp.go Normal file
View file

@ -0,0 +1,157 @@
// d2lsp contains functions useful for IDE clients
package d2lsp
import (
"fmt"
"strings"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2ir"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/lib/memfs"
)
func GetRefRanges(path string, fs map[string]string, boardPath []string, key string) (ranges []d2ast.Range, importRanges []d2ast.Range, _ error) {
m, err := getBoardMap(path, fs, boardPath)
if err != nil {
return nil, nil, err
}
mk, err := d2parser.ParseMapKey(key)
if err != nil {
return nil, nil, err
}
if mk.Key == nil && len(mk.Edges) == 0 {
return nil, nil, fmt.Errorf(`"%s" is invalid`, key)
}
var f *d2ir.Field
if mk.Key != nil {
for _, p := range mk.Key.Path {
f = m.GetField(p.Unbox())
if f == nil {
return nil, nil, nil
}
m = f.Map()
}
}
if len(mk.Edges) > 0 {
eids := d2ir.NewEdgeIDs(mk)
var edges []*d2ir.Edge
for _, eid := range eids {
edges = append(edges, m.GetEdges(eid, nil, nil)...)
}
if len(edges) == 0 {
return nil, nil, nil
}
for _, edge := range edges {
for _, ref := range edge.References {
ranges = append(ranges, ref.AST().GetRange())
}
if edge.ImportAST() != nil {
importRanges = append(importRanges, edge.ImportAST().GetRange())
}
}
} else {
for _, ref := range f.References {
ranges = append(ranges, ref.AST().GetRange())
}
if f.ImportAST() != nil {
importRanges = append(importRanges, f.ImportAST().GetRange())
}
}
return ranges, importRanges, nil
}
func getBoardMap(path string, fs map[string]string, boardPath []string) (*d2ir.Map, error) {
if _, ok := fs[path]; !ok {
return nil, fmt.Errorf(`"%s" not found`, path)
}
r := strings.NewReader(fs[path])
ast, err := d2parser.Parse(path, r, nil)
if err != nil {
return nil, err
}
mfs, err := memfs.New(fs)
if err != nil {
return nil, err
}
m, _, err := d2ir.Compile(ast, &d2ir.CompileOptions{
FS: mfs,
})
if err != nil {
return nil, err
}
m = m.FindBoardRoot(boardPath)
if m == nil {
return nil, fmt.Errorf(`board "%v" not found`, boardPath)
}
return m, nil
}
func GetBoardAtPosition(text string, pos d2ast.Position) ([]string, error) {
r := strings.NewReader(text)
ast, err := d2parser.Parse("", r, nil)
if err != nil {
return nil, err
}
pos.Byte = -1
return getBoardPathAtPosition(*ast, nil, pos), nil
}
func getBoardPathAtPosition(m d2ast.Map, currPath []string, pos d2ast.Position) []string {
inRange := func(r d2ast.Range) bool {
return !pos.Before(r.Start) && pos.Before(r.End)
}
if !inRange(m.Range) {
return nil
}
for _, n := range m.Nodes {
if n.MapKey == nil {
continue
}
mk := n.MapKey
if mk.Key == nil || len(mk.Key.Path) == 0 {
continue
}
if mk.Value.Map == nil {
continue
}
keyName := mk.Key.Path[0].Unbox().ScalarString()
if len(currPath)%2 == 0 {
isBoardType := keyName == "layers" || keyName == "scenarios" || keyName == "steps"
if !isBoardType {
continue
}
}
if inRange(mk.Value.Map.Range) {
newPath := append(currPath, keyName)
// Check deeper
if deeperPath := getBoardPathAtPosition(*mk.Value.Map, newPath, pos); deeperPath != nil {
return deeperPath
}
// We're in between boards, e.g. layers.x.scenarios
// Which means, there's no board at this position
if len(newPath)%2 == 1 {
return nil
}
// Nothing deeper matched but we're in this map's range, return current path
return newPath
}
}
return nil
}

321
d2lsp/d2lsp_test.go Normal file
View file

@ -0,0 +1,321 @@
package d2lsp_test
import (
"slices"
"testing"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2lsp"
"oss.terrastruct.com/util-go/assert"
)
func TestGetFieldRanges(t *testing.T) {
script := `x
x.a
a.x
x -> y`
fs := map[string]string{
"index.d2": script,
}
ranges, _, err := d2lsp.GetRefRanges("index.d2", fs, nil, "x")
assert.Success(t, err)
assert.Equal(t, 3, len(ranges))
assert.Equal(t, 0, ranges[0].Start.Line)
assert.Equal(t, 1, ranges[1].Start.Line)
assert.Equal(t, 3, ranges[2].Start.Line)
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, nil, "a.x")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 2, ranges[0].Start.Line)
}
func TestGetEdgeRanges(t *testing.T) {
script := `x
x.a
a.x
x -> y
y -> z
x -> z
b: {
x -> y
}
`
fs := map[string]string{
"index.d2": script,
}
ranges, _, err := d2lsp.GetRefRanges("index.d2", fs, nil, "x -> y")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 3, ranges[0].Start.Line)
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, nil, "y -> z")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 4, ranges[0].Start.Line)
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, nil, "x -> z")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 5, ranges[0].Start.Line)
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, nil, "a -> b")
assert.Success(t, err)
assert.Equal(t, 0, len(ranges))
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, nil, "b.(x -> y)")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 7, ranges[0].Start.Line)
}
func TestGetRangesImported(t *testing.T) {
fs := map[string]string{
"index.d2": `
...@ok
hi
hey: @ok
`,
"ok.d2": `
what
lala
okay
`,
}
ranges, importRanges, err := d2lsp.GetRefRanges("index.d2", fs, nil, "hi")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 2, ranges[0].Start.Line)
assert.Equal(t, 0, len(importRanges))
ranges, importRanges, err = d2lsp.GetRefRanges("index.d2", fs, nil, "okay")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, "ok.d2", ranges[0].Path)
assert.Equal(t, 1, len(importRanges))
assert.Equal(t, 1, importRanges[0].Start.Line)
ranges, importRanges, err = d2lsp.GetRefRanges("index.d2", fs, nil, "hey.okay")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, "ok.d2", ranges[0].Path)
assert.Equal(t, 1, len(importRanges))
assert.Equal(t, 3, importRanges[0].Start.Line)
assert.Equal(t, 5, importRanges[0].Start.Column)
ranges, _, err = d2lsp.GetRefRanges("ok.d2", fs, nil, "hi")
assert.Success(t, err)
assert.Equal(t, 0, len(ranges))
ranges, _, err = d2lsp.GetRefRanges("ok.d2", fs, nil, "okay")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
}
func TestGetRangesBoards(t *testing.T) {
fs := map[string]string{
"index.d2": `
hi
layers: {
x: {
hello
layers: {
y: {
qwer
}
}
}
}
`,
}
ranges, _, err := d2lsp.GetRefRanges("index.d2", fs, []string{"x"}, "hello")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
assert.Equal(t, 4, ranges[0].Start.Line)
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, []string{"x"}, "hi")
assert.Success(t, err)
assert.Equal(t, 0, len(ranges))
ranges, _, err = d2lsp.GetRefRanges("index.d2", fs, []string{"x", "y"}, "qwer")
assert.Success(t, err)
assert.Equal(t, 1, len(ranges))
_, _, err = d2lsp.GetRefRanges("index.d2", fs, []string{"y"}, "hello")
assert.Equal(t, `board "[y]" not found`, err.Error())
}
func TestGetBoardAtPosition(t *testing.T) {
tests := []struct {
name string
fs map[string]string
path string
position d2ast.Position
want []string
}{
{
name: "cursor in layer",
fs: map[string]string{
"index.d2": `x
layers: {
basic: {
x -> y
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 3, Column: 4},
want: []string{"layers", "basic"},
},
{
name: "cursor in nested layer",
fs: map[string]string{
"index.d2": `
layers: {
outer: {
layers: {
inner: {
x -> y
}
}
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 5, Column: 4},
want: []string{"layers", "outer", "layers", "inner"},
},
{
name: "cursor in second sibling nested layer",
fs: map[string]string{
"index.d2": `
layers: {
outer: {
layers: {
first: {
a -> b
}
second: {
x -> y
}
}
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 8, Column: 4},
want: []string{"layers", "outer", "layers", "second"},
},
{
name: "cursor in root container",
fs: map[string]string{
"index.d2": `
wumbo: {
car
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 2, Column: 4},
want: nil,
},
{
name: "cursor in layer container",
fs: map[string]string{
"index.d2": `
layers: {
x: {
wumbo: {
car
}
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 4, Column: 4},
want: []string{"layers", "x"},
},
{
name: "cursor in scenario",
fs: map[string]string{
"index.d2": `
scenarios: {
happy: {
x -> y
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 3, Column: 4},
want: []string{"scenarios", "happy"},
},
{
name: "cursor in step",
fs: map[string]string{
"index.d2": `
steps: {
first: {
x -> y
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 3, Column: 4},
want: []string{"steps", "first"},
},
{
name: "cursor outside any board",
fs: map[string]string{
"index.d2": `
x -> y
layers: {
basic: {
a -> b
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 1, Column: 1},
want: nil,
},
{
name: "cursor in empty board",
fs: map[string]string{
"index.d2": `
layers: {
basic: {
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 3, Column: 2},
want: []string{"layers", "basic"},
},
{
name: "cursor in between",
fs: map[string]string{
"index.d2": `
layers: {
basic: {
}
}`,
},
path: "index.d2",
position: d2ast.Position{Line: 2, Column: 2},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := d2lsp.GetBoardAtPosition(tt.fs[tt.path], tt.position)
assert.Success(t, err)
if tt.want == nil {
assert.Equal(t, true, got == nil)
} else {
assert.Equal(t, len(tt.want), len(got))
assert.True(t, slices.Equal(tt.want, got))
}
})
}
}

View file

@ -42,6 +42,9 @@ func Create(g *d2graph.Graph, boardPath []string, key string) (_ *d2graph.Graph,
}
// TODO beter name
baseAST = boardG.BaseAST
if baseAST == nil {
return nil, "", fmt.Errorf("board %v cannot be modified through this file", boardPath)
}
}
newKey, edge, err := generateUniqueKey(boardG, key, nil, nil)
@ -98,6 +101,9 @@ func Set(g *d2graph.Graph, boardPath []string, key string, tag, value *string) (
}
// TODO beter name
baseAST = boardG.BaseAST
if baseAST == nil {
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
}
}
err = _set(boardG, baseAST, key, tag, value)
@ -142,6 +148,9 @@ func ReconnectEdge(g *d2graph.Graph, boardPath []string, edgeKey string, srcKey,
}
// TODO beter name
baseAST = boardG.BaseAST
if baseAST == nil {
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
}
}
obj := boardG.Root
@ -372,7 +381,7 @@ func _set(g *d2graph.Graph, baseAST *d2ast.Map, key string, tag, value *string)
if mk.Key != nil {
found := true
for _, idel := range d2graph.Key(mk.Key) {
_, ok := d2graph.ReservedKeywords[idel]
_, ok := d2ast.ReservedKeywords[idel]
if ok {
reserved = true
break
@ -411,6 +420,47 @@ func _set(g *d2graph.Graph, baseAST *d2ast.Map, key string, tag, value *string)
}
}
if mk.Key != nil && len(mk.Key.Path) == 2 {
boardType := mk.Key.Path[0].Unbox().ScalarString()
if boardType == "layers" || boardType == "scenarios" || boardType == "steps" {
// Force map structure
var containerMap *d2ast.Map
for _, n := range scope.Nodes {
if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Key.Path) == 1 &&
n.MapKey.Key.Path[0].Unbox().ScalarString() == boardType {
containerMap = n.MapKey.Value.Map
break
}
}
if containerMap == nil {
containerMap = &d2ast.Map{
Range: d2ast.MakeRange(",1:0:0-1:0:0"),
}
containerMK := &d2ast.Key{
Key: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
d2ast.MakeValueBox(d2ast.RawString(boardType, true)).StringBox(),
},
},
Value: d2ast.MakeValueBox(containerMap),
}
appendMapKey(scope, containerMK)
}
itemMK := &d2ast.Key{
Key: &d2ast.KeyPath{
Path: []*d2ast.StringBox{
d2ast.MakeValueBox(d2ast.RawString(mk.Key.Path[1].Unbox().ScalarString(), true)).StringBox(),
},
},
Value: mk.Value,
}
appendMapKey(containerMap, itemMK)
return nil
}
}
writeableLabelMK := true
var objK *d2ast.Key
if baseAST != g.AST || imported {
@ -450,7 +500,12 @@ func _set(g *d2graph.Graph, baseAST *d2ast.Map, key string, tag, value *string)
m = obj.Map
}
if (obj.Label.MapKey != nil && writeableLabelMK) && m == nil && (!found || reserved || len(mk.Edges) > 0) {
if (obj.Label.MapKey != nil && writeableLabelMK) && m == nil && (!found || reserved || len(mk.Edges) > 0) &&
// Label is not set like `hey.label: mylabel`
// This should only work when label is set like `hey: mylabel`
(obj.Label.MapKey.Key == nil ||
len(obj.Label.MapKey.Key.Path) == 0 ||
obj.Label.MapKey.Key.Path[len(obj.Label.MapKey.Key.Path)-1].Unbox().ScalarString() != "label") {
m2 := &d2ast.Map{
Range: d2ast.MakeRange(",1:0:0-1:0:0"),
}
@ -544,7 +599,7 @@ func _set(g *d2graph.Graph, baseAST *d2ast.Map, key string, tag, value *string)
attrs = edge.Attributes
if mk.EdgeKey != nil {
if _, ok := d2graph.ReservedKeywords[mk.EdgeKey.Path[0].Unbox().ScalarString()]; !ok {
if _, ok := d2ast.ReservedKeywords[mk.EdgeKey.Path[0].Unbox().ScalarString()]; !ok {
return errors.New("edge key must be reserved")
}
reserved = true
@ -856,7 +911,7 @@ func appendMapKey(m *d2ast.Map, mk *d2ast.Key) {
if len(m.Nodes) == 1 &&
mk.Key != nil &&
len(mk.Key.Path) > 0 {
_, ok := d2graph.ReservedKeywords[mk.Key.Path[0].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[mk.Key.Path[0].Unbox().ScalarString()]
if ok {
// Allow one line reserved key (like shape) maps.
// TODO: This needs to be smarter as certain keys are only reserved in context.
@ -900,6 +955,9 @@ func Delete(g *d2graph.Graph, boardPath []string, key string) (_ *d2graph.Graph,
}
// TODO beter name
baseAST = boardG.BaseAST
if baseAST == nil {
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
}
}
g2, err := deleteReserved(g, boardPath, baseAST, mk)
@ -949,7 +1007,10 @@ func Delete(g *d2graph.Graph, boardPath []string, key string) (_ *d2graph.Graph,
for i := len(e.References) - 1; i >= 0; i-- {
ref := e.References[i]
deleteEdge(g, ref.Scope, ref.MapKey, ref.MapKeyEdgeIndex)
// Leave glob setters alone
if !(ref.MapKey.EdgeIndex != nil && ref.MapKey.EdgeIndex.Glob) {
deleteEdge(g, ref.Scope, ref.MapKey, ref.MapKeyEdgeIndex)
}
}
edges, ok := obj.FindEdges(mk)
@ -981,7 +1042,10 @@ func Delete(g *d2graph.Graph, boardPath []string, key string) (_ *d2graph.Graph,
return recompile(boardG)
}
prevG, _ := recompile(boardG)
prevG, err := recompile(boardG)
if err != nil {
return nil, err
}
obj, ok := boardG.Root.HasChild(d2graph.Key(mk.Key))
if !ok {
@ -1071,7 +1135,7 @@ func hoistRefChildren(g *d2graph.Graph, key *d2ast.KeyPath, ref d2graph.Referenc
continue
}
if n.MapKey.Key != nil {
_, ok := d2graph.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
if ok {
continue
}
@ -1124,7 +1188,7 @@ func renameConflictsToParent(g *d2graph.Graph, key *d2ast.KeyPath) (*d2graph.Gra
continue
}
if n.MapKey.Key != nil {
_, ok := d2graph.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
if ok {
continue
}
@ -1144,7 +1208,7 @@ func renameConflictsToParent(g *d2graph.Graph, key *d2ast.KeyPath) (*d2graph.Gra
absKey.Path = append(absKey.Path, k.Path[0])
absKeys = append(absKeys, absKey)
}
} else if _, ok := d2graph.ReservedKeywords[ref.Key.Path[len(ref.Key.Path)-1].Unbox().ScalarString()]; !ok {
} else if _, ok := d2ast.ReservedKeywords[ref.Key.Path[len(ref.Key.Path)-1].Unbox().ScalarString()]; !ok {
absKey, err := d2parser.ParseKey(ref.ScopeObj.AbsID())
if err != nil {
absKey = &d2ast.KeyPath{}
@ -1248,7 +1312,7 @@ func deleteReserved(g *d2graph.Graph, boardPath []string, baseAST *d2ast.Map, mk
}
targetKey = mk.EdgeKey
}
_, ok := d2graph.ReservedKeywords[targetKey.Path[len(targetKey.Path)-1].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[targetKey.Path[len(targetKey.Path)-1].Unbox().ScalarString()]
if !ok {
return g, nil
}
@ -1265,7 +1329,7 @@ func deleteReserved(g *d2graph.Graph, boardPath []string, baseAST *d2ast.Map, mk
if len(mk.Edges) == 1 {
if mk.Key != nil {
var ok bool
obj, ok = g.Root.HasChild(d2graph.Key(mk.Key))
obj, ok = obj.HasChild(d2graph.Key(mk.Key))
if !ok {
return g, nil
}
@ -1291,7 +1355,7 @@ func deleteReserved(g *d2graph.Graph, boardPath []string, baseAST *d2ast.Map, mk
imported := false
parts := d2graph.Key(targetKey)
for i, id := range parts {
_, ok := d2graph.ReservedKeywords[id]
_, ok := d2ast.ReservedKeywords[id]
if ok {
if id == "style" {
isNestedKey = true
@ -1469,7 +1533,7 @@ func deleteObject(g *d2graph.Graph, baseAST *d2ast.Map, key *d2ast.KeyPath, obj
}
ref.Key.Path = append(ref.Key.Path[:ref.KeyPathIndex], ref.Key.Path[ref.KeyPathIndex+1:]...)
withoutSpecial := go2.Filter(ref.Key.Path, func(x *d2ast.StringBox) bool {
_, isReserved := d2graph.ReservedKeywords[x.Unbox().ScalarString()]
_, isReserved := d2ast.ReservedKeywords[x.Unbox().ScalarString()]
isSpecial := isReserved || x.Unbox().ScalarString() == "_"
return !isSpecial
})
@ -1488,7 +1552,7 @@ func deleteObject(g *d2graph.Graph, baseAST *d2ast.Map, key *d2ast.KeyPath, obj
for i := 0; i < len(ref.MapKey.Value.Map.Nodes); i++ {
n := ref.MapKey.Value.Map.Nodes[i]
if n.MapKey != nil && n.MapKey.Key != nil {
_, ok := d2graph.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
if ok {
deleteFromMap(ref.MapKey.Value.Map, n.MapKey)
i--
@ -1655,7 +1719,7 @@ func Rename(g *d2graph.Graph, boardPath []string, key, newName string) (_ *d2gra
mk2.Key = mk.Key
mk = mk2
} else {
_, ok := d2graph.ReservedKeywords[newName]
_, ok := d2ast.ReservedKeywords[newName]
if ok {
return nil, "", fmt.Errorf("cannot rename to reserved keyword: %#v", newName)
}
@ -1680,7 +1744,7 @@ func Rename(g *d2graph.Graph, boardPath []string, key, newName string) (_ *d2gra
func trimReservedSuffix(path []*d2ast.StringBox) []*d2ast.StringBox {
for i, p := range path {
if _, ok := d2graph.ReservedKeywords[p.Unbox().ScalarString()]; ok {
if _, ok := d2ast.ReservedKeywords[p.Unbox().ScalarString()]; ok {
return path[:i]
}
}
@ -1709,6 +1773,9 @@ func move(g *d2graph.Graph, boardPath []string, key, newKey string, includeDesce
}
// TODO beter name
baseAST = boardG.BaseAST
if baseAST == nil {
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
}
}
newKey, _, err := generateUniqueKey(boardG, newKey, nil, nil)
@ -1758,7 +1825,10 @@ func move(g *d2graph.Graph, boardPath []string, key, newKey string, includeDesce
return recompile(g)
}
prevG, _ := recompile(boardG)
prevG, err := recompile(boardG)
if err != nil {
return nil, err
}
ak := d2graph.Key(mk.Key)
ak2 := d2graph.Key(mk2.Key)
@ -1924,9 +1994,9 @@ func move(g *d2graph.Graph, boardPath []string, key, newKey string, includeDesce
ida = resolvedIDA
}
// e.g. "a.b.shape: circle"
_, endsWithReserved := d2graph.ReservedKeywords[ida[len(ida)-1]]
_, endsWithReserved := d2ast.ReservedKeywords[ida[len(ida)-1]]
ida = go2.Filter(ida, func(x string) bool {
_, ok := d2graph.ReservedKeywords[x]
_, ok := d2ast.ReservedKeywords[x]
return !ok
})
@ -1967,7 +2037,7 @@ func move(g *d2graph.Graph, boardPath []string, key, newKey string, includeDesce
continue
}
if n.MapKey.Key != nil {
_, ok := d2graph.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
if ok {
detachedMK.Value.Map.Nodes = append(detachedMK.Value.Map.Nodes, n)
}
@ -2213,7 +2283,7 @@ func filterReserved(value d2ast.ValueBox) (with, without d2ast.ValueBox) {
forWithout = append(forWithout, n)
continue
}
_, ok := d2graph.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
_, ok := d2ast.ReservedKeywords[n.MapKey.Key.Path[0].Unbox().ScalarString()]
if !ok {
flushComments(&forWithout)
forWithout = append(forWithout, n)
@ -2282,8 +2352,17 @@ func updateNear(prevG, g *d2graph.Graph, from, to *string, includeDescendants bo
if len(n.MapKey.Key.Path) == 0 {
continue
}
if len(n.MapKey.Key.Path) > 1 {
if n.MapKey.Key.Path[len(n.MapKey.Key.Path)-2].Unbox().ScalarString() == "label" ||
n.MapKey.Key.Path[len(n.MapKey.Key.Path)-2].Unbox().ScalarString() == "icon" {
continue
}
}
if n.MapKey.Key.Path[len(n.MapKey.Key.Path)-1].Unbox().ScalarString() == "near" {
k := n.MapKey.Value.ScalarBox().Unbox().ScalarString()
if _, ok := d2ast.NearConstants[k]; ok {
continue
}
if strings.EqualFold(k, *from) && to == nil {
deleteFromMap(obj.Map, n.MapKey)
} else {
@ -2907,7 +2986,7 @@ func DeleteIDDeltas(g *d2graph.Graph, boardPath []string, key string) (deltas ma
if mk.Key != nil {
ida := d2graph.Key(mk.Key)
// Deleting a reserved field cannot possibly have any deltas
if _, ok := d2graph.ReservedKeywords[ida[len(ida)-1]]; ok {
if _, ok := d2ast.ReservedKeywords[ida[len(ida)-1]]; ok {
return nil, nil
}
@ -3238,7 +3317,7 @@ func getMostNestedRefs(obj *d2graph.Object) []d2graph.Reference {
func filterReservedPath(path []*d2ast.StringBox) (filtered []*d2ast.StringBox) {
for _, box := range path {
if _, ok := d2graph.ReservedKeywords[strings.ToLower(box.Unbox().ScalarString())]; ok {
if _, ok := d2ast.ReservedKeywords[strings.ToLower(box.Unbox().ScalarString())]; ok {
return
}
filtered = append(filtered, box)

View file

@ -482,6 +482,119 @@ layers: {
b
}
}
`,
},
{
name: "add_layer/1",
text: `b`,
key: `layers.c`,
expKey: `layers.c`,
exp: `b
layers: {
c
}
`,
},
{
name: "add_layer/2",
text: `b
layers: {
c: {
x
}
}`,
key: `layers.b`,
expKey: `layers.b`,
exp: `b
layers: {
c: {
x
}
b
}
`,
},
{
name: "add_layer/3",
text: `b
layers: {
c: {
d
}
}
`,
key: `layers.c`,
boardPath: []string{"c"},
expKey: `layers.c`,
exp: `b
layers: {
c: {
d
layers: {
c
}
}
}
`,
},
{
name: "add_layer/4",
text: `b
layers: {
c
}
`,
key: `d`,
boardPath: []string{"c"},
expKey: `d`,
exp: `b
layers: {
c: {
d
}
}
`,
},
{
name: "add_layer/5",
text: `classes: {
a: {
style.stroke: red
}
}
b
layers: {
c
}
`,
key: `d`,
boardPath: []string{"c"},
expKey: `d`,
exp: `classes: {
a: {
style.stroke: red
}
}
b
layers: {
c: {
d
}
}
`,
},
{
@ -941,6 +1054,16 @@ square.style.opacity: 0.2
key: `square.top`,
value: go2.Pointer(`200`),
exp: `square: {top: 200}
`,
},
{
name: "labeled_set_position",
text: `hey.label: what
`,
key: `hey.top`,
value: go2.Pointer(`200`),
exp: `hey.label: what
hey.top: 200
`,
},
{
@ -2344,6 +2467,28 @@ layers: {
(a -> b)[0].style.stroke: red
`,
},
{
name: "import/10",
text: `heyn
layers: {
man: {...@meow}
}
`,
fsTexts: map[string]string{
"meow.d2": `layers: {
1: {
asdf
}
}
`,
},
boardPath: []string{"man", "1"},
key: `asdf.link`,
value: go2.Pointer(`_._`),
expErr: `failed to set "asdf.link" to "\"_._\"": board [man 1] cannot be modified through this file`,
},
{
name: "label-near/1",
@ -2518,6 +2663,87 @@ x -> a.b -> a.b.c
# hi
style.stroke: green
}
`,
},
{
name: "scenario-child",
text: `a -> b
scenarios: {
x: {
hi
}
}
`,
key: `(a -> b)[0].style.stroke-width`,
value: go2.Pointer(`3`),
boardPath: []string{"x"},
exp: `a -> b
scenarios: {
x: {
hi
(a -> b)[0].style.stroke-width: 3
}
}
`,
},
{
name: "scenario-grandchild",
text: `a -> b
scenarios: {
x: {
scenarios: {
c: {
(a -> b)[0].style.bold: true
}
}
}
}
`,
key: `(a -> b)[0].style.stroke-width`,
value: go2.Pointer(`3`),
boardPath: []string{"x", "c"},
exp: `a -> b
scenarios: {
x: {
scenarios: {
c: {
(a -> b)[0].style.bold: true
(a -> b)[0].style.stroke-width: 3
}
}
}
}
`,
},
{
name: "step-connection",
text: `steps: {
1: {
Modules -- Metricbeat: {
style.stroke-width: 1
}
}
}
`,
key: `Metricbeat.style.stroke`,
value: go2.Pointer(`red`),
boardPath: []string{"1"},
exp: `steps: {
1: {
Modules -- Metricbeat: {
style.stroke-width: 1
}
Metricbeat.style.stroke: red
}
}
`,
},
}
@ -7759,6 +7985,124 @@ layers: {
a
}
}
`,
},
{
name: "edge-out-layer",
text: `x: {
a -> b
}
`,
key: `x.(a -> b)[0].style.stroke`,
exp: `x: {
a -> b
}
`,
},
{
name: "edge-in-layer",
text: `layers: {
test: {
x: {
a -> b
}
}
}
`,
boardPath: []string{"test"},
key: `x.(a -> b)[0].style.stroke`,
exp: `layers: {
test: {
x: {
a -> b
}
}
}
`,
},
{
name: "label-near-in-layer",
text: `layers: {
x: {
y: {
label.near: center-center
}
a
}
}
`,
boardPath: []string{"x"},
key: `y`,
exp: `layers: {
x: {
a
}
}
`,
},
{
name: "update-near-in-layer",
text: `layers: {
x: {
y: {
near: a
}
a
}
}
`,
boardPath: []string{"x"},
key: `y`,
exp: `layers: {
x: {
a
}
}
`,
},
{
name: "edge-with-glob",
text: `x -> y
y
(* -> *)[*].style.opacity: 0.8
`,
key: `(x -> y)[0]`,
exp: `x
y
(* -> *)[*].style.opacity: 0.8
`,
},
{
name: "layer-delete-complex-object",
text: `k
layers: {
x: {
a: "b" {
top: 184
left: 180
}
j
}
}
`,
key: `a`,
boardPath: []string{"x"},
exp: `k
layers: {
x: {
j
}
}
`,
},
}

View file

@ -153,11 +153,12 @@ func IsImportedObj(ast *d2ast.Map, obj *d2graph.Object) bool {
return false
}
// Globs count as imported for now
// Glob creations count as imported for now
// TODO Probably rename later
func IsImportedEdge(ast *d2ast.Map, edge *d2graph.Edge) bool {
for _, ref := range edge.References {
if ref.Edge.Src.HasGlob() || ref.Edge.Dst.HasGlob() {
// If edge index, the glob is just setting something, not responsible for creating the edge
if (ref.Edge.Src.HasGlob() || ref.Edge.Dst.HasGlob()) && ref.MapKey.EdgeIndex == nil {
return true
}
if ref.Edge.Range.Path != ast.Range.Path {
@ -248,7 +249,7 @@ func GetID(key string) string {
func GetWriteableRefs(obj *d2graph.Object, writeableAST *d2ast.Map) (out []d2graph.Reference) {
for i, ref := range obj.References {
if ref.ScopeAST == writeableAST && ref.Key.Range.Path == writeableAST.Range.Path {
if ref.ScopeAST == writeableAST && ref.Key.Range.Path == writeableAST.Range.Path && len(ref.MapKey.Edges) == 0 {
out = append(out, obj.References[i])
}
}

View file

@ -1163,16 +1163,18 @@ func (p *parser) parseUnquotedString(inKey bool) (s *d2ast.UnquotedString) {
sb.WriteRune(r)
rawb.WriteRune(r)
r = r2
case '*':
if sb.Len() == 0 {
s.Pattern = append(s.Pattern, "*")
} else {
s.Pattern = append(s.Pattern, sb.String()[lastPatternIndex:], "*")
}
lastPatternIndex = len(sb.String()) + 1
}
}
if r == '*' {
if sb.Len() == 0 {
s.Pattern = append(s.Pattern, "*")
} else {
s.Pattern = append(s.Pattern, sb.String()[lastPatternIndex:], "*")
}
lastPatternIndex = len(sb.String()) + 1
}
p.commit()
if !unicode.IsSpace(r) {

View file

@ -54,13 +54,21 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO
width := br.X - tl.X + int(*renderOpts.Pad)*2
height := br.Y - tl.Y + int(*renderOpts.Pad)*2
fitToScreenWrapperOpening := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" d2Version="%s" preserveAspectRatio="xMinYMin meet" viewBox="0 0 %d %d">`,
var dimensions string
if renderOpts.Scale != nil {
dimensions = fmt.Sprintf(` width="%d" height="%d"`,
int(math.Ceil((*renderOpts.Scale)*float64(width))),
int(math.Ceil((*renderOpts.Scale)*float64(height))),
)
}
fitToScreenWrapperOpening := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" d2Version="%s" preserveAspectRatio="xMinYMin meet" viewBox="0 0 %d %d"%s>`,
version.Version,
width, height,
width, height, dimensions,
)
fmt.Fprint(buf, fitToScreenWrapperOpening)
innerOpening := fmt.Sprintf(`<svg id="d2-svg" width="%d" height="%d" viewBox="%d %d %d %d">`,
innerOpening := fmt.Sprintf(`<svg class="d2-svg" width="%d" height="%d" viewBox="%d %d %d %d">`,
width, height, left, top, width, height)
fmt.Fprint(buf, innerOpening)
@ -69,7 +77,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO
svgsStr += string(svg) + " "
}
diagramHash, err := rootDiagram.HashID()
diagramHash, err := rootDiagram.HashID(renderOpts.Salt)
if err != nil {
return nil, err
}
@ -94,7 +102,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO
}
if renderOpts.Sketch != nil && *renderOpts.Sketch {
d2sketch.DefineFillPatterns(buf)
d2sketch.DefineFillPatterns(buf, diagramHash)
}
fmt.Fprint(buf, `<style type="text/css"><![CDATA[`)

View file

@ -6,9 +6,9 @@ import (
"math"
"regexp"
"strconv"
"strings"
"github.com/dop251/goja"
"oss.terrastruct.com/d2/lib/jsrunner"
"oss.terrastruct.com/util-go/xdefer"
)
@ -29,21 +29,25 @@ var svgRe = regexp.MustCompile(`<svg[^>]+width="([0-9\.]+)ex" height="([0-9\.]+)
func Render(s string) (_ string, err error) {
defer xdefer.Errorf(&err, "latex failed to parse")
vm := goja.New()
s = doubleBackslashes(s)
runner := jsrunner.NewJSRunner()
if _, err := vm.RunString(polyfillsJS); err != nil {
if _, err := runner.RunString(polyfillsJS); err != nil {
return "", err
}
if _, err := vm.RunString(mathjaxJS); err != nil {
if _, err := runner.RunString(mathjaxJS); err != nil {
// Known issue that a harmless error occurs in JS: https://github.com/mathjax/MathJax/issues/3289
if runner.Engine() == jsrunner.Goja {
return "", err
}
}
if _, err := runner.RunString(setupJS); err != nil {
return "", err
}
if _, err := vm.RunString(setupJS); err != nil {
return "", err
}
val, err := vm.RunString(fmt.Sprintf(`adaptor.innerHTML(html.convert(`+"`"+"%s`"+`, {
val, err := runner.RunString(fmt.Sprintf(`adaptor.innerHTML(html.convert(`+"`"+"%s`"+`, {
em: %d,
ex: %d,
}))`, s, pxPerEx*2, pxPerEx))
@ -80,3 +84,15 @@ func Measure(s string) (width, height int, err error) {
return int(math.Ceil(wf * float64(pxPerEx))), int(math.Ceil(hf * float64(pxPerEx))), nil
}
func doubleBackslashes(s string) string {
var result strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' {
result.WriteString("\\\\")
} else {
result.WriteByte(s[i])
}
}
return result.String()
}

View file

@ -8,7 +8,7 @@ import (
func TestRender(t *testing.T) {
txts := []string{
`a + b = c`,
`\\frac{1}{2}`,
`\frac{1}{2}`,
`a + b
= c
`,
@ -24,10 +24,3 @@ func TestRender(t *testing.T) {
}
}
}
func TestRenderError(t *testing.T) {
_, err := Render(`\frac{1}{2}`)
if err == nil {
t.Fatal("expected to error on invalid latex syntax")
}
}

View file

@ -2,5 +2,10 @@ const adaptor = MathJax._.adaptors.liteAdaptor.liteAdaptor();
MathJax._.handlers.html_ts.RegisterHTMLHandler(adaptor)
const html = MathJax._.mathjax.mathjax.document('', {
InputJax: new MathJax._.input.tex_ts.TeX({ packages: ['base', 'mathtools', 'ams', 'amscd', 'braket', 'cancel', 'cases', 'color', 'gensymb', 'mhchem', 'physics'] }),
OutputJax: new MathJax._.output.svg_ts.SVG(),
OutputJax: new MathJax._.output.svg_ts.SVG({fontCache: 'none'}),
});
if (typeof globalThis !== 'undefined') {
globalThis.adaptor = adaptor;
globalThis.html = html;
}

View file

@ -17,3 +17,7 @@ const root = {
};
const rc = rough.svg(root, { seed: 1 });
let node;
if (typeof globalThis !== "undefined") {
globalThis.rc = rc;
}

View file

@ -9,12 +9,11 @@ import (
_ "embed"
"github.com/dop251/goja"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/jsrunner"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/svg"
"oss.terrastruct.com/util-go/go2"
@ -29,8 +28,6 @@ var setupJS string
//go:embed streaks.txt
var streaks string
type Runner goja.Runtime
var baseRoughProps = `fillWeight: 2.0,
hachureGap: 16,
fillStyle: "solid",
@ -44,46 +41,39 @@ const (
FG_COLOR = color.N1
)
func (r *Runner) run(js string) (goja.Value, error) {
vm := (*goja.Runtime)(r)
return vm.RunString(js)
}
func InitSketchVM() (*Runner, error) {
vm := goja.New()
if _, err := vm.RunString(roughJS); err != nil {
return nil, err
func LoadJS(runner jsrunner.JSRunner) error {
if _, err := runner.RunString(roughJS); err != nil {
return err
}
if _, err := vm.RunString(setupJS); err != nil {
return nil, err
if _, err := runner.RunString(setupJS); err != nil {
return err
}
r := Runner(*vm)
return &r, nil
return nil
}
// DefineFillPatterns adds reusable patterns that are overlayed on shapes with
// fill. This gives it a subtle streaky effect that subtly looks hand-drawn but
// not distractingly so.
func DefineFillPatterns(buf *bytes.Buffer) {
func DefineFillPatterns(buf *bytes.Buffer, diagramHash string) {
source := buf.String()
fmt.Fprint(buf, "<defs>")
defineFillPattern(buf, source, "bright", "rgba(0, 0, 0, 0.1)")
defineFillPattern(buf, source, "normal", "rgba(0, 0, 0, 0.16)")
defineFillPattern(buf, source, "dark", "rgba(0, 0, 0, 0.32)")
defineFillPattern(buf, source, "darker", "rgba(255, 255, 255, 0.24)")
defineFillPattern(buf, source, diagramHash, "bright", "rgba(0, 0, 0, 0.1)")
defineFillPattern(buf, source, diagramHash, "normal", "rgba(0, 0, 0, 0.16)")
defineFillPattern(buf, source, diagramHash, "dark", "rgba(0, 0, 0, 0.32)")
defineFillPattern(buf, source, diagramHash, "darker", "rgba(255, 255, 255, 0.24)")
fmt.Fprint(buf, "</defs>")
}
func defineFillPattern(buf *bytes.Buffer, source string, luminanceCategory, fill string) {
trigger := fmt.Sprintf(`url(#streaks-%s)`, luminanceCategory)
func defineFillPattern(buf *bytes.Buffer, source, diagramHash string, luminanceCategory, fill string) {
trigger := fmt.Sprintf(`url(#streaks-%s-%s)`, luminanceCategory, diagramHash)
if strings.Contains(source, trigger) {
fmt.Fprintf(buf, streaks, luminanceCategory, fill)
fmt.Fprintf(buf, streaks, luminanceCategory, diagramHash, fill)
}
}
func Rect(r *Runner, shape d2target.Shape) (string, error) {
func Rect(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "#000",
stroke: "#000",
@ -95,7 +85,7 @@ func Rect(r *Runner, shape d2target.Shape) (string, error) {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
@ -106,7 +96,7 @@ func Rect(r *Runner, shape d2target.Shape) (string, error) {
output += pathEl.Render()
}
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl := d2themes.NewThemableElement("rect", nil)
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = float64(shape.Height)
@ -119,7 +109,7 @@ func Rect(r *Runner, shape d2target.Shape) (string, error) {
return output, nil
}
func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
func DoubleRect(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
jsBigRect := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "#000",
stroke: "#000",
@ -143,7 +133,7 @@ func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
@ -154,7 +144,7 @@ func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
output += pathEl.Render()
}
pathEl = d2themes.NewThemableElement("path")
pathEl = d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X+d2target.INNER_BORDER_OFFSET), float64(shape.Pos.Y+d2target.INNER_BORDER_OFFSET))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
// No need for inner to double paint
@ -166,7 +156,7 @@ func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
output += pathEl.Render()
}
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl := d2themes.NewThemableElement("rect", nil)
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = float64(shape.Height)
@ -179,7 +169,7 @@ func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
return output, nil
}
func Oval(r *Runner, shape d2target.Shape) (string, error) {
func Oval(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
js := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
fill: "#000",
stroke: "#000",
@ -191,7 +181,7 @@ func Oval(r *Runner, shape d2target.Shape) (string, error) {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
@ -202,7 +192,7 @@ func Oval(r *Runner, shape d2target.Shape) (string, error) {
output += pathEl.Render()
}
soElement := d2themes.NewThemableElement("ellipse")
soElement := d2themes.NewThemableElement("ellipse", nil)
soElement.SetTranslate(float64(shape.Pos.X+shape.Width/2), float64(shape.Pos.Y+shape.Height/2))
soElement.Rx = float64(shape.Width / 2)
soElement.Ry = float64(shape.Height / 2)
@ -218,7 +208,7 @@ func Oval(r *Runner, shape d2target.Shape) (string, error) {
return output, nil
}
func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
func DoubleOval(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
jsBigCircle := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
fill: "#000",
stroke: "#000",
@ -242,7 +232,7 @@ func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
@ -253,7 +243,7 @@ func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
output += pathEl.Render()
}
pathEl = d2themes.NewThemableElement("path")
pathEl = d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
// No need for inner to double paint
@ -264,7 +254,7 @@ func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
pathEl.D = p
output += pathEl.Render()
}
soElement := d2themes.NewThemableElement("ellipse")
soElement := d2themes.NewThemableElement("ellipse", nil)
soElement.SetTranslate(float64(shape.Pos.X+shape.Width/2), float64(shape.Pos.Y+shape.Height/2))
soElement.Rx = float64(shape.Width / 2)
soElement.Ry = float64(shape.Height / 2)
@ -281,7 +271,7 @@ func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
}
// TODO need to personalize this per shape like we do in Terrastruct app
func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
func Paths(r jsrunner.JSRunner, shape d2target.Shape, paths []string) (string, error) {
output := ""
for _, path := range paths {
js := fmt.Sprintf(`node = rc.path("%s", {
@ -294,7 +284,7 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
pathEl.ClassName = "shape"
@ -304,7 +294,7 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
output += pathEl.Render()
}
soElement := d2themes.NewThemableElement("path")
soElement := d2themes.NewThemableElement("path", nil)
for _, p := range sketchPaths {
soElement.D = p
renderedSO, err := d2themes.NewThemableSketchOverlay(
@ -320,34 +310,75 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
return output, nil
}
func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) {
roughness := 0.5
js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness)
paths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
output := ""
func Connection(r jsrunner.JSRunner, connection d2target.Connection, path, attrs string) (string, error) {
animatedClass := ""
if connection.Animated {
animatedClass = " animated-connection"
}
pathEl := d2themes.NewThemableElement("path")
pathEl.Fill = color.None
pathEl.Stroke = connection.Stroke
pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
pathEl.Style = connection.CSSStyle()
pathEl.Attributes = attrs
for _, p := range paths {
pathEl.D = p
output += pathEl.Render()
if connection.Animated {
// If connection is animated and bidirectional
if (connection.DstArrow == d2target.NoArrowhead && connection.SrcArrow == d2target.NoArrowhead) || (connection.DstArrow != d2target.NoArrowhead && connection.SrcArrow != d2target.NoArrowhead) {
// There is no pure CSS way to animate bidirectional connections in two directions, so we split it up
path1, path2, err := svg.SplitPath(path, 0.5)
if err != nil {
return "", err
}
pathEl1 := d2themes.NewThemableElement("path", nil)
pathEl1.D = path1
pathEl1.Fill = color.None
pathEl1.Stroke = connection.Stroke
pathEl1.ClassName = fmt.Sprintf("connection%s", animatedClass)
pathEl1.Style = connection.CSSStyle()
pathEl1.Style += "animation-direction: reverse;"
pathEl1.Attributes = attrs
pathEl2 := d2themes.NewThemableElement("path", nil)
pathEl2.D = path2
pathEl2.Fill = color.None
pathEl2.Stroke = connection.Stroke
pathEl2.ClassName = fmt.Sprintf("connection%s", animatedClass)
pathEl2.Style = connection.CSSStyle()
pathEl2.Attributes = attrs
return pathEl1.Render() + " " + pathEl2.Render(), nil
} else {
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.D = path
pathEl.Fill = color.None
pathEl.Stroke = connection.Stroke
pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
pathEl.Style = connection.CSSStyle()
pathEl.Attributes = attrs
return pathEl.Render(), nil
}
} else {
roughness := 0.5
js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness)
paths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.Fill = color.None
pathEl.Stroke = connection.Stroke
pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
pathEl.Style = connection.CSSStyle()
pathEl.Attributes = attrs
for _, p := range paths {
pathEl.D = p
output += pathEl.Render()
}
return output, nil
}
return output, nil
}
// TODO cleanup
func Table(r *Runner, shape d2target.Shape) (string, error) {
func Table(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
output := ""
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "#000",
@ -359,7 +390,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
@ -386,7 +417,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl = d2themes.NewThemableElement("path")
pathEl = d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill = shape.Fill
pathEl.FillPattern = shape.FillPattern
@ -404,7 +435,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
float64(shape.LabelHeight),
)
textEl := d2themes.NewThemableElement("text")
textEl := d2themes.NewThemableElement("text", nil)
textEl.X = tl.X
textEl.Y = tl.Y + float64(shape.LabelHeight)*3/4
textEl.Fill = shape.GetFontColor()
@ -437,7 +468,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
float64(shape.FontSize),
)
textEl := d2themes.NewThemableElement("text")
textEl := d2themes.NewThemableElement("text", nil)
textEl.X = nameTL.X
textEl.Y = nameTL.Y + float64(shape.FontSize)*3/4
textEl.Fill = shape.PrimaryAccentColor
@ -467,7 +498,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.Fill = shape.Fill
pathEl.FillPattern = shape.FillPattern
for _, p := range paths {
@ -476,7 +507,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
}
}
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl := d2themes.NewThemableElement("rect", nil)
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = float64(shape.Height)
@ -489,7 +520,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
return output, nil
}
func Class(r *Runner, shape d2target.Shape) (string, error) {
func Class(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
output := ""
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "#000",
@ -501,7 +532,7 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.FillPattern = shape.FillPattern
@ -529,7 +560,7 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl = d2themes.NewThemableElement("path")
pathEl = d2themes.NewThemableElement("path", nil)
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill = shape.Fill
pathEl.FillPattern = shape.FillPattern
@ -539,7 +570,7 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
output += pathEl.Render()
}
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl := d2themes.NewThemableElement("rect", nil)
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = headerBox.Height
@ -557,7 +588,7 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
float64(shape.LabelHeight),
)
textEl := d2themes.NewThemableElement("text")
textEl := d2themes.NewThemableElement("text", nil)
textEl.X = tl.X + float64(shape.LabelWidth)/2
textEl.Y = tl.Y + float64(shape.LabelHeight)*3/4
textEl.Fill = shape.GetFontColor()
@ -584,7 +615,7 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl = d2themes.NewThemableElement("path")
pathEl = d2themes.NewThemableElement("path", nil)
pathEl.Fill = shape.Fill
pathEl.FillPattern = shape.FillPattern
pathEl.ClassName = "class_header"
@ -616,7 +647,7 @@ func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText str
fontSize,
)
textEl := d2themes.NewThemableElement("text")
textEl := d2themes.NewThemableElement("text", nil)
textEl.X = prefixTL.X
textEl.Y = prefixTL.Y + fontSize*3/4
textEl.Fill = shape.PrimaryAccentColor
@ -640,8 +671,8 @@ func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText str
return output
}
func computeRoughPathData(r *Runner, js string) ([]string, error) {
if _, err := r.run(js); err != nil {
func computeRoughPathData(r jsrunner.JSRunner, js string) ([]string, error) {
if _, err := r.RunString(js); err != nil {
return nil, err
}
roughPaths, err := extractRoughPaths(r)
@ -651,8 +682,8 @@ func computeRoughPathData(r *Runner, js string) ([]string, error) {
return extractPathData(roughPaths)
}
func computeRoughPaths(r *Runner, js string) ([]roughPath, error) {
if _, err := r.run(js); err != nil {
func computeRoughPaths(r jsrunner.JSRunner, js string) ([]roughPath, error) {
if _, err := r.RunString(js); err != nil {
return nil, err
}
return extractRoughPaths(r)
@ -681,8 +712,8 @@ func (rp roughPath) StyleCSS() string {
return style
}
func extractRoughPaths(r *Runner) ([]roughPath, error) {
val, err := r.run("JSON.stringify(node.children, null, ' ')")
func extractRoughPaths(r jsrunner.JSRunner) ([]roughPath, error) {
val, err := r.RunString("JSON.stringify(node.children, null, ' ')")
if err != nil {
return nil, err
}
@ -715,7 +746,7 @@ func extractPathData(roughPaths []roughPath) ([]string, error) {
return paths, nil
}
func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeWidth int) (arrowJS, extraJS string) {
func ArrowheadJS(r jsrunner.JSRunner, arrowhead d2target.Arrowhead, stroke string, strokeWidth int) (arrowJS, extraJS string) {
// Note: selected each seed that looks the good for consistent renders
switch arrowhead {
case d2target.ArrowArrowhead:
@ -802,11 +833,34 @@ func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeW
stroke,
BG_COLOR,
)
case d2target.CircleArrowhead:
arrowJS = fmt.Sprintf(
`node = rc.circle(-2, -1, 8, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 1, seed: 5 })`,
strokeWidth,
stroke,
BG_COLOR,
)
case d2target.BoxArrowhead:
arrowJS = fmt.Sprintf(
`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 1})`,
`[[0, -10], [0, 10], [-20, 10], [-20, -10]]`,
strokeWidth,
stroke,
BG_COLOR,
)
case d2target.FilledBoxArrowhead:
arrowJS = fmt.Sprintf(
`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 1})`,
`[[0, -10], [0, 10], [-20, 10], [-20, -10]]`,
strokeWidth,
stroke,
stroke,
)
}
return
}
func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.Point) (string, error) {
func Arrowheads(r jsrunner.JSRunner, connection d2target.Connection, srcAdj, dstAdj *geo.Point) (string, error) {
arrowPaths := []string{}
if connection.SrcArrow != d2target.NoArrowhead {
@ -835,7 +889,7 @@ func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.P
roughPaths = append(roughPaths, extraPaths...)
}
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.ClassName = "connection"
pathEl.Attributes = transform
for _, rp := range roughPaths {
@ -874,7 +928,7 @@ func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.P
roughPaths = append(roughPaths, extraPaths...)
}
pathEl := d2themes.NewThemableElement("path")
pathEl := d2themes.NewThemableElement("path", nil)
pathEl.ClassName = "connection"
pathEl.Attributes = transform
for _, rp := range roughPaths {

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