Merge branch 'master' into update-contributing

This commit is contained in:
Alexander Wang 2022-12-30 10:07:45 -08:00
commit e278ce2eaf
No known key found for this signature in database
GPG key ID: D89FA31966BDBECE
542 changed files with 41094 additions and 5089 deletions

View file

@ -27,7 +27,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: git submodule update --init
- run: COLOR=1 ./ci/sub/nofixups.sh
- run: COLOR=1 ./ci/sub/bin/nofixups.sh
env:
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
d2layouts/d2dagrelayout/dagre.js
d2layouts/d2elklayout/setup.js
d2renderers/d2latex/mathjax.js
d2renderers/d2latex/polyfills.js
d2renderers/d2latex/setup.js
lib/png/generate_png.js

View file

@ -5,7 +5,7 @@ all: fmt gen lint build test
.PHONY: fmt
fmt:
prefix "$@" ./ci/fmt.sh
prefix "$@" ./ci/sub/bin/fmt.sh
.PHONY: gen
gen:
prefix "$@" ./ci/gen.sh

View file

@ -4,14 +4,20 @@
A modern diagram scripting language that turns text to diagrams.
</h2>
[Language docs](https://d2lang.com) | [Cheat sheet](./docs/assets/cheat_sheet.pdf) | [Comparisons](https://text-to-diagram.com)
[Docs](https://d2lang.com) | [Cheat sheet](./docs/assets/cheat_sheet.pdf) | [Comparisons](https://text-to-diagram.com) | [Playground](https://play.d2lang.com)
[![ci](https://github.com/terrastruct/d2/actions/workflows/ci.yml/badge.svg)](https://github.com/terrastruct/d2/actions/workflows/ci.yml)
[![daily](https://github.com/terrastruct/d2/actions/workflows/daily.yml/badge.svg)](https://github.com/terrastruct/d2/actions/workflows/daily.yml)
[![release](https://img.shields.io/github/v/release/terrastruct/d2)](https://github.com/terrastruct/d2/releases)
[![discord](https://img.shields.io/discord/1039184639652265985?label=discord)](https://discord.gg/NF6X8K4eDq)
[![twitter](https://img.shields.io/twitter/follow/terrastruct?style=social)](https://twitter.com/terrastruct)
[![license](https://img.shields.io/github/license/terrastruct/d2?color=9cf)](./LICENSE.txt)
<a href="https://play.d2lang.com">
<img src="./docs/assets/playground_button.png" alt="D2 Playground button" width="200" />
</a>
https://user-images.githubusercontent.com/3120367/206125010-bd1fea8e-248a-43e7-8f85-0bbfca0c6e2a.mp4
</div>
@ -32,9 +38,8 @@ https://user-images.githubusercontent.com/3120367/206125010-bd1fea8e-248a-43e7-8
- <a href="#contributing" id="toc-contributing">Contributing</a>
- <a href="#license" id="toc-license">License</a>
- <a href="#related" id="toc-related">Related</a>
- <a href="#vscode-extension" id="toc-vscode-extension">VSCode extension</a>
- <a href="#vim-extension" id="toc-vim-extension">Vim extension</a>
- <a href="#language-docs" id="toc-language-docs">Language docs</a>
- <a href="#official-plugins" id="toc-official-plugins">Official plugins</a>
- <a href="#community-plugins" id="toc-community-plugins">Community plugins</a>
- <a href="#misc" id="toc-misc">Misc</a>
- <a href="#faq" id="toc-faq">FAQ</a>
@ -140,7 +145,7 @@ one, please see [./d2renderers/d2fonts](./d2renderers/d2fonts).
## Export file types
D2 currently supports SVG exports. More coming soon.
D2 currently supports SVG and PNG exports. More coming soon.
## Language tooling
@ -186,21 +191,37 @@ Open sourced under the Mozilla Public License 2.0. See [./LICENSE.txt](./LICENSE
## Related
### VSCode extension
We are constantly working on new plugins, integrations, extensions. Contributions are
welcome in any official or community plugins. If you have somewhere in your workflow that
you want to use D2, feel free to open a discussion. We have limited bandwidth and usually
choose the most high-demand ones to work on. If you make something cool with D2 yourself,
let us know and we'll be happy to include it here!
[https://github.com/terrastruct/d2-vscode](https://github.com/terrastruct/d2-vscode)
### Official plugins
### Vim extension
- **VSCode extension**: [https://github.com/terrastruct/d2-vscode](https://github.com/terrastruct/d2-vscode)
- **Vim extension**: [https://github.com/terrastruct/d2-vim](https://github.com/terrastruct/d2-vim)
- **Obsidian plugin**: [https://github.com/terrastruct/d2-obsidian](https://github.com/terrastruct/d2-obsidian)
- **Slack app**: [https://d2lang.com/tour/slack](https://d2lang.com/tour/slack)
- **Discord plugin**: [https://d2lang.com/tour/discord](https://d2lang.com/tour/discord)
[https://github.com/terrastruct/d2-vim](https://github.com/terrastruct/d2-vim)
### Community plugins
### Language docs
[https://github.com/terrastruct/d2-docs](https://github.com/terrastruct/d2-docs)
- **Tree-sitter grammar**: [https://github.com/pleshevskiy/tree-sitter-d2](https://github.com/pleshevskiy/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)
- **Postgres importer**: [https://github.com/zekenie/d2-erd-from-postgres](https://github.com/zekenie/d2-erd-from-postgres)
- **Structurizr to D2 exporter**: [https://github.com/goto1134/structurizr-d2-exporter](https://github.com/goto1134/structurizr-d2-exporter)
- **MdBook preprocessor**: [https://github.com/danieleades/mdbook-d2](https://github.com/danieleades/mdbook-d2)
- **D2 org-mode support**: [https://github.com/xcapaldi/ob-d2](https://github.com/xcapaldi/ob-d2)
- **Python D2 diagram builder**: [https://github.com/MrBlenny/py-d2](https://github.com/MrBlenny/py-d2)
### Misc
- [https://github.com/terrastruct/text-to-diagram-site](https://github.com/terrastruct/text-to-diagram-site)
- **Comparison site**: [https://github.com/terrastruct/text-to-diagram-site](https://github.com/terrastruct/text-to-diagram-site)
- **Playground**: [https://github.com/terrastruct/d2-playground](https://github.com/terrastruct/d2-playground)
- **Language docs**: [https://github.com/terrastruct/d2-docs](https://github.com/terrastruct/d2-docs)
## FAQ

View file

@ -3,6 +3,7 @@ set -eu
export REPORT_OUTPUT="out/e2e_report.html"
rm -f $REPORT_OUTPUT
export E2E_REPORT=1
FORCE_COLOR=1 DEBUG=1 go run ./e2etests/report/main.go "$@";

View file

@ -1,12 +0,0 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/sub/lib.sh"
cd -- "$(dirname "$0")/.."
if is_changed README.md; then
sh_c tocsubst --skip 1 README.md
fi
if is_changed docs/INSTALL.md; then
sh_c tocsubst --skip 1 docs/INSTALL.md
fi
./ci/sub/fmt/make.sh

18
ci/release/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
# https://hub.docker.com/repository/docker/terrastruct/d2
FROM debian:latest
ARG TARGETARCH
COPY ./d2-*-linux-$TARGETARCH.tar.gz /tmp
RUN mkdir -p /usr/local/lib/d2 \
&& tar -C /usr/local/lib/d2 -xzf /tmp/d2-*-linux-"$TARGETARCH".tar.gz \
&& /usr/local/lib/d2/d2-*/scripts/install.sh \
&& rm -Rf /tmp/d2-*-linux-"$TARGETARCH".tar.gz
WORKDIR /root/src
EXPOSE 8080
ENV PORT 8080
ENV HOST 0.0.0.0
ENV BROWSER false
ENTRYPOINT ["/usr/local/bin/d2"]

View file

@ -2,7 +2,7 @@
## _install.sh
The template for the install script in the root of the repository.
The template for the install script in the root of the d2 repository.
### gen_install.sh
@ -23,12 +23,12 @@ it depends on from ../sub/lib.
> variables as we must compile d2 directly on each release target to include dagre.
> See https://github.com/terrastruct/d2/issues/31
Use `--host-only` to build only the release for the host's OS-ARCH pair.
Use `--host-only` to build only the release for the host's `$OS-$ARCH` pair.
### build_docker.sh
Helper script called by build.sh to build D2 on each linux runner inside Docker.
The Dockerfile is in ./builders/Dockerfile
The Dockerfile is in ./linux/Dockerfile
### _build.sh

View file

@ -3,20 +3,25 @@ set -eu
cd -- "$(dirname "$0")/../.."
. ./ci/sub/lib.sh
sh_c rm -Rf "$HW_BUILD_DIR"
sh_c mkdir -p "$HW_BUILD_DIR"
sh_c rsync --recursive --perms --delete \
--human-readable --copy-links ./ci/release/template/ "$HW_BUILD_DIR/"
VERSION=$VERSION sh_c eval "'$HW_BUILD_DIR/README.md.sh'" \> "'$HW_BUILD_DIR/README.md'"
sh_c rm -f "$HW_BUILD_DIR/README.md.sh"
sh_c find "$HW_BUILD_DIR" -exec touch {} \\\;
sh_c cp ./ci/release/template/LICENSE.txt "$HW_BUILD_DIR"
sh_c cp ./ci/release/template/Makefile "$HW_BUILD_DIR"
sh_c cp -R ./ci/release/template/man "$HW_BUILD_DIR"
sh_c cp -R ./ci/release/template/scripts "$HW_BUILD_DIR"
sh_c VERSION="$VERSION" ./ci/release/template/README.md.sh \> "'$HW_BUILD_DIR/README.md'"
ensure_goos
ensure_goarch
sh_c mkdir -p "$HW_BUILD_DIR/bin"
sh_c CGO_ENABLED=0 go build -trimpath \
sh_c GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build -trimpath \
-ldflags "'-X oss.terrastruct.com/d2/lib/version.Version=$VERSION'" \
-o "$HW_BUILD_DIR/bin/d2" .
if [ "$GOOS" = windows ]; then
sh_c mv "$HW_BUILD_DIR/bin/d2" "$HW_BUILD_DIR/bin/d2.exe"
fi
ARCHIVE=$PWD/$ARCHIVE
cd "$(dirname "$HW_BUILD_DIR")"
sh_c tar -czf "$ARCHIVE" "$(basename "$HW_BUILD_DIR")"

View file

@ -347,7 +347,7 @@ install_d2_standalone() {
install_d2_brew() {
header "installing d2 with homebrew"
sh_c brew tap terrastruct/d2
sh_c brew update
sh_c brew install d2
}
@ -390,8 +390,8 @@ install_tala_standalone() {
install_tala_brew() {
header "installing tala with homebrew"
sh_c brew tap terrastruct/d2
sh_c brew install tala
sh_c brew update
sh_c brew install terrastruct/tap/tala
}
uninstall() {
@ -502,11 +502,6 @@ fetch_gh() {
sh_c mv "$file.inprogress" "$file"
}
brew() {
# Makes brew sane.
HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_NO_AUTO_UPDATE=1 command brew "$@"
}
# The main function does more than provide organization. It provides robustness in that if
# the install script was to only partial download into sh, sh will not execute it because
# main is not invoked until the very last byte.

545
ci/release/aws/ensure.sh Executable file
View file

@ -0,0 +1,545 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../../../ci/sub/lib.sh"
cd -- "$(dirname "$0")/../../.."
help() {
cat <<EOF
usage: $0 [--dry-run] [--skip-create] [--skip-init] [--copy-id=id.pub]
[--run=jobregex]
$0 creates and ensures the d2 builders in AWS.
EOF
}
main() {
while flag_parse "$@"; do
case "$FLAG" in
h|help)
help
return 0
;;
x)
flag_noarg && shift "$FLAGSHIFT"
set -x
export TRACE=1
;;
dry-run)
flag_noarg && shift "$FLAGSHIFT"
export DRY_RUN=1
;;
copy-id)
flag_nonemptyarg && shift "$FLAGSHIFT"
ID_PUB_PATH=$FLAGARG
;;
run)
flag_reqarg && shift "$FLAGSHIFT"
JOBFILTER="$FLAGARG"
;;
*)
flag_errusage "unrecognized flag $FLAGRAW"
;;
esac
done
shift "$FLAGSHIFT"
if [ $# -gt 0 ]; then
flag_errusage "no arguments are accepted"
fi
if [ -z "${ID_PUB_PATH-}" ]; then
flag_errusage "--copy-id is required"
fi
JOBNAME=create runjob_filter create_remote_hosts
JOBNAME=init runjob_filter init_remote_hosts
FGCOLOR=2 header summary
echo "export CI_D2_LINUX_AMD64=$CI_D2_LINUX_AMD64"
echo "export CI_D2_LINUX_ARM64=$CI_D2_LINUX_ARM64"
echo "export CI_D2_MACOS_AMD64=$CI_D2_MACOS_AMD64"
echo "export CI_D2_MACOS_ARM64=$CI_D2_MACOS_ARM64"
echo "export CI_D2_WINDOWS_AMD64=$CI_D2_WINDOWS_AMD64"
}
create_remote_hosts() {
bigheader create_remote_hosts
KEY_NAME=$(aws ec2 describe-key-pairs | jq -r .KeyPairs[0].KeyName)
KEY_NAME_WINDOWS=windows
VPC_ID=$(aws ec2 describe-vpcs | jq -r .Vpcs[0].VpcId)
JOBNAME=$JOBNAME/security-groups runjob_filter create_security_groups
JOBNAME=$JOBNAME/linux/amd64 runjob_filter create_linux_amd64
JOBNAME=$JOBNAME/linux/arm64 runjob_filter create_linux_arm64
JOBNAME=$JOBNAME/macos/amd64 runjob_filter create_macos_amd64
JOBNAME=$JOBNAME/macos/arm64 runjob_filter create_macos_arm64
JOBNAME=$JOBNAME/windows/amd64 runjob_filter create_windows_amd64
}
create_security_groups() {
header security-group
SG_ID=$(aws ec2 describe-security-groups --group-names ssh 2>/dev/null \
| jq -r .SecurityGroups[0].GroupId)
if [ -z "$SG_ID" ]; then
SG_ID=$(sh_c aws ec2 create-security-group \
--group-name ssh \
--description ssh \
--vpc-id "$VPC_ID" | jq -r .GroupId)
fi
header security-group-ingress
SG_RULES_COUNT=$(aws ec2 describe-security-groups --group-names ssh \
| jq -r '.SecurityGroups[0].IpPermissions | length')
if [ "$SG_RULES_COUNT" -eq 0 ]; then
sh_c aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0 >/dev/null
fi
header windows-security-group
SG_ID=$(aws ec2 describe-security-groups --group-names windows 2>/dev/null \
| jq -r .SecurityGroups[0].GroupId)
if [ -z "$SG_ID" ]; then
SG_ID=$(sh_c aws ec2 create-security-group \
--group-name windows \
--description windows \
--vpc-id "$VPC_ID" | jq -r .GroupId)
fi
header windows-security-group-ingress
SG_RULES_COUNT=$(aws ec2 describe-security-groups --group-names windows \
| jq -r '.SecurityGroups[0].IpPermissions | length')
if [ "$SG_RULES_COUNT" -ne 2 ]; then
sh_c aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0 >/dev/null
sh_c aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp \
--port 3389 \
--cidr 0.0.0.0/0 >/dev/null
fi
}
create_linux_amd64() {
header linux-amd64
REMOTE_NAME=ci-d2-linux-amd64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=ci-d2-linux-amd64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0ecc74eca1d66d8a6 \
--count=1 \
--instance-type=t3.small \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--iam-instance-profile 'Name=AmazonSSMRoleForInstancesQuickSetup' \
--block-device-mappings '"DeviceName=/dev/sda1,Ebs={VolumeSize=64,VolumeType=gp3}"' \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=ci-d2-linux-amd64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=ci-d2-linux-amd64}]"' >/dev/null
fi
wait_remote_host_ip
log "CI_D2_LINUX_AMD64=ubuntu@$ip"
export CI_D2_LINUX_AMD64=ubuntu@$ip
}
create_linux_arm64() {
header linux-arm64
REMOTE_NAME=ci-d2-linux-arm64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=ci-d2-linux-arm64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-06e2dea2cdda3acda \
--count=1 \
--instance-type=t4g.small \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--iam-instance-profile 'Name=AmazonSSMRoleForInstancesQuickSetup' \
--block-device-mappings '"DeviceName=/dev/sda1,Ebs={VolumeSize=64,VolumeType=gp3}"' \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=ci-d2-linux-arm64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=ci-d2-linux-arm64}]"' >/dev/null
fi
wait_remote_host_ip
log "CI_D2_LINUX_ARM64=ubuntu@$ip"
export CI_D2_LINUX_ARM64=ubuntu@$ip
}
create_macos_amd64() {
header macos-amd64-host
MACOS_AMD64_ID=$(aws ec2 describe-hosts --filter 'Name=state,Values=pending,available' 'Name=tag:Name,Values=ci-d2-macos-amd64' | jq -r '.Hosts[].HostId')
if [ -z "$MACOS_AMD64_ID" ]; then
MACOS_AMD64_ID=$(sh_c aws ec2 allocate-hosts --instance-type mac1.metal --quantity 1 --availability-zone us-west-2a \
--tag-specifications '"ResourceType=dedicated-host,Tags=[{Key=Name,Value=ci-d2-macos-amd64}]"' \
| jq -r .HostIds[0])
fi
header macos-amd64
REMOTE_NAME=ci-d2-macos-amd64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=ci-d2-macos-amd64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0dd2ded7568750663 \
--count=1 \
--instance-type=mac1.metal \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--iam-instance-profile 'Name=AmazonSSMRoleForInstancesQuickSetup' \
--placement "Tenancy=host,HostId=$MACOS_AMD64_ID" \
--block-device-mappings '"DeviceName=/dev/sda1,Ebs={VolumeSize=100,VolumeType=gp3}"' \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=ci-d2-macos-amd64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=ci-d2-macos-amd64}]"' >/dev/null
fi
wait_remote_host_ip
log "CI_D2_MACOS_AMD64=ec2-user@$ip"
export CI_D2_MACOS_AMD64=ec2-user@$ip
}
create_macos_arm64() {
header macos-arm64-host
MACOS_ARM64_ID=$(aws ec2 describe-hosts --filter 'Name=state,Values=pending,available' 'Name=tag:Name,Values=ci-d2-macos-arm64' | jq -r '.Hosts[].HostId')
if [ -z "$MACOS_ARM64_ID" ]; then
MACOS_ARM64_ID=$(sh_c aws ec2 allocate-hosts --instance-type mac2.metal --quantity 1 --availability-zone us-west-2a \
--tag-specifications '"ResourceType=dedicated-host,Tags=[{Key=Name,Value=ci-d2-macos-arm64}]"' \
| jq -r .HostIds[0])
fi
header macos-arm64
REMOTE_NAME=ci-d2-macos-arm64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=ci-d2-macos-arm64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0af0516ff2c43dbbe \
--count=1 \
--instance-type=mac2.metal \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--iam-instance-profile 'Name=AmazonSSMRoleForInstancesQuickSetup' \
--placement "Tenancy=host,HostId=$MACOS_ARM64_ID" \
--block-device-mappings '"DeviceName=/dev/sda1,Ebs={VolumeSize=100,VolumeType=gp3}"' \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=ci-d2-macos-arm64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=ci-d2-macos-arm64}]"' >/dev/null
fi
wait_remote_host_ip
log "CI_D2_MACOS_ARM64=ec2-user@$ip"
export CI_D2_MACOS_ARM64=ec2-user@$ip
}
create_windows_amd64() {
header windows-amd64
REMOTE_NAME=ci-d2-windows-amd64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' "Name=tag:Name,Values=$REMOTE_NAME" \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0c5300e833c2b32f3 \
--count=1 \
--instance-type=t3.medium \
--security-groups=windows \
"--key-name=$KEY_NAME_WINDOWS" \
--iam-instance-profile 'Name=AmazonSSMRoleForInstancesQuickSetup' \
--block-device-mappings '"DeviceName=/dev/sda1,Ebs={VolumeSize=64,VolumeType=gp3}"' \
--tag-specifications "'ResourceType=instance,Tags=[{Key=Name,Value=$REMOTE_NAME}]'" \
"'ResourceType=volume,Tags=[{Key=Name,Value=$REMOTE_NAME}]'" >/dev/null
fi
wait_remote_host_ip
log "CI_D2_WINDOWS_AMD64=Administrator@$ip"
export CI_D2_WINDOWS_AMD64=Administrator@$ip
}
wait_remote_host_ip() {
while true; do
ip=$(sh_c aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' "Name=tag:Name,Values=$REMOTE_NAME" \
| jq -r '.Reservations[].Instances[].PublicIpAddress')
if [ -n "$ip" ]; then
alloc_static_ip
ip=$(sh_c aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' "Name=tag:Name,Values=$REMOTE_NAME" \
| jq -r '.Reservations[].Instances[].PublicIpAddress')
ssh-keygen -R "$ip"
break
fi
sleep 5
done
}
alloc_static_ip() {
allocation_id=$(aws ec2 describe-addresses --filters "Name=tag:Name,Values=$REMOTE_NAME" | jq -r '.Addresses[].AllocationId')
if [ -z "$allocation_id" ]; then
sh_c aws ec2 allocate-address --tag-specifications "'ResourceType=elastic-ip,Tags=[{Key=Name,Value=$REMOTE_NAME}]'"
allocation_id=$(aws ec2 describe-addresses --filters "Name=tag:Name,Values=$REMOTE_NAME" | jq -r '.Addresses[].AllocationId')
fi
instance_id=$(aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' "Name=tag:Name,Values=$REMOTE_NAME" \
| jq -r '.Reservations[].Instances[].InstanceId')
aws ec2 associate-address --instance-id "$instance_id" --allocation-id "$allocation_id"
}
init_remote_hosts() {
bigheader init_remote_hosts
JOBNAME=$JOBNAME/linux/amd64 runjob_filter REMOTE_HOST=$CI_D2_LINUX_AMD64 REMOTE_NAME=ci-d2-linux-amd64 init_remote_linux
JOBNAME=$JOBNAME/linux/arm64 runjob_filter REMOTE_HOST=$CI_D2_LINUX_ARM64 REMOTE_NAME=ci-d2-linux-arm64 init_remote_linux
JOBNAME=$JOBNAME/macos/amd64 runjob_filter REMOTE_HOST=$CI_D2_MACOS_AMD64 REMOTE_NAME=ci-d2-macos-amd64 init_remote_macos
JOBNAME=$JOBNAME/macos/arm64 runjob_filter REMOTE_HOST=$CI_D2_MACOS_ARM64 REMOTE_NAME=ci-d2-macos-arm64 init_remote_macos
JOBNAME=$JOBNAME/windows/amd64 runjob_filter REMOTE_HOST=$CI_D2_WINDOWS_AMD64 REMOTE_NAME=ci-d2-windows-amd64 init_remote_windows
# Windows and AWS SSM both defeated me.
FGCOLOR=3 bigheader "WARNING: WINDOWS INITIALIZATION MUST BE COMPLETED MANUALLY OVER RDP AND POWERSHELL!"
}
init_remote_linux() {
header "$REMOTE_NAME"
wait_remote_host
sh_c ssh_copy_id -i="$ID_PUB_PATH" "$REMOTE_HOST"
sh_c ssh "$REMOTE_HOST" sh -s -- <<EOF
set -eux
export DEBIAN_FRONTEND=noninteractive
sudo -E apt-get update -y
sudo -E apt-get dist-upgrade -y
sudo -E apt-get update -y
sudo -E apt-get install -y build-essential rsync
# Docker from https://docs.docker.com/engine/install/ubuntu/
sudo -E apt-get -y install \
ca-certificates \
curl \
gnupg \
lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --yes --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
\$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo -E apt-get update -y
sudo -E apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo groupadd docker || true
sudo usermod -aG docker \$USER
mkdir -p \$HOME/.local/bin
mkdir -p \$HOME/.local/share/man
EOF
init_remote_env
sh_c ssh "$REMOTE_HOST" sh -s -- <<EOF
set -eux
export DEBIAN_FRONTEND=noninteractive
sudo -E apt-get autoremove -y
EOF
sh_c ssh "$REMOTE_HOST" 'sudo reboot' || true
}
init_remote_macos() {
header "$REMOTE_NAME"
wait_remote_host
sh_c ssh_copy_id -i="$ID_PUB_PATH" "$REMOTE_HOST"
sh_c ssh "$REMOTE_HOST" '"/bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""'
if sh_c ssh "$REMOTE_HOST" uname -m | grep -qF arm64; then
shellenv=$(sh_c ssh "$REMOTE_HOST" /opt/homebrew/bin/brew shellenv)
else
shellenv=$(sh_c ssh "$REMOTE_HOST" /usr/local/bin/brew shellenv)
fi
if ! echo "$shellenv" | sh_c ssh "$REMOTE_HOST" "IFS= read -r regex\; \"grep -qF \\\"\\\$regex\\\" ~/.zshrc\""; then
echo "$shellenv" | sh_c ssh "$REMOTE_HOST" "\"(echo && cat) >> ~/.zshrc\""
fi
if ! sh_c ssh "$REMOTE_HOST" "'grep -qF \\\$HOME/.local ~/.zshrc'"; then
sh_c ssh "$REMOTE_HOST" "\"(echo && cat) >> ~/.zshrc\"" <<EOF
PATH=\$HOME/.local/bin:\$PATH
MANPATH=\$HOME/.local/share/man:\$MANPATH
EOF
fi
init_remote_env
sh_c ssh "$REMOTE_HOST" brew update
sh_c ssh "$REMOTE_HOST" brew upgrade
sh_c ssh "$REMOTE_HOST" brew install go rsync
sh_c ssh "$REMOTE_HOST" 'sudo reboot' || true
}
init_remote_env() {
sh_c ssh "$REMOTE_HOST" '"rm -f ~/.ssh/environment"'
sh_c ssh "$REMOTE_HOST" '"echo PATH=\$(echo \"echo \\\$PATH\" | \"\$SHELL\" -ils) >\$HOME/.ssh/environment"'
sh_c ssh "$REMOTE_HOST" '"echo MANPATH=\$(echo \"echo \\\$MANPATH\" | \"\$SHELL\" -ils) >>\$HOME/.ssh/environment"'
sh_c ssh "$REMOTE_HOST" "sudo sed -i.bak '\"s/#PermitUserEnvironment no/PermitUserEnvironment yes/\"' /etc/ssh/sshd_config"
if sh_c ssh "$REMOTE_HOST" uname | grep -qF Darwin; then
sh_c ssh "$REMOTE_HOST" "sudo launchctl stop com.openssh.sshd"
else
sh_c ssh "$REMOTE_HOST" "sudo systemctl restart sshd"
# ubuntu has $PATH hard coded in /etc/environment for some reason. It takes precedence
# over ~/.ssh/environment.
sh_c ssh "$REMOTE_HOST" "sudo rm /etc/environment"
fi
}
wait_remote_host() {
while true; do
if sh_c ssh "$REMOTE_HOST" true; then
break
fi
sleep 5
done
}
wait_remote_host_windows() {
instance_id=$(aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' "Name=tag:Name,Values=$REMOTE_NAME" \
| jq -r '.Reservations[].Instances[].InstanceId')
while true; do
if sh_c aws ssm start-session --target "$instance_id" \
--document-name 'AWS-StartNonInteractiveCommand' \
--parameters "'{\"command\": [\"echo true\"]}'"; then
break
fi
sleep 5
done
}
init_remote_windows() {
header "$REMOTE_NAME"
wait_remote_host_windows
init_ps1=$(cat <<EOF
\$ProgressPreference = 'SilentlyContinue'
# Bootstrap PowerShell v7
if ((\$PSVersionTable.PSVersion).Major -eq 5) {
Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.UI.Xaml/2.7.3 -OutFile .\microsoft.ui.xaml.2.7.3.zip
Expand-Archive -Force .\microsoft.ui.xaml.2.7.3.zip
Add-AppxPackage .\microsoft.ui.xaml.2.7.3\tools\AppX\x64\Release\Microsoft.UI.Xaml.2.7.appx
Invoke-WebRequest -Uri https://github.com/microsoft/winget-cli/releases/download/v1.3.2691/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle -OutFile .\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle
Invoke-WebRequest -Uri https://github.com/microsoft/winget-cli/releases/download/v1.3.2691/7bcb1a0ab33340daa57fa5b81faec616_License1.xml -OutFile .\7bcb1a0ab33340daa57fa5b81faec616_License1.xml
Invoke-WebRequest -Uri https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx -OutFile Microsoft.VCLibs.x64.14.00.Desktop.appx
Add-AppxProvisionedPackage -online -PackagePath .\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle -LicensePath .\7bcb1a0ab33340daa57fa5b81faec616_License1.xml -DependencyPackagePath Microsoft.VCLibs.x64.14.00.Desktop.appx
Add-AppxPackage .\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle
winget install --silent --accept-package-agreements --accept-source-agreements Microsoft.DotNet.SDK.7
# Refresh env.
\$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
dotnet tool install --global PowerShell --version 7.3.1
pwsh -c 'Enable-ExperimentalFeature PSNativeCommandErrorActionPreference'
pwsh .\Desktop\init.ps1
Exit
}
Set-StrictMode -Version Latest
\$ErrorActionPreference = "Stop"
\$PSNativeCommandUseErrorActionPreference = \$true
if (-Not (Get-Command wix -errorAction SilentlyContinue)) {
dotnet tool install --global wix --version 4.0.0-preview.1
}
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType 'Automatic'
New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\msys64\usr\bin\bash.exe" -PropertyType String -Force
ConvertFrom-Json -InputObject @'
$(perl -pe 's#\n#\r\n#' "$ID_PUB_PATH" | jq -Rs .)
'@ | Out-File -Encoding utf8 "\$env:ProgramData\ssh\administrators_authorized_keys"
# utf8BOM -> utf8: https://stackoverflow.com/a/34969243/4283659
\$null = New-Item -Force "\$env:ProgramData\ssh\administrators_authorized_keys" -Value (Get-Content -Path "\$env:ProgramData\ssh\administrators_authorized_keys" | Out-String)
get-acl "\$env:ProgramData\ssh\ssh_host_rsa_key" | set-acl "\$env:ProgramData\ssh\administrators_authorized_keys"
if (-Not (Test-Path -Path C:\msys64)) {
Invoke-WebRequest -Uri "https://github.com/msys2/msys2-installer/releases/download/2022-10-28/msys2-x86_64-20221028.exe" -OutFile "./msys2-x86_64.exe"
./msys2-x86_64.exe install --default-answer --confirm-command --root C:\msys64
}
C:\msys64\msys2_shell.cmd -defterm -here -no-start -mingw64 -c 'pacman -Sy --noconfirm base-devel vim rsync man'
C:\msys64\msys2_shell.cmd -defterm -here -no-start -mingw64 -c 'curl -fsSL https://d2lang.com/install.sh | sh -s -- --tala'
C:\msys64\msys2_shell.cmd -defterm -here -no-start -mingw64 -c 'd2 --version'
\$path = (Get-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name Path).Path
if (\$path -notlike '*C:\msys64\usr\bin*') {
\$path = "\$path;C:\msys64\usr\bin"
Set-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name Path -Value \$path
}
if (\$path -notlike '*C:\msys64\usr\local\bin*') {
\$path = "\$path;C:\msys64\usr\local\bin"
Set-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name Path -Value \$path
}
(Get-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name Path).Path
Restart-Computer
EOF
# To run a POSIX script:
# ssh "$CI_D2_WINDOWS_AMD64" sh -s -- <<EOF
# wix --version
# EOF
# To run a command in a pure MSYS2 shell:
# ssh "$CI_D2_WINDOWS_AMD64" 'C:\msys64\msys2_shell.cmd -defterm -here -no-start -mingw64 -c "\"d2 --version\""'
# To run a pure MSYS2 shell:
# ssh -t "$CI_D2_WINDOWS_AMD64" 'C:\msys64\msys2_shell.cmd -defterm -here -no-start -mingw64'
# In case MSYS2 improves in the future and allows for noninteractive commands the
# following will set the OpenSSH shell to MSYS2 instead of PowerShell.
#
# Right now, setting MSYS2 to the DefaultShell like this will make it start bash in
# interactive mode always. Even for ssh "$CI_D2_WINDOWS_AMD64" echo hi. And so you'll end
# up with a blank prompt on which to input commands instead of having it execute the
# command you passed in via ssh.
#
# PowerShell as the default is better anyway as it gives us access to both the UNIX
# userspace and Windows tools like wix/dotnet/winget.
#
# To set:
# <<EOF
# echo '@C:\msys64\msys2_shell.cmd -defterm -here -no-start -mingw64' | Out-File C:\msys64\sshd_default_shell.cmd
# # utf8BOM -> utf8: https://stackoverflow.com/a/34969243/4283659
# \$null = New-Item -Force C:\msys64\sshd_default_shell.cmd -Value (Get-Content -Path C:\msys64\sshd_default_shell.cmd | Out-String)
# Set-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -Value C:\msys64\sshd_default_shell.cmd
# EOF
#
# To undo:
# <<EOF
# Remove-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell
# rm C:\msys64\sshd_default_shell.cmd
# EOF
)
gen_init_ps1=$(cat <<EOF
ConvertFrom-Json -InputObject @'
$(printf %s "$init_ps1" | perl -pe 'chomp if eof' | perl -pe 's#\n#\r\n#' | jq -Rs .)
'@ | Out-File -Encoding utf8 C:\Users\Administrator\Desktop\init.ps1; C:\Users\Administrator\Desktop\init.ps1
EOF
)
# Windows and AWS SSM both defeated me.
FGCOLOR=3 bigheader "WARNING: WINDOWS INITIALIZATION MUST BE COMPLETED MANUALLY OVER RDP AND POWERSHELL!"
warn '1. Obtain Windows RDP password with:'
echo " aws ec2 get-password-data --instance-id \$(aws ec2 describe-instances --filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' "Name=tag:Name,Values=$REMOTE_NAME" | jq -r '.Reservations[].Instances[].InstanceId') --priv-launch-key windows.pem | jq -r .PasswordData" >&2
warn "2. RDP into $REMOTE_HOST and open PowerShell."
warn '3. Generate and execute C:\Users\Administrator\Desktop\init.ps1 with:'
printf '%s\n' "$gen_init_ps1" >&2
warn '4. Run the following to be notified once installation is successful:'
cat <<EOF
until ssh $REMOTE_HOST d2 --version; do echo 'failed: retrying in 5s' && sleep 5; done && printf 'success\a\n'
EOF
}
main "$@"

View file

@ -1,7 +1,7 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../../../ci/sub/lib.sh"
cd -- "$(dirname "$0")/../../.."
. ./ci/sub/lib.sh
help() {
cat <<EOF
@ -33,10 +33,11 @@ main() {
done
shift "$FLAGSHIFT"
REMOTE_HOST=$TSTRUCT_LINUX_AMD64_BUILDER; runjob linux-amd64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$TSTRUCT_LINUX_ARM64_BUILDER; runjob linux-arm64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$TSTRUCT_MACOS_AMD64_BUILDER; runjob macos-amd64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$TSTRUCT_MACOS_ARM64_BUILDER; runjob macos-arm64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$CI_HOST_D2_LINUX_AMD64 && runjob linux-amd64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$CI_HOST_D2_LINUX_ARM64 && runjob linux-arm64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$CI_HOST_D2_MACOS_AMD64 && runjob macos-amd64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$CI_HOST_D2_MACOS_ARM64 && runjob macos-arm64 ssh "$REMOTE_HOST" "$@"
REMOTE_HOST=$CI_HOST_D2_WINDOWS_AMD64 && runjob macos-arm64 ssh "$REMOTE_HOST" "$@"
}
main "$@"

View file

@ -1,7 +1,12 @@
#!/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"
cd -- "$(dirname "$0")/../.."
. ./ci/sub/lib.sh
help() {
cat <<EOF
@ -20,11 +25,12 @@ Flags:
changed something and need to force rebuild, use this flag.
--local
By default build.sh uses \$TSTRUCT_MACOS_AMD64_BUILDER, \$TSTRUCT_MACOS_ARM64_BUILDER,
\$TSTRUCT_LINUX_AMD64_BUILDER and \$TSTRUCT_LINUX_ARM64_BUILDER to build the release
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!
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
@ -45,6 +51,13 @@ Flags:
--uninstall
Ensure a release using --host-only and uninstall it.
--push-docker
Push the built docker image. Unfortunately dockerx requires the multi-arch images be
pushed if required in the same invocation as build. dockerx cannot load multi-arch
images into the daemon for push later. It's not slow though to use --push-docker after
building the image as nearly all artifacts are cached.
Automatically set if called from release.sh
EOF
}
@ -69,7 +82,7 @@ main() {
;;
run)
flag_reqarg && shift "$FLAGSHIFT"
JOBFILTER="$FLAGARG"
JOBFILTER=$FLAGARG
;;
host-only)
flag_noarg && shift "$FLAGSHIFT"
@ -96,6 +109,10 @@ main() {
HOST_ONLY=1
LOCAL=1
;;
push-docker)
flag_noarg && shift "$FLAGSHIFT"
PUSH_DOCKER=1
;;
*)
flag_errusage "unrecognized flag $FLAGRAW"
;;
@ -108,10 +125,13 @@ main() {
VERSION=${VERSION:-$(git_describe_ref)}
BUILD_DIR=ci/release/build/$VERSION
sh_c mkdir -p "$BUILD_DIR"
sh_c rm -f ci/release/build/latest
sh_c ln -s "$VERSION" ci/release/build/latest
if [ -n "${HOST_ONLY-}" ]; then
ensure_os
ensure_arch
runjob "$OS-$ARCH" "build"
runjob "$OS/$ARCH" "build"
if [ -n "${INSTALL-}" ]; then
sh_c make -sC "ci/release/build/$VERSION/$OS-$ARCH/d2-$VERSION" install
@ -121,12 +141,16 @@ main() {
return 0
fi
runjob linux-amd64 'OS=linux ARCH=amd64 build' &
runjob linux-arm64 'OS=linux ARCH=arm64 build' &
runjob macos-amd64 'OS=macos ARCH=amd64 build' &
runjob macos-arm64 'OS=macos ARCH=arm64 build' &
runjob windows-amd64 'OS=windows ARCH=amd64 build' &
runjob windows-arm64 'OS=windows ARCH=arm64 build' &
runjob linux/amd64 'OS=linux ARCH=amd64 build' &
runjob linux/arm64 'OS=linux ARCH=arm64 build' &
runjob macos/amd64 'OS=macos ARCH=amd64 build' &
runjob macos/arm64 'OS=macos ARCH=arm64 build' &
runjob windows/amd64 'OS=windows ARCH=amd64 build' &
runjob windows/arm64 'OS=windows ARCH=arm64 build' &
waitjobs
runjob linux/dockerimage 'OS=linux build_docker_image' &
runjob windows/amd64/msi 'OS=windows ARCH=amd64 build_windows_msi' &
waitjobs
}
@ -148,10 +172,10 @@ build() {
macos)
case $ARCH in
amd64)
REMOTE_HOST=$TSTRUCT_MACOS_AMD64_BUILDER build_remote_macos
REMOTE_HOST=$CI_D2_MACOS_AMD64 build_remote_macos
;;
arm64)
REMOTE_HOST=$TSTRUCT_MACOS_ARM64_BUILDER build_remote_macos
REMOTE_HOST=$CI_D2_MACOS_ARM64 build_remote_macos
;;
*)
warn "no builder for OS=$OS ARCH=$ARCH, building locally..."
@ -162,10 +186,10 @@ build() {
linux)
case $ARCH in
amd64)
REMOTE_HOST=$TSTRUCT_LINUX_AMD64_BUILDER build_remote_linux
REMOTE_HOST=$CI_D2_LINUX_AMD64 build_remote_linux
;;
arm64)
REMOTE_HOST=$TSTRUCT_LINUX_ARM64_BUILDER build_remote_linux
REMOTE_HOST=$CI_D2_LINUX_ARM64 build_remote_linux
;;
*)
warn "no builder for OS=$OS ARCH=$ARCH, building locally..."
@ -190,7 +214,7 @@ build_local() {
sh_c ./ci/release/_build.sh
}
build_remote_macos() {
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-} \
@ -205,9 +229,9 @@ PATH=\\\"/usr/local/bin:/usr/local/sbin:/opt/homebrew/bin:/opt/homebrew/sbin\\\$
./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() {
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-} \
@ -218,13 +242,39 @@ VERSION=$VERSION \
OS=$OS \
ARCH=$ARCH \
ARCHIVE=$ARCHIVE \
./src/d2/ci/release/build_docker.sh"
./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_image() {
D2_DOCKER_IMAGE=${D2_DOCKER_IMAGE:-terrastruct/d2}
flags='--load'
if [ -n "${PUSH_DOCKER-}" -o -n "${RELEASE-}" ]; then
flags='--push --platform linux/amd64,linux/arm64'
fi
sh_c docker buildx build $flags -t "$D2_DOCKER_IMAGE:$VERSION" -t "$D2_DOCKER_IMAGE:latest" --build-arg "VERSION=$VERSION" -f ./ci/release/Dockerfile "./ci/release/build/$VERSION"
}
ssh() {
command ssh -o='StrictHostKeyChecking=accept-new' "$@"
build_windows_msi() {
REMOTE_HOST=$CI_D2_WINDOWS_AMD64
ln -sf "../build/$VERSION/windows-amd64/d2-$VERSION/bin/d2.exe" ./ci/release/windows/d2.exe
sh_c rsync --archive --human-readable --copy-links --delete ./ci/release/windows/ "'$REMOTE_HOST:windows/'"
if ! echo "$VERSION" | grep '[0-9]\.[0-9]\.[0-9]'; then
WIX_VERSION=0.0.0
else
WIX_VERSION=$VERSION
fi
sh_c ssh "$REMOTE_HOST" "'cd ./windows && wix build -arch x64 -d D2Version=$WIX_VERSION ./d2.wxs'"
# --files-from shouldn't be necessary but for some reason selecting d2.msi directly
# makes rsync error with:
# ERROR: rejecting unrequested file-list name: ./windows/d2.msi
# rsync error: requested action not supported (code 4) at flist.c(1027) [Receiver=3.2.7]
rsync_files=$(mktempd)/rsync-files
echo d2.msi >$rsync_files
sh_c rsync --archive --human-readable --files-from "$rsync_files" "'$REMOTE_HOST:windows/'" "./ci/release/build/$VERSION/d2-$VERSION-$OS-$ARCH.msi"
}
main "$@"

View file

@ -0,0 +1 @@
**/d2*/

View file

@ -5,7 +5,7 @@ cd -- "$(dirname "$0")/../.."
tag="$(sh_c docker build \
--build-arg GOVERSION="1.19.3.linux-$ARCH" \
-qf ./ci/release/builders/Dockerfile ./ci/release/builders)"
-qf ./ci/release/linux/Dockerfile ./ci/release/linux)"
docker_run \
-e DRY_RUN \
-e HW_BUILD_DIR \

View file

@ -1,16 +0,0 @@
FROM centos:7
ARG GOVERSION=
RUN curl -fsSL "https://go.dev/dl/go$GOVERSION.tar.gz" >/tmp/go.tar.gz
RUN tar -C /usr/local -xzf /tmp/go.tar.gz
ENV PATH="/usr/local/go/bin:$PATH"
RUN yum install -y rsync wget
RUN yum groupinstall -y 'Development Tools'
RUN curl -fsSL https://ftp.gnu.org/gnu/gcc/gcc-5.2.0/gcc-5.2.0.tar.gz >/tmp/gcc.tar.gz
RUN tar -C /usr/local -xzf /tmp/gcc.tar.gz
RUN cd /usr/local/gcc-5.2.0 && ./contrib/download_prerequisites && mkdir -p build \
&& cd build && ../configure --disable-multilib && make && make install

View file

@ -1,57 +0,0 @@
#!/bin/sh
set -eu
cd -- "$(dirname "$0")/../../.."
. ./ci/sub/lib.sh
help() {
cat <<EOF
usage: $0 [--dry-run] -i keys.pub
$0 copies keys.pub to each builder and then deduplicates its .authorized_keys.
EOF
}
main() {
while flag_parse "$@"; do
case "$FLAG" in
h|help)
help
return 0
;;
dry-run)
flag_noarg && shift "$FLAGSHIFT"
DRY_RUN=1
;;
i)
flag_nonemptyarg && shift "$FLAGSHIFT"
KEY_FILE=$FLAGARG
;;
*)
flag_errusage "unrecognized flag $FLAGRAW"
;;
esac
done
shift "$FLAGSHIFT"
if [ -z "${KEY_FILE-}" ]; then
echoerr "-i is required"
exit 1
fi
header linux-amd64
REMOTE_HOST=$TSTRUCT_LINUX_AMD64_BUILDER copy_keys
header linux-arm64
REMOTE_HOST=$TSTRUCT_LINUX_ARM64_BUILDER copy_keys
header macos-amd64
REMOTE_HOST=$TSTRUCT_MACOS_AMD64_BUILDER copy_keys
header macos-arm64
REMOTE_HOST=$TSTRUCT_MACOS_ARM64_BUILDER copy_keys
}
copy_keys() {
sh_c ssh-copy-id -fi "$KEY_FILE" "$REMOTE_HOST"
sh_c ssh "$REMOTE_HOST" 'cat .ssh/authorized_keys \| sort -u \> .ssh/authorized_keys.dedup'
sh_c ssh "$REMOTE_HOST" 'cp .ssh/authorized_keys.dedup .ssh/authorized_keys'
sh_c ssh "$REMOTE_HOST" 'rm .ssh/authorized_keys.dedup'
}
main "$@"

View file

@ -1,294 +0,0 @@
#!/bin/sh
set -eu
cd -- "$(dirname "$0")/../../.."
. ./ci/sub/lib.sh
help() {
cat <<EOF
usage: $0 [--dry-run] [--skip-create]
$0 creates and ensures the d2 builders in AWS.
EOF
}
main() {
while flag_parse "$@"; do
case "$FLAG" in
h|help)
help
return 0
;;
dry-run)
flag_noarg && shift "$FLAGSHIFT"
DRY_RUN=1
;;
skip-create)
flag_noarg && shift "$FLAGSHIFT"
SKIP_CREATE=1
;;
*)
flag_errusage "unrecognized flag $FLAGRAW"
;;
esac
done
shift "$FLAGSHIFT"
if [ $# -gt 0 ]; then
flag_errusage "no arguments are accepted"
fi
if [ -z "${SKIP_CREATE-}" ]; then
create_remote_hosts
fi
init_remote_hosts
}
create_remote_hosts() {
KEY_NAME=$(aws ec2 describe-key-pairs | jq -r .KeyPairs[0].KeyName)
VPC_ID=$(aws ec2 describe-vpcs | jq -r .Vpcs[0].VpcId)
header security-group
SG_ID=$(aws ec2 describe-security-groups --group-names ssh 2>/dev/null \
| jq -r .SecurityGroups[0].GroupId)
if [ -z "$SG_ID" ]; then
SG_ID=$(sh_c aws ec2 create-security-group \
--group-name ssh \
--description ssh \
--vpc-id "$VPC_ID" | jq -r .GroupId)
fi
header security-group-ingress
SG_RULES_COUNT=$(aws ec2 describe-security-groups --group-names ssh \
| jq -r '.SecurityGroups[0].IpPermissions | length')
if [ "$SG_RULES_COUNT" -eq 0 ]; then
sh_c aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0 >/dev/null
fi
header linux-amd64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-linux-amd64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-071e6cafc48327ca2 \
--count=1 \
--instance-type=t2.small \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=d2-builder-linux-amd64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=d2-builder-linux-amd64}]"' >/dev/null
fi
while true; do
dnsname=$(sh_c aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-linux-amd64' \
| jq -r '.Reservations[].Instances[].PublicDnsName')
if [ -n "$dnsname" ]; then
log "TSTRUCT_LINUX_AMD64_BUILDER=admin@$dnsname"
export TSTRUCT_LINUX_AMD64_BUILDER=admin@$dnsname
break
fi
sleep 5
done
header linux-arm64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-linux-arm64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0e67506f183e5ab60 \
--count=1 \
--instance-type=t4g.small \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=d2-builder-linux-arm64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=d2-builder-linux-arm64}]"' >/dev/null
fi
while true; do
dnsname=$(sh_c aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-linux-arm64' \
| jq -r '.Reservations[].Instances[].PublicDnsName')
if [ -n "$dnsname" ]; then
log "TSTRUCT_LINUX_ARM64_BUILDER=admin@$dnsname"
export TSTRUCT_LINUX_ARM64_BUILDER=admin@$dnsname
break
fi
sleep 5
done
header "macos-amd64-host"
MACOS_AMD64_HOST_ID=$(aws ec2 describe-hosts --filter 'Name=state,Values=pending,available' 'Name=tag:Name,Values=d2-builder-macos-amd64' | jq -r '.Hosts[].HostId')
if [ -z "$MACOS_AMD64_HOST_ID" ]; then
MACOS_AMD64_HOST_ID=$(sh_c aws ec2 allocate-hosts --instance-type mac1.metal --quantity 1 --availability-zone us-west-2a \
--tag-specifications '"ResourceType=dedicated-host,Tags=[{Key=Name,Value=d2-builder-macos-amd64}]"' \
| jq -r .HostIds[0])
fi
header "macos-arm64-host"
MACOS_ARM64_HOST_ID=$(aws ec2 describe-hosts --filter 'Name=state,Values=pending,available' 'Name=tag:Name,Values=d2-builder-macos-arm64' | jq -r '.Hosts[].HostId')
if [ -z "$MACOS_ARM64_HOST_ID" ]; then
MACOS_ARM64_HOST_ID=$(sh_c aws ec2 allocate-hosts --instance-type mac2.metal --quantity 1 --availability-zone us-west-2a \
--tag-specifications '"ResourceType=dedicated-host,Tags=[{Key=Name,Value=d2-builder-macos-amd64}]"' \
| jq -r .HostIds[0])
fi
header macos-amd64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-macos-amd64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0dd2ded7568750663 \
--count=1 \
--instance-type=mac1.metal \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--placement "Tenancy=host,HostId=$MACOS_AMD64_HOST_ID" \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=d2-builder-macos-amd64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=d2-builder-macos-amd64}]"' >/dev/null
fi
while true; do
dnsname=$(sh_c aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-macos-amd64' \
| jq -r '.Reservations[].Instances[].PublicDnsName')
if [ -n "$dnsname" ]; then
log "TSTRUCT_MACOS_AMD64_BUILDER=ec2-user@$dnsname"
export TSTRUCT_MACOS_AMD64_BUILDER=ec2-user@$dnsname
break
fi
sleep 5
done
header macos-arm64
state=$(aws ec2 describe-instances --filters \
'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-macos-arm64' \
| jq -r '.Reservations[].Instances[].State.Name')
if [ -z "$state" ]; then
sh_c aws ec2 run-instances \
--image-id=ami-0af0516ff2c43dbbe \
--count=1 \
--instance-type=mac2.metal \
--security-groups=ssh \
"--key-name=$KEY_NAME" \
--placement "Tenancy=host,HostId=$MACOS_ARM64_HOST_ID" \
--tag-specifications '"ResourceType=instance,Tags=[{Key=Name,Value=d2-builder-macos-arm64}]"' \
'"ResourceType=volume,Tags=[{Key=Name,Value=d2-builder-macos-arm64}]"' >/dev/null
fi
while true; do
dnsname=$(sh_c aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=pending,running,stopping,stopped' 'Name=tag:Name,Values=d2-builder-macos-arm64' \
| jq -r '.Reservations[].Instances[].PublicDnsName')
if [ -n "$dnsname" ]; then
log "TSTRUCT_MACOS_ARM64_BUILDER=ec2-user@$dnsname"
export TSTRUCT_MACOS_ARM64_BUILDER=ec2-user@$dnsname
break
fi
sleep 5
done
}
init_remote_hosts() {
header linux-amd64
REMOTE_HOST=$TSTRUCT_LINUX_AMD64_BUILDER init_remote_linux
header linux-arm64
REMOTE_HOST=$TSTRUCT_LINUX_ARM64_BUILDER init_remote_linux
header macos-amd64
REMOTE_HOST=$TSTRUCT_MACOS_AMD64_BUILDER init_remote_macos
header macos-arm64
REMOTE_HOST=$TSTRUCT_MACOS_ARM64_BUILDER init_remote_macos
FGCOLOR=2 header summary
log "export TSTRUCT_LINUX_AMD64_BUILDER=$TSTRUCT_LINUX_AMD64_BUILDER"
log "export TSTRUCT_LINUX_ARM64_BUILDER=$TSTRUCT_LINUX_ARM64_BUILDER"
log "export TSTRUCT_MACOS_AMD64_BUILDER=$TSTRUCT_MACOS_AMD64_BUILDER"
log "export TSTRUCT_MACOS_ARM64_BUILDER=$TSTRUCT_MACOS_ARM64_BUILDER"
}
init_remote_linux() {
wait_remote_host
sh_c ssh "$REMOTE_HOST" sh -s -- <<EOF
set -eux
export DEBIAN_FRONTEND=noninteractive
sudo -E apt-get update -y
sudo -E apt-get dist-upgrade -y
sudo -E apt-get install -y build-essential rsync
# Docker from https://docs.docker.com/engine/install/debian/
sudo -E apt-get -y install \
ca-certificates \
curl \
gnupg \
lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
\$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo -E apt-get update -y
sudo -E apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo groupadd docker || true
sudo usermod -aG docker \$USER
mkdir -p \$HOME/.local/bin
mkdir -p \$HOME/.local/share/man
EOF
init_remote_env
sh_c ssh "$REMOTE_HOST" 'sudo reboot' || true
}
init_remote_macos() {
wait_remote_host
sh_c ssh "$REMOTE_HOST" '"/bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""'
if sh_c ssh "$REMOTE_HOST" uname -m | grep -qF arm64; then
shellenv=$(sh_c ssh "$REMOTE_HOST" /opt/homebrew/bin/brew shellenv)
else
shellenv=$(sh_c ssh "$REMOTE_HOST" /usr/local/bin/brew shellenv)
fi
if ! echo "$shellenv" | sh_c ssh "$REMOTE_HOST" "IFS= read -r regex\; \"grep -qF \\\"\\\$regex\\\" ~/.zshrc\""; then
echo "$shellenv" | sh_c ssh "$REMOTE_HOST" "\"(echo && cat) >> ~/.zshrc\""
fi
if ! sh_c ssh "$REMOTE_HOST" "'grep -qF \\\$HOME/.local ~/.zshrc'"; then
sh_c ssh "$REMOTE_HOST" "\"(echo && cat) >> ~/.zshrc\"" <<EOF
PATH=\$HOME/.local/bin:\$PATH
MANPATH=\$HOME/.local/share/man:\$MANPATH
EOF
fi
init_remote_env
sh_c ssh "$REMOTE_HOST" brew update
sh_c ssh "$REMOTE_HOST" brew upgrade
sh_c ssh "$REMOTE_HOST" brew install go rsync
}
init_remote_env() {
sh_c ssh "$REMOTE_HOST" '"rm -f ~/.ssh/environment"'
sh_c ssh "$REMOTE_HOST" '"echo PATH=\$(echo \"echo \\\$PATH\" | \"\$SHELL\" -ils) >\$HOME/.ssh/environment"'
sh_c ssh "$REMOTE_HOST" '"echo MANPATH=\$(echo \"echo \\\$MANPATH\" | \"\$SHELL\" -ils) >>\$HOME/.ssh/environment"'
sh_c ssh "$REMOTE_HOST" "sudo sed -i.bak '\"s/#PermitUserEnvironment no/PermitUserEnvironment yes/\"' /etc/ssh/sshd_config"
if sh_c ssh "$REMOTE_HOST" uname | grep -qF Darwin; then
sh_c ssh "$REMOTE_HOST" "sudo launchctl stop com.openssh.sshd"
else
sh_c ssh "$REMOTE_HOST" "sudo systemctl restart sshd"
fi
}
wait_remote_host() {
while true; do
if sh_c ssh "$REMOTE_HOST" true; then
break
fi
sleep 5
done
}
main "$@"

View file

@ -1,7 +1,16 @@
#### Features 🚀
- Tooltips can be set on shapes. See [https://d2lang.com/tour/tooltips](https://d2lang.com/tour/interactive). [#548](https://github.com/terrastruct/d2/pull/548)
- Links can be set on shapes. See [https://d2lang.com/tour/tooltips](https://d2lang.com/tour/interactive). [#548](https://github.com/terrastruct/d2/pull/548)
- The `width` and `height` attributes are no longer restricted to images and can be applied to non-container shapes. [#498](https://github.com/terrastruct/d2/pull/498)
#### Improvements 🧹
- Watch mode now renders fit to screen. [#560](https://github.com/terrastruct/d2/pull/560)
#### Bugfixes ⛑️
- Fixed sequence diagram span size for self-edges [#397](https://github.com/terrastruct/d2/pull/397)
- Restricts where `near` key constant values can be used, with good error messages, instead of erroring (e.g. setting `near: top-center` on a container would cause bad layouts or error). [#538](https://github.com/terrastruct/d2/pull/538)
- Fixes an error during ELK layout when images had empty labels. [#555](https://github.com/terrastruct/d2/pull/555)
- Fixes rendering classes and tables with empty headers. [#498](https://github.com/terrastruct/d2/pull/498)
- Fixes rendering sql tables with no columns. [#553](https://github.com/terrastruct/d2/pull/553)

View file

@ -0,0 +1,7 @@
#### Improvements 🧹
- The Windows release binary is now suffixed correctly with `.exe` [#388](https://github.com/terrastruct/d2/issues/388)
#### Bugfixes ⛑️
- Fixed sequence diagram span size for self-edges [#397](https://github.com/terrastruct/d2/pull/397)

View file

@ -0,0 +1,25 @@
D2 now has an official playground site: [https://play.d2lang.com](https://play.d2lang.com). It loads and runs fast, works on all the major browsers and has been tested on desktop and mobile on a variety of devices. It's the easiest way to get started with D2 and share diagrams. The playground is all open source ([https://github.com/terrastruct/d2-playground](https://github.com/terrastruct/d2-playground)). We'd love to hear your feedback and feature requests.
Windows users, the install experience just got a whole lot better. Making D2 accessible and easy to use continues to be a priority for us. With this release, we added an MSI installer for Windows, so that installs are just a few clicks. An official Docker image has also been added.
#### Features 🚀
- Diagram padding can be configured in the CLI (default 100px). [https://github.com/terrastruct/d2/pull/431](https://github.com/terrastruct/d2/pull/431)
- Connection label backgrounds can be set with the `style.fill` keyword. [https://github.com/terrastruct/d2/pull/452](https://github.com/terrastruct/d2/pull/452)
- Adds official Docker image. See [./docs/INSTALL.md#docker](./docs/INSTALL.md#docker). [#76](https://github.com/terrastruct/d2/issues/76)
- Adds `.msi` installer for convenient installation on Windows. [#379](https://github.com/terrastruct/d2/issues/379)
#### Improvements 🧹
- `d2 fmt` preserves leading comment spacing. [#400](https://github.com/terrastruct/d2/issues/400)
- `stroke` and `fill` keywords work for Markdown text. [https://github.com/terrastruct/d2/pull/460](https://github.com/terrastruct/d2/pull/460)
- PNG export resolution increased by 2x to not be blurry exporting on retina displays. [https://github.com/terrastruct/d2/pull/445](https://github.com/terrastruct/d2/pull/445)
#### Bugfixes ⛑️
- Fixes crash when sequence diagrams has no messages. [https://github.com/terrastruct/d2/pull/427](https://github.com/terrastruct/d2/pull/427)
- Fixes `constraint` keyword setting label. [https://github.com/terrastruct/d2/issues/415](https://github.com/terrastruct/d2/issues/415)
- Fixes serialization affecting binary plugins (TALA). [https://github.com/terrastruct/d2/pull/426](https://github.com/terrastruct/d2/pull/426)
- Fixes connections in ELK layouts not going all the way to shape borders. [https://github.com/terrastruct/d2/pull/459](https://github.com/terrastruct/d2/pull/459)
- Fixes a connection rendering bug that could happen in Firefox when there were no connection labels. [https://github.com/terrastruct/d2/pull/453](https://github.com/terrastruct/d2/pull/453)
- Fixes a crash when external connection IDs were prefixes of a sequence diagram ID. [https://github.com/terrastruct/d2/pull/462](https://github.com/terrastruct/d2/pull/462)

View file

@ -0,0 +1,34 @@
Many have asked how to get the diagram to look like the one on D2's [cheat sheet](https://d2lang.com/tour/cheat-sheet). With this release, now you can! See [https://d2lang.com/tour/themes](https://d2lang.com/tour/themes) for more.
![sketch](https://user-images.githubusercontent.com/3120367/209235066-d8ad6b3c-d19b-491d-b014-407f3c47407f.png)
The Slack app for D2 has now hit production, so if you're looking for the quickest way to express a visual model without interrupting the conversation flow, go to [http://d2lang.com/tour/slack](http://d2lang.com/tour/slack) to install.
Hope everyone is enjoying the holidays this week!
#### Features 🚀
- `sketch` flag renders the diagram to look like it was sketched by hand. [#492](https://github.com/terrastruct/d2/pull/492)
- `near` now takes constants like `top-center`, particularly useful for diagram titles. See [docs](https://d2lang.com/tour/text#near-a-constant) for more. [#525](https://github.com/terrastruct/d2/pull/525)
#### Improvements 🧹
- Improved label placements for shapes with images and icons to avoid overlapping labels. [#474](https://github.com/terrastruct/d2/pull/474)
- Themes are applied to `sql_table` and `class` shapes. [#521](https://github.com/terrastruct/d2/pull/521)
- `class` shapes use monospaced font. [#521](https://github.com/terrastruct/d2/pull/521)
- Sequence diagram edge group labels have more reasonable padding. [#512](https://github.com/terrastruct/d2/pull/512)
- ELK layout engine preserves order of nodes. [#282](https://github.com/terrastruct/d2/issues/282)
- Non-markdown text (`shape: text` without language block) works with `bold`, `italic`, `underline`, and `font-size`. [#528](https://github.com/terrastruct/d2/pull/528)
- Markdown headings set font-family explicitly, so that external stylesheets with more specific targeting don't override it. [#525](https://github.com/terrastruct/d2/pull/525)
#### Bugfixes ⛑️
- `d2 fmt` only rewrites if it has changes, instead of always rewriting. [#470](https://github.com/terrastruct/d2/pull/470)
- Text no longer overflows in `sql_table` shapes. [#458](https://github.com/terrastruct/d2/pull/458)
- ELK connection labels are now given the appropriate dimensions. [#483](https://github.com/terrastruct/d2/pull/483)
- Dagre connection lengths make room for longer labels. [#484](https://github.com/terrastruct/d2/pull/484)
- Icons with query parameters are escaped to valid SVG XML. [#438](https://github.com/terrastruct/d2/issues/438)
- Connections at the boundaries no longer get part of its stroke clipped. [#493](https://github.com/terrastruct/d2/pull/493)
- Fixes edge case where `style` being defined in same scope as `sql_table` causes compiler to skip compiling `sql_table`. [#506](https://github.com/terrastruct/d2/issues/506)
- Fixes panic passing a non-string value to `constraint`. [#248](https://github.com/terrastruct/d2/issues/248)
- Fixes edge case where the key `null` was compiling wrongly. [#507](https://github.com/terrastruct/d2/issues/507)

View file

@ -8,5 +8,5 @@ RUN curl -fsSL "https://go.dev/dl/go$GOVERSION.tar.gz" >/tmp/go.tar.gz
RUN tar -C /usr/local -xzf /tmp/go.tar.gz
ENV PATH="/usr/local/go/bin:$PATH"
RUN apt-get install -y build-essential
RUN apt-get install -y rsync
RUN apt-get install -y build-essential \
rsync

View file

@ -1,12 +1,35 @@
#!/bin/sh
set -eu
cd -- "$(dirname "$0")/../../.."
. ./ci/sub/lib.sh
ensure_os
ensure_arch
cat <<EOF
# d2
For docs, more installation options and the source code see https://oss.terrastruct.com/d2
version: $VERSION
os: $OS
arch: $ARCH
Built with $(go version | grep -o 'go[^ ]\+').
EOF
if [ "$OS" = windows ]; then
cat <<EOF
This release is structured the same as our Unix releases for use with MSYS2.
You may find our \`.msi\` installer more convenient as it handles putting \`d2.exe\` into
your \`\$PATH\` for you.
See https://github.com/terrastruct/d2/blob/master/docs/INSTALL.md#windows
EOF
fi
cat <<EOF
## Install

View file

@ -58,6 +58,12 @@ Port listening address when used with
Set the diagram theme to the passed integer. For a list of available options, see
.Lk https://oss.terrastruct.com/d2
.Ns .
.It Fl s , -sketch Ar false
Renders the diagram to look like it was sketched by hand
.Ns .
.It Fl -pad Ar 100
Pixels padded around the rendered diagram
.Ns .
.It Fl l , -layout Ar dagre
Set the diagram layout engine to the passed string. For a list of available options, run
.Ar layout

View file

@ -9,7 +9,7 @@ main() {
ensure_os
if [ "$OS" = windows ]; then
"$sh_c" install ./bin/d2 "$PREFIX/bin/d2.exe"
"$sh_c" install ./bin/d2.exe "$PREFIX/bin/d2.exe"
else
"$sh_c" install ./bin/d2 "$PREFIX/bin/d2"
fi

View file

@ -50,20 +50,16 @@ ensure_tmpdir() {
if [ -n "${_TMPDIR-}" ]; then
return
fi
_TMPDIR=$(mktemp -d)
export _TMPDIR
trap temp_exittrap EXIT
}
temp_exittrap() {
if [ -n "${_TMPDIR-}" ]; then
rm -r "$_TMPDIR"
fi
}
if [ -z "${_TMPDIR-}" ]; then
trap 'rm -Rf "$_TMPDIR"' EXIT
fi
ensure_tmpdir
temppath() {
ensure_tmpdir
while true; do
temppath=$_TMPDIR/$(</dev/urandom od -N8 -tx -An -v | tr -d '[:space:]')
if [ ! -e "$temppath" ]; then
@ -121,10 +117,14 @@ should_color() {
}
setaf() {
tput setaf "$1"
fg=$1
shift
printf '%s' "$*"
tput sgr0
printf '%s\n' "$*" | while IFS= read -r line; do
tput setaf "$fg"
printf '%s' "$line"
tput sgr0
printf '\n'
done
}
_echo() {
@ -273,7 +273,7 @@ header() {
bigheader() {
set -- "$(echo "$*" | sed "s/^/ * /")"
FGCOLOR=${FGCOLOR:-3} logp "/****************************************************************
FGCOLOR=${FGCOLOR:-6} logp "/****************************************************************
$*
****************************************************************/"
}
@ -298,6 +298,16 @@ hide() {
return "$code"
}
hide_stderr() {
out="$(mktempd)/hideout"
capcode "$@" 2>"$out"
if [ "$code" -eq 0 ]; then
return
fi
cat "$out" >&2
return "$code"
}
echo_dur() {
local dur=$1
local h=$((dur/60/60))

1
ci/release/windows/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
d2.exe

BIN
ci/release/windows/d2.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
ci/release/windows/d2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

39
ci/release/windows/d2.wxs Normal file
View file

@ -0,0 +1,39 @@
<!--
Wix documentation is nonexistent for v4. What exists is largely out of date and inconsistent.
This file was pieced together from:
1. https://www.firegiant.com/wix/tutorial/getting-started/
- This is for v3, I used wix convert to convert to v4
2. https://wixtoolset.org/docs/reference/schema/wxs/
3. Googling with trial and error
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="D2" UpgradeCode="ac84fee7-eb67-4f5d-a08d-adef69538690" Language="1033" Codepage="utf-8" Version="$(var.D2Version)" Manufacturer="Terrastruct, Inc." InstallerVersion="200">
<SummaryInformation Keywords="Installer" Description="The D2 Installer" Manufacturer="Terrastruct, Inc." Codepage="1252" />
<Icon Id="d2.ico" SourceFile="d2.ico" />
<Property Id="ARPPRODUCTICON" Value="d2.ico" />
<Media Id="1" Cabinet="D2.cab" EmbedCab="yes" />
<Feature Id="Complete" Level="1">
<ComponentRef Id="Executable" />
</Feature>
<MajorUpgrade AllowSameVersionUpgrades='yes' DowngradeErrorMessage="A later version of [ProductName] is already installed. Setup will now exit."/>
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLDIR" Name="D2">
<Component Id="Executable" Guid="1090d036-c985-461f-94f6-3121dbcfcb48">
<File Id="D2EXE" Name="d2.exe" Source="d2.exe" KeyPath="yes" />
<Environment
Id="D2PathEntry"
Action="set"
Part="last"
Name="PATH"
Permanent="no"
System="yes"
Value="[INSTALLDIR]" />
</Component>
</Directory>
</StandardDirectory>
</Package>
</Wix>

View file

@ -120,7 +120,7 @@ func test(t *testing.T, textPath, text string) {
ruler, err := textmeasure.NewRuler()
assert.Nil(t, err)
err = g.SetDimensions(nil, ruler)
err = g.SetDimensions(nil, ruler, nil)
assert.Nil(t, err)
err = d2dagrelayout.Layout(ctx, g)
@ -128,7 +128,7 @@ func test(t *testing.T, textPath, text string) {
t.Fatal(err)
}
_, err = d2exporter.Export(ctx, g, 0)
_, err = d2exporter.Export(ctx, g, 0, nil)
if err != nil {
t.Fatal(err)
}

View file

@ -363,6 +363,9 @@ func (c *compiler) applyScalar(attrs *d2graph.Attributes, reserved string, box d
}
attrs.Direction.Value = scalar.ScalarString()
return
case "constraint":
// Compilation for shape-specific keywords happens elsewhere
return
}
if _, ok := d2graph.StyleKeywords[reserved]; ok {
@ -580,16 +583,25 @@ func (c *compiler) compileShapes(obj *d2graph.Object) {
c.compileShapes(obj)
}
for _, obj := range obj.ChildrenArray {
switch obj.Attributes.Shape.Value {
for i := 0; i < len(obj.ChildrenArray); i++ {
ch := obj.ChildrenArray[i]
switch ch.Attributes.Shape.Value {
case d2target.ShapeClass, d2target.ShapeSQLTable:
flattenContainer(obj.Graph, obj)
flattenContainer(obj.Graph, ch)
}
if obj.IDVal == "style" {
obj.Parent.Attributes.Style = obj.Attributes.Style
if ch.IDVal == "style" {
obj.Attributes.Style = ch.Attributes.Style
if obj.Graph != nil {
flattenContainer(obj.Graph, obj)
removeObject(obj.Graph, obj)
flattenContainer(obj.Graph, ch)
for i := 0; i < len(obj.Graph.Objects); i++ {
if obj.Graph.Objects[i] == ch {
obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
break
}
}
delete(obj.Children, ch.ID)
obj.ChildrenArray = append(obj.ChildrenArray[:i], obj.ChildrenArray[i+1:]...)
i--
}
}
}
@ -666,8 +678,8 @@ func (c *compiler) compileSQLTable(obj *d2graph.Object) {
typ = ""
}
d2Col := d2target.SQLColumn{
Name: col.IDVal,
Type: typ,
Name: d2target.Text{Label: col.IDVal},
Type: d2target.Text{Label: typ},
}
// The only map a sql table field could have is to specify constraint
if col.Map != nil {
@ -676,6 +688,10 @@ func (c *compiler) compileSQLTable(obj *d2graph.Object) {
continue
}
if n.MapKey.Key.Path[0].Unbox().ScalarString() == "constraint" {
if n.MapKey.Value.StringBox().Unbox() == nil {
c.errorf(n.MapKey.GetRange().Start, n.MapKey.GetRange().End, "constraint value must be a string")
return
}
d2Col.Constraint = n.MapKey.Value.StringBox().Unbox().ScalarString()
}
}
@ -703,23 +719,6 @@ func (c *compiler) compileSQLTable(obj *d2graph.Object) {
}
}
// TODO too similar to flattenContainer, should reconcile in a refactor
func removeObject(g *d2graph.Graph, obj *d2graph.Object) {
for i := 0; i < len(obj.Graph.Objects); i++ {
if obj.Graph.Objects[i] == obj {
obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
break
}
}
delete(obj.Parent.Children, obj.ID)
for i, child := range obj.Parent.ChildrenArray {
if obj == child {
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray[:i], obj.Parent.ChildrenArray[i+1:]...)
break
}
}
}
func flattenContainer(g *d2graph.Graph, obj *d2graph.Object) {
absID := obj.AbsID()
@ -798,15 +797,18 @@ func (c *compiler) validateKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key)
return
}
if reserved == "" && obj.Attributes.Shape.Value == d2target.ShapeImage {
c.errorf(mk.Range.Start, mk.Range.End, "image shapes cannot have children.")
}
switch strings.ToLower(obj.Attributes.Shape.Value) {
case d2target.ShapeImage:
if reserved == "" {
c.errorf(mk.Range.Start, mk.Range.End, "image shapes cannot have children.")
}
case d2target.ShapeCircle, d2target.ShapeSquare:
checkEqual := (reserved == "width" && obj.Attributes.Height != nil) ||
(reserved == "height" && obj.Attributes.Width != nil)
if reserved == "width" && obj.Attributes.Shape.Value != d2target.ShapeImage {
c.errorf(mk.Range.Start, mk.Range.End, "width is only applicable to image shapes.")
}
if reserved == "height" && obj.Attributes.Shape.Value != d2target.ShapeImage {
c.errorf(mk.Range.Start, mk.Range.End, "height is only applicable to image shapes.")
if checkEqual && obj.Attributes.Width.Value != obj.Attributes.Height.Value {
c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf("width and height must be equal for %s shapes", obj.Attributes.Shape.Value))
}
}
in := d2target.IsShape(obj.Attributes.Shape.Value)
@ -831,6 +833,14 @@ func (c *compiler) validateKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key)
return
}
switch strings.ToLower(obj.Attributes.Shape.Value) {
case d2target.ShapeSQLTable, d2target.ShapeClass:
default:
if len(obj.Children) > 0 && (reserved == "width" || reserved == "height") {
c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf("%s cannot be used on container: %s", reserved, obj.AbsID()))
}
}
if len(mk.Edges) > 0 {
return
}
@ -851,11 +861,33 @@ func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ast.Map) {
func (c *compiler) validateNear(g *d2graph.Graph) {
for _, obj := range g.Objects {
if obj.Attributes.NearKey != nil {
_, ok := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
if !ok {
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "near key %#v does not exist. It must be the absolute path to a shape.", d2format.Format(obj.Attributes.NearKey))
_, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
_, isConst := d2graph.NearConstants[d2graph.Key(obj.Attributes.NearKey)[0]]
if !isKey && !isConst {
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.Attributes.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
continue
}
if !isKey && isConst && obj.Parent != g.Root {
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys can only be set on root level shapes")
continue
}
if !isKey && isConst && len(obj.ChildrenArray) > 0 {
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys cannot be set on shapes with children")
continue
}
if !isKey && isConst {
is := false
for _, e := range g.Edges {
if e.Src == obj || e.Dst == obj {
is = true
break
}
}
if is {
c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys cannot be set on connected shapes")
continue
}
}
}
}
}

View file

@ -7,13 +7,13 @@ import (
"testing"
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
)
func TestCompile(t *testing.T) {
@ -95,8 +95,119 @@ x: {
height: 230
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/dimensions_on_nonimage.d2:3:2: width is only applicable to image shapes.
d2/testdata/d2compiler/TestCompile/dimensions_on_nonimage.d2:4:2: height is only applicable to image shapes.
assertions: func(t *testing.T, g *d2graph.Graph) {
if len(g.Objects) != 1 {
t.Fatalf("expected 1 objects: %#v", g.Objects)
}
if g.Objects[0].ID != "hey" {
t.Fatalf("expected g.Objects[0].ID to be 'hey': %#v", g.Objects[0])
}
if g.Objects[0].Attributes.Shape.Value != d2target.ShapeHexagon {
t.Fatalf("expected g.Objects[0].Attributes.Shape.Value to be hexagon: %#v", g.Objects[0].Attributes.Shape.Value)
}
if g.Objects[0].Attributes.Width.Value != "200" {
t.Fatalf("expected g.Objects[0].Attributes.Width.Value to be 200: %#v", g.Objects[0].Attributes.Width.Value)
}
if g.Objects[0].Attributes.Height.Value != "230" {
t.Fatalf("expected g.Objects[0].Attributes.Height.Value to be 230: %#v", g.Objects[0].Attributes.Height.Value)
}
},
},
{
name: "equal_dimensions_on_circle",
text: `hey: "" {
shape: circle
width: 200
height: 230
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/equal_dimensions_on_circle.d2:3:2: width and height must be equal for circle shapes
d2/testdata/d2compiler/TestCompile/equal_dimensions_on_circle.d2:4:2: width and height must be equal for circle shapes
`,
},
{
name: "single_dimension_on_circle",
text: `hey: "" {
shape: circle
height: 230
}
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
if len(g.Objects) != 1 {
t.Fatalf("expected 1 objects: %#v", g.Objects)
}
if g.Objects[0].ID != "hey" {
t.Fatalf("expected ID to be 'hey': %#v", g.Objects[0])
}
if g.Objects[0].Attributes.Shape.Value != d2target.ShapeCircle {
t.Fatalf("expected Attributes.Shape.Value to be circle: %#v", g.Objects[0].Attributes.Shape.Value)
}
if g.Objects[0].Attributes.Width != nil {
t.Fatalf("expected Attributes.Width to be nil: %#v", g.Objects[0].Attributes.Width)
}
if g.Objects[0].Attributes.Height == nil {
t.Fatalf("Attributes.Height is nil")
}
},
},
{
name: "no_dimensions_on_containers",
text: `
containers: {
circle container: {
shape: circle
width: 512
diamond: {
shape: diamond
width: 128
height: 64
}
}
diamond container: {
shape: diamond
width: 512
height: 256
circle: {
shape: circle
width: 128
}
}
oval container: {
shape: oval
width: 512
height: 256
hexagon: {
shape: hexagon
width: 128
height: 64
}
}
hexagon container: {
shape: hexagon
width: 512
height: 256
oval: {
shape: oval
width: 128
height: 64
}
}
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:5:3: width cannot be used on container: containers.circle container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:15:3: width cannot be used on container: containers.diamond container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:16:3: height cannot be used on container: containers.diamond container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:25:3: width cannot be used on container: containers.oval container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:26:3: height cannot be used on container: containers.oval container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:36:3: width cannot be used on container: containers.hexagon container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height cannot be used on container: containers.hexagon container
`,
},
{
@ -1266,6 +1377,50 @@ x -> y: {
}
},
},
{
name: "near_constant",
text: `x.near: top-center
`,
},
{
name: "near_bad_constant",
text: `x.near: txop-center
`,
expErr: `d2/testdata/d2compiler/TestCompile/near_bad_constant.d2:1:1: near key "txop-center" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right
`,
},
{
name: "near_bad_container",
text: `x: {
near: top-center
y
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/near_bad_container.d2:1:1: constant near keys cannot be set on shapes with children
`,
},
{
name: "near_bad_connected",
text: `x: {
near: top-center
}
x -> y
`,
expErr: `d2/testdata/d2compiler/TestCompile/near_bad_connected.d2:1:1: constant near keys cannot be set on connected shapes
`,
},
{
name: "nested_near_constant",
text: `x.y.near: top-center
`,
expErr: `d2/testdata/d2compiler/TestCompile/nested_near_constant.d2:1:1: constant near keys can only be set on root level shapes
`,
},
{
name: "reserved_icon_near_style",
@ -1312,7 +1467,7 @@ y
expErr: `d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:3:9: bad icon url "::????:::%%orange": parse "::????:::%%orange": missing protocol scheme
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:4:18: expected "opacity" to be a number between 0.0 and 1.0
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:5:18: expected "opacity" to be a number between 0.0 and 1.0
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:1:1: near key "y" does not exist. It must be the absolute path to a shape.
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:1:1: near key "y" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right
`,
},
{
@ -1465,8 +1620,8 @@ b`, g.Objects[0].Attributes.Label.Value)
if len(g.Objects) != 1 {
t.Fatal(g.Objects)
}
assert.String(t, `GetType()`, g.Objects[0].SQLTable.Columns[0].Name)
assert.String(t, `Is()`, g.Objects[0].SQLTable.Columns[1].Name)
assert.String(t, `GetType()`, g.Objects[0].SQLTable.Columns[0].Name.Label)
assert.String(t, `Is()`, g.Objects[0].SQLTable.Columns[1].Name.Label)
},
},
{
@ -1490,8 +1645,8 @@ b`, g.Objects[0].Attributes.Label.Value)
if len(g.Objects[0].ChildrenArray) != 1 {
t.Fatal(g.Objects)
}
assert.String(t, `GetType()`, g.Objects[1].SQLTable.Columns[0].Name)
assert.String(t, `Is()`, g.Objects[1].SQLTable.Columns[1].Name)
assert.String(t, `GetType()`, g.Objects[1].SQLTable.Columns[0].Name.Label)
assert.String(t, `Is()`, g.Objects[1].SQLTable.Columns[1].Name.Label)
},
},
{
@ -1618,6 +1773,17 @@ choo: {
assert.String(t, "left", g.Objects[0].Attributes.Direction.Value)
},
},
{
name: "constraint_label",
text: `foo {
label: bar
constraint: BIZ
}`,
assertions: func(t *testing.T, g *d2graph.Graph) {
assert.String(t, "bar", g.Objects[0].Attributes.Label.Value)
},
},
{
name: "invalid_direction",
@ -1639,6 +1805,44 @@ choo: {
}
},
},
{
name: "null",
text: `null
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, "'null'", g.Objects[0].ID)
tassert.Equal(t, "null", g.Objects[0].IDVal)
},
},
{
name: "sql-regression",
text: `a: {
style: {
fill: lemonchiffon
}
b: {
shape: sql_table
c
}
d
}
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, 3, len(g.Objects))
},
},
{
name: "sql-panic",
text: `test {
shape: sql_table
test_id: varchar(64) {constraint: [primary_key, foreign_key]}
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/sql-panic.d2:3:27: constraint value must be a string
`,
},
}
for _, tc := range testCases {

View file

@ -5,15 +5,21 @@ import (
"strconv"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/util-go/go2"
)
func Export(ctx context.Context, g *d2graph.Graph, themeID int64) (*d2target.Diagram, error) {
func Export(ctx context.Context, g *d2graph.Graph, themeID int64, fontFamily *d2fonts.FontFamily) (*d2target.Diagram, error) {
theme := d2themescatalog.Find(themeID)
diagram := d2target.NewDiagram()
if fontFamily == nil {
fontFamily = go2.Pointer(d2fonts.SourceSansPro)
}
diagram.FontFamily = fontFamily
diagram.Shapes = make([]d2target.Shape, len(g.Objects))
for i := range g.Objects {
@ -34,6 +40,11 @@ func applyTheme(shape *d2target.Shape, obj *d2graph.Object, theme *d2themes.Them
if obj.Attributes.Shape.Value == d2target.ShapeText {
shape.Color = theme.Colors.Neutrals.N1
}
if obj.Attributes.Shape.Value == d2target.ShapeSQLTable || obj.Attributes.Shape.Value == d2target.ShapeClass {
shape.PrimaryAccentColor = theme.Colors.B2
shape.SecondaryAccentColor = theme.Colors.AA2
shape.NeutralAccentColor = theme.Colors.Neutrals.N2
}
}
func applyStyles(shape *d2target.Shape, obj *d2graph.Object) {
@ -45,6 +56,8 @@ func applyStyles(shape *d2target.Shape, obj *d2graph.Object) {
}
if obj.Attributes.Style.Fill != nil {
shape.Fill = obj.Attributes.Style.Fill.Value
} else if obj.Attributes.Shape.Value == d2target.ShapeText {
shape.Fill = "transparent"
}
if obj.Attributes.Style.Stroke != nil {
shape.Stroke = obj.Attributes.Style.Stroke.Value
@ -68,19 +81,17 @@ func applyStyles(shape *d2target.Shape, obj *d2graph.Object) {
if obj.Attributes.Style.FontColor != nil {
shape.Color = obj.Attributes.Style.FontColor.Value
}
if obj.Attributes.Shape.Value != d2target.ShapeText {
if obj.Attributes.Style.Italic != nil {
shape.Italic, _ = strconv.ParseBool(obj.Attributes.Style.Italic.Value)
}
if obj.Attributes.Style.Bold != nil {
shape.Bold, _ = strconv.ParseBool(obj.Attributes.Style.Bold.Value)
}
if obj.Attributes.Style.Underline != nil {
shape.Underline, _ = strconv.ParseBool(obj.Attributes.Style.Underline.Value)
}
if obj.Attributes.Style.Font != nil {
shape.FontFamily = obj.Attributes.Style.Font.Value
}
if obj.Attributes.Style.Italic != nil {
shape.Italic, _ = strconv.ParseBool(obj.Attributes.Style.Italic.Value)
}
if obj.Attributes.Style.Bold != nil {
shape.Bold, _ = strconv.ParseBool(obj.Attributes.Style.Bold.Value)
}
if obj.Attributes.Style.Underline != nil {
shape.Underline, _ = strconv.ParseBool(obj.Attributes.Style.Underline.Value)
}
if obj.Attributes.Style.Font != nil {
shape.FontFamily = obj.Attributes.Style.Font.Value
}
}
@ -200,6 +211,10 @@ func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection
connection.StrokeWidth, _ = strconv.Atoi(edge.Attributes.Style.StrokeWidth.Value)
}
if edge.Attributes.Style.Fill != nil {
connection.Fill = edge.Attributes.Style.Fill.Value
}
connection.FontSize = text.FontSize
if edge.Attributes.Style.FontSize != nil {
connection.FontSize, _ = strconv.Atoi(edge.Attributes.Style.FontSize.Value)

View file

@ -8,12 +8,15 @@ import (
"cdr.dev/slog"
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2exporter"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/geo"
@ -80,6 +83,23 @@ y: {shape: square}
}
},
},
{
name: "sequence_group_position",
dsl: `hey {
shape: sequence_diagram
a
b
group: {
a -> b
}
}
`,
assertions: func(t *testing.T, d *d2target.Diagram) {
tassert.Equal(t, "hey.group", d.Shapes[3].ID)
tassert.Equal(t, "INSIDE_TOP_LEFT", d.Shapes[3].LabelPosition)
},
},
}
runa(t, tcs)
@ -216,15 +236,15 @@ func run(t *testing.T, tc testCase) {
ruler, err := textmeasure.NewRuler()
assert.JSON(t, nil, err)
err = g.SetDimensions(nil, ruler)
err = g.SetDimensions(nil, ruler, nil)
assert.JSON(t, nil, err)
err = d2dagrelayout.Layout(ctx, g)
err = d2sequence.Layout(ctx, g, d2dagrelayout.Layout)
if err != nil {
t.Fatal(err)
}
got, err := d2exporter.Export(ctx, g, tc.themeID)
got, err := d2exporter.Export(ctx, g, tc.themeID, nil)
if err != nil {
t.Fatal(err)
}

View file

@ -48,7 +48,7 @@ func escapeUnquotedValue(s string, inKey bool) string {
}
if strings.EqualFold(s, "null") {
return "\\null"
return `'null'`
}
var b strings.Builder

View file

@ -170,7 +170,7 @@ func TestEscapeUnquoted(t *testing.T) {
{
name: "null",
str: `null`,
exp: `\null`,
exp: `'null'`,
},
{
name: "empty",

View file

@ -81,7 +81,7 @@ func (p *printer) comment(c *d2ast.Comment) {
lines := strings.Split(c.Value, "\n")
for i, line := range lines {
p.sb.WriteString("#")
if line != "" && !strings.HasPrefix(line, " ") {
if line != "" {
p.sb.WriteByte(' ')
}
p.sb.WriteString(line)

View file

@ -592,6 +592,23 @@ hi # Fraud is the homage that force pays to reason.
in: `x: {}
`,
exp: `x
`,
},
{
name: "leading_space_comments",
in: `# foo
# foobar
# baz
x -> y
#foo
y
`,
exp: `# foo
# foobar
# baz
x -> y
# foo
y
`,
},
}

View file

@ -3,6 +3,7 @@ package d2graph
import (
"errors"
"fmt"
"math"
"net/url"
"strconv"
"strings"
@ -21,6 +22,7 @@ import (
)
const INNER_LABEL_PADDING int = 5
const DEFAULT_SHAPE_PADDING = 100.
// TODO: Refactor with a light abstract layer on top of AST implementing scenarios,
// variables, imports, substitutions and then a final set of structures representing
@ -392,14 +394,23 @@ func (obj *Object) GetFill(theme *d2themes.Theme) string {
return theme.Colors.Neutrals.N5
}
if strings.EqualFold(shape, d2target.ShapeSQLTable) || strings.EqualFold(shape, d2target.ShapeClass) {
return theme.Colors.Neutrals.N1
}
return theme.Colors.Neutrals.N7
}
func (obj *Object) GetStroke(theme *d2themes.Theme, dashGapSize interface{}) string {
shape := obj.Attributes.Shape.Value
if strings.EqualFold(shape, d2target.ShapeCode) || strings.EqualFold(shape, d2target.ShapeClass) || strings.EqualFold(shape, d2target.ShapeSQLTable) {
if strings.EqualFold(shape, d2target.ShapeCode) ||
strings.EqualFold(shape, d2target.ShapeText) {
return theme.Colors.Neutrals.N1
}
if strings.EqualFold(shape, d2target.ShapeClass) ||
strings.EqualFold(shape, d2target.ShapeSQLTable) {
return theme.Colors.Neutrals.N7
}
if dashGapSize != 0.0 {
return theme.Colors.B2
}
@ -432,7 +443,14 @@ func (obj *Object) AbsIDArray() []string {
}
func (obj *Object) Text() *d2target.MText {
isBold := !obj.IsContainer()
isBold := !obj.IsContainer() && obj.Attributes.Shape.Value != "text"
isItalic := false
if obj.Attributes.Style.Bold != nil && obj.Attributes.Style.Bold.Value == "true" {
isBold = true
}
if obj.Attributes.Style.Italic != nil && obj.Attributes.Style.Italic.Value == "true" {
isItalic = true
}
fontSize := d2fonts.FONT_SIZE_M
if obj.OuterSequenceDiagram() == nil {
if obj.IsContainer() {
@ -448,11 +466,14 @@ func (obj *Object) Text() *d2target.MText {
if obj.Class != nil || obj.SQLTable != nil {
fontSize = d2fonts.FONT_SIZE_XL
}
if obj.Class != nil {
isBold = false
}
return &d2target.MText{
Text: obj.Attributes.Label.Value,
FontSize: fontSize,
IsBold: isBold,
IsItalic: false,
IsItalic: isItalic,
Language: obj.Attributes.Language,
Shape: obj.Attributes.Shape.Value,
@ -649,6 +670,164 @@ func (obj *Object) AppendReferences(ida []string, ref Reference, unresolvedObj *
}
}
func (obj *Object) GetLabelSize(mtexts []*d2target.MText, ruler *textmeasure.Ruler, fontFamily *d2fonts.FontFamily) (*d2target.TextDimensions, error) {
shapeType := strings.ToLower(obj.Attributes.Shape.Value)
var dims *d2target.TextDimensions
switch shapeType {
case d2target.ShapeText:
if obj.Attributes.Language == "latex" {
width, height, err := d2latex.Measure(obj.Text().Text)
if err != nil {
return nil, err
}
dims = d2target.NewTextDimensions(width, height)
} else if obj.Attributes.Language != "" {
var err error
dims, err = getMarkdownDimensions(mtexts, ruler, obj.Text(), fontFamily)
if err != nil {
return nil, err
}
} else {
dims = GetTextDimensions(mtexts, ruler, obj.Text(), fontFamily)
}
case d2target.ShapeClass:
dims = GetTextDimensions(mtexts, ruler, obj.Text(), go2.Pointer(d2fonts.SourceCodePro))
default:
dims = GetTextDimensions(mtexts, ruler, obj.Text(), fontFamily)
}
if shapeType == d2target.ShapeSQLTable && obj.Attributes.Label.Value == "" {
// measure with placeholder text to determine height
placeholder := *obj.Text()
placeholder.Text = "Table"
dims = GetTextDimensions(mtexts, ruler, &placeholder, fontFamily)
}
if dims == nil {
if shapeType == d2target.ShapeImage {
dims = d2target.NewTextDimensions(0, 0)
} else {
return nil, fmt.Errorf("dimensions for object label %#v not found", obj.Text())
}
}
return dims, nil
}
func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.Ruler, fontFamily *d2fonts.FontFamily, labelDims d2target.TextDimensions) (*d2target.TextDimensions, error) {
dims := d2target.TextDimensions{}
switch strings.ToLower(obj.Attributes.Shape.Value) {
default:
return d2target.NewTextDimensions(labelDims.Width, labelDims.Height), nil
case d2target.ShapeImage:
return d2target.NewTextDimensions(128, 128), nil
case d2target.ShapeClass:
maxWidth := labelDims.Width
for _, f := range obj.Class.Fields {
fdims := GetTextDimensions(mtexts, ruler, f.Text(), go2.Pointer(d2fonts.SourceCodePro))
if fdims == nil {
return nil, fmt.Errorf("dimensions for class field %#v not found", f.Text())
}
lineWidth := fdims.Width
if maxWidth < lineWidth {
maxWidth = lineWidth
}
}
for _, m := range obj.Class.Methods {
mdims := GetTextDimensions(mtexts, ruler, m.Text(), go2.Pointer(d2fonts.SourceCodePro))
if mdims == nil {
return nil, fmt.Errorf("dimensions for class method %#v not found", m.Text())
}
lineWidth := mdims.Width
if maxWidth < lineWidth {
maxWidth = lineWidth
}
}
dims.Width = maxWidth
// All rows should be the same height
var anyRowText *d2target.MText
if len(obj.Class.Fields) > 0 {
anyRowText = obj.Class.Fields[0].Text()
} else if len(obj.Class.Methods) > 0 {
anyRowText = obj.Class.Methods[0].Text()
}
if anyRowText != nil {
// 10px of padding top and bottom so text doesn't look squished
rowHeight := GetTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + 20
dims.Height = rowHeight * (len(obj.Class.Fields) + len(obj.Class.Methods) + 2)
} else {
dims.Height = labelDims.Height
}
case d2target.ShapeSQLTable:
maxNameWidth := 0
maxTypeWidth := 0
constraintWidth := 0
for i := range obj.SQLTable.Columns {
// Note: we want to set dimensions of actual column not the for loop copy of the struct
c := &obj.SQLTable.Columns[i]
ctexts := c.Texts()
nameDims := GetTextDimensions(mtexts, ruler, ctexts[0], fontFamily)
if nameDims == nil {
return nil, fmt.Errorf("dimensions for sql_table name %#v not found", ctexts[0].Text)
}
c.Name.LabelWidth = nameDims.Width
c.Name.LabelHeight = nameDims.Height
if maxNameWidth < nameDims.Width {
maxNameWidth = nameDims.Width
}
typeDims := GetTextDimensions(mtexts, ruler, ctexts[1], fontFamily)
if typeDims == nil {
return nil, fmt.Errorf("dimensions for sql_table type %#v not found", ctexts[1].Text)
}
c.Type.LabelWidth = typeDims.Width
c.Type.LabelHeight = typeDims.Height
if maxTypeWidth < typeDims.Width {
maxTypeWidth = typeDims.Width
}
if c.Constraint != "" {
// covers UNQ constraint with padding
constraintWidth = 60
}
}
// The rows get padded a little due to header font being larger than row font
dims.Height = labelDims.Height * (len(obj.SQLTable.Columns) + 1)
headerWidth := d2target.HeaderPadding + labelDims.Width + d2target.HeaderPadding
rowsWidth := d2target.NamePadding + maxNameWidth + d2target.TypePadding + maxTypeWidth + d2target.TypePadding + constraintWidth
dims.Width = go2.Max(headerWidth, rowsWidth)
}
return &dims, nil
}
func (obj *Object) GetPadding() (x, y float64) {
switch strings.ToLower(obj.Attributes.Shape.Value) {
case d2target.ShapeImage,
d2target.ShapeSQLTable,
d2target.ShapeText,
d2target.ShapeCode:
return 0., 0.
case d2target.ShapeClass:
// TODO fix class row width measurements (see SQL table)
return 100., 0.
default:
return DEFAULT_SHAPE_PADDING, DEFAULT_SHAPE_PADDING
}
}
type Edge struct {
Index int `json:"index"`
@ -822,13 +1001,13 @@ func findMeasured(mtexts []*d2target.MText, t1 *d2target.MText) *d2target.TextDi
return nil
}
func getMarkdownDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2target.MText) (*d2target.TextDimensions, error) {
func getMarkdownDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2target.MText, fontFamily *d2fonts.FontFamily) (*d2target.TextDimensions, error) {
if dims := findMeasured(mtexts, t); dims != nil {
return dims, nil
}
if ruler != nil {
width, height, err := textmeasure.MeasureMarkdown(t.Text, ruler)
width, height, err := textmeasure.MeasureMarkdown(t.Text, ruler, fontFamily)
if err != nil {
return nil, err
}
@ -838,7 +1017,7 @@ func getMarkdownDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t
return nil, fmt.Errorf("text not pre-measured and no ruler provided")
}
func getTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2target.MText) *d2target.TextDimensions {
func GetTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2target.MText, fontFamily *d2fonts.FontFamily) *d2target.TextDimensions {
if dims := findMeasured(mtexts, t); dims != nil {
return dims
}
@ -858,7 +1037,10 @@ func getTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2
} else if t.IsItalic {
style = d2fonts.FONT_STYLE_ITALIC
}
w, h = ruler.Measure(d2fonts.SourceSansPro.Font(t.FontSize, style), t.Text)
if fontFamily == nil {
fontFamily = go2.Pointer(d2fonts.SourceSansPro)
}
w, h = ruler.Measure(fontFamily.Font(t.FontSize, style), t.Text)
}
return d2target.NewTextDimensions(w, h)
}
@ -867,150 +1049,75 @@ func getTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2
}
func appendTextDedup(texts []*d2target.MText, t *d2target.MText) []*d2target.MText {
if getTextDimensions(texts, nil, t) == nil {
if GetTextDimensions(texts, nil, t, nil) == nil {
return append(texts, t)
}
return texts
}
func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler) error {
func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, fontFamily *d2fonts.FontFamily) error {
for _, obj := range g.Objects {
obj.Box = &geo.Box{}
// TODO fix edge cases for unnamed class etc
// Image shapes can set their own widths/heights
if obj.Attributes.Label.Value == "" && obj.Attributes.Shape.Value != d2target.ShapeImage {
obj.Width = 100
obj.Height = 100
continue
var desiredWidth int
var desiredHeight int
if obj.Attributes.Width != nil {
desiredWidth, _ = strconv.Atoi(obj.Attributes.Width.Value)
}
if obj.Attributes.Height != nil {
desiredHeight, _ = strconv.Atoi(obj.Attributes.Height.Value)
}
shapeType := strings.ToLower(obj.Attributes.Shape.Value)
labelDims, err := obj.GetLabelSize(mtexts, ruler, fontFamily)
if err != nil {
return err
}
var dims *d2target.TextDimensions
var innerLabelPadding = INNER_LABEL_PADDING
if obj.Attributes.Shape.Value == d2target.ShapeText {
if obj.Attributes.Language == "latex" {
width, height, err := d2latex.Measure(obj.Text().Text)
if err != nil {
return err
}
dims = d2target.NewTextDimensions(width, height)
} else {
var err error
dims, err = getMarkdownDimensions(mtexts, ruler, obj.Text())
if err != nil {
return err
}
}
innerLabelPadding = 0
} else {
dims = getTextDimensions(mtexts, ruler, obj.Text())
}
if dims == nil {
if obj.Attributes.Shape.Value == d2target.ShapeImage {
dims = d2target.NewTextDimensions(0, 0)
} else {
return fmt.Errorf("dimensions for object label %#v not found", obj.Text())
}
}
switch obj.Attributes.Shape.Value {
switch shapeType {
case d2target.ShapeText, d2target.ShapeClass, d2target.ShapeSQLTable, d2target.ShapeCode:
// no labels
default:
if obj.Attributes.Label.Value != "" {
obj.LabelWidth = go2.Pointer(dims.Width)
obj.LabelHeight = go2.Pointer(dims.Height)
obj.LabelWidth = go2.Pointer(labelDims.Width)
obj.LabelHeight = go2.Pointer(labelDims.Height)
}
}
dims.Width += innerLabelPadding
dims.Height += innerLabelPadding
obj.LabelDimensions = *dims
obj.Width = float64(dims.Width)
obj.Height = float64(dims.Height)
if shapeType != d2target.ShapeText && obj.Attributes.Label.Value != "" {
labelDims.Width += INNER_LABEL_PADDING
labelDims.Height += INNER_LABEL_PADDING
}
obj.LabelDimensions = *labelDims
switch strings.ToLower(obj.Attributes.Shape.Value) {
default:
obj.Width += 100
obj.Height += 100
defaultDims, err := obj.GetDefaultSize(mtexts, ruler, fontFamily, *labelDims)
if err != nil {
return err
}
case d2target.ShapeImage:
if obj.Attributes.Width != nil {
w, _ := strconv.Atoi(obj.Attributes.Width.Value)
obj.Width = float64(w)
} else {
obj.Width = 128
}
if obj.Attributes.Height != nil {
h, _ := strconv.Atoi(obj.Attributes.Height.Value)
obj.Height = float64(h)
} else {
obj.Height = 128
}
obj.Width = float64(go2.Max(defaultDims.Width, desiredWidth))
obj.Height = float64(go2.Max(defaultDims.Height, desiredHeight))
paddingX, paddingY := obj.GetPadding()
switch shapeType {
case d2target.ShapeSquare, d2target.ShapeCircle:
sideLength := go2.Max(obj.Width, obj.Height)
obj.Width = sideLength + 100
obj.Height = sideLength + 100
case d2target.ShapeClass:
maxWidth := dims.Width
for _, f := range obj.Class.Fields {
fdims := getTextDimensions(mtexts, ruler, f.Text())
if fdims == nil {
return fmt.Errorf("dimensions for class field %#v not found", f.Text())
}
lineWidth := fdims.Width
if maxWidth < lineWidth {
maxWidth = lineWidth
}
}
for _, m := range obj.Class.Methods {
mdims := getTextDimensions(mtexts, ruler, m.Text())
if mdims == nil {
return fmt.Errorf("dimensions for class method %#v not found", m.Text())
}
lineWidth := mdims.Width
if maxWidth < lineWidth {
maxWidth = lineWidth
}
if desiredWidth != 0 || desiredHeight != 0 {
paddingX = 0.
paddingY = 0.
}
// All rows should be the same height
var anyRowText *d2target.MText
if len(obj.Class.Fields) > 0 {
anyRowText = obj.Class.Fields[0].Text()
} else if len(obj.Class.Methods) > 0 {
anyRowText = obj.Class.Methods[0].Text()
sideLength := math.Max(obj.Width+paddingX, obj.Height+paddingY)
obj.Width = sideLength
obj.Height = sideLength
default:
if desiredWidth == 0 {
obj.Width += float64(paddingX)
}
if anyRowText != nil {
// 10px of padding top and bottom so text doesn't look squished
rowHeight := getTextDimensions(mtexts, ruler, anyRowText).Height + 20
obj.Height = float64(rowHeight * (len(obj.Class.Fields) + len(obj.Class.Methods) + 2))
if desiredHeight == 0 {
obj.Height += float64(paddingY)
}
// Leave room for padding
obj.Width = float64(maxWidth + 100)
case d2target.ShapeSQLTable:
maxWidth := dims.Width
for _, c := range obj.SQLTable.Columns {
cdims := getTextDimensions(mtexts, ruler, c.Text())
if cdims == nil {
return fmt.Errorf("dimensions for column %#v not found", c.Text())
}
lineWidth := cdims.Width
if maxWidth < lineWidth {
maxWidth = lineWidth
}
}
// The rows get padded a little due to header font being larger than row font
obj.Height = float64(dims.Height * (len(obj.SQLTable.Columns) + 1))
// Leave room for padding
obj.Width = float64(maxWidth + 100)
case d2target.ShapeText, d2target.ShapeCode:
}
}
for _, edge := range g.Edges {
@ -1025,7 +1132,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
for _, label := range endpointLabels {
t := edge.Text()
t.Text = label
dims := getTextDimensions(mtexts, ruler, t)
dims := GetTextDimensions(mtexts, ruler, t, fontFamily)
edge.MinWidth += dims.Width
// Some padding as it's not totally near the end
edge.MinHeight += dims.Height + 5
@ -1035,7 +1142,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
continue
}
dims := getTextDimensions(mtexts, ruler, edge.Text())
dims := GetTextDimensions(mtexts, ruler, edge.Text(), fontFamily)
if dims == nil {
return fmt.Errorf("dimensions for edge label %#v not found", edge.Text())
}
@ -1063,7 +1170,9 @@ func (g *Graph) Texts() []*d2target.MText {
}
} else if obj.SQLTable != nil {
for _, column := range obj.SQLTable.Columns {
texts = appendTextDedup(texts, column.Text())
for _, t := range column.Texts() {
texts = appendTextDedup(texts, t)
}
}
}
}
@ -1149,6 +1258,22 @@ var StyleKeywords = map[string]struct{}{
"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{}
func init() {
for k, v := range StyleKeywords {
ReservedKeywords[k] = v
@ -1156,4 +1281,8 @@ func init() {
for k, v := range ReservedKeywordHolders {
ReservedKeywords[k] = v
}
NearConstants = make(map[string]struct{}, len(NearConstantsArray))
for _, k := range NearConstantsArray {
NearConstants[k] = struct{}{}
}
}

View file

@ -2,6 +2,7 @@ package d2graph
import (
"encoding/json"
"strings"
"oss.terrastruct.com/util-go/go2"
)
@ -48,7 +49,7 @@ func DeserializeGraph(bytes []byte, g *Graph) error {
for _, id := range so["ChildrenArray"].([]interface{}) {
o := idToObj[id.(string)]
childrenArray = append(childrenArray, o)
children[id.(string)] = o
children[strings.ToLower(id.(string))] = o
o.Parent = idToObj[so["AbsID"].(string)]
}

View file

@ -14,9 +14,7 @@ func TestSerialization(t *testing.T) {
t.Parallel()
g, err := d2compiler.Compile("", strings.NewReader("a.a.b -> a.a.c"), nil)
if err != nil {
t.Fatal(err)
}
assert.Nil(t, err)
asserts := func(g *d2graph.Graph) {
assert.Equal(t, 4, len(g.Objects))
@ -41,15 +39,33 @@ func TestSerialization(t *testing.T) {
asserts(g)
b, err := d2graph.SerializeGraph(g)
if err != nil {
t.Fatal(err)
}
assert.Nil(t, err)
var newG d2graph.Graph
err = d2graph.DeserializeGraph(b, &newG)
if err != nil {
t.Fatal(err)
}
assert.Nil(t, err)
asserts(&newG)
}
func TestCasingRegression(t *testing.T) {
t.Parallel()
script := `UserCreatedTypeField`
g, err := d2compiler.Compile("", strings.NewReader(script), nil)
assert.Nil(t, err)
_, ok := g.Root.HasChild([]string{"UserCreatedTypeField"})
assert.True(t, ok)
b, err := d2graph.SerializeGraph(g)
assert.Nil(t, err)
var newG d2graph.Graph
err = d2graph.DeserializeGraph(b, &newG)
assert.Nil(t, err)
_, ok = newG.Root.HasChild([]string{"UserCreatedTypeField"})
assert.True(t, ok)
}

View file

@ -76,7 +76,7 @@ var lodash;if(typeof require==="function"){try{lodash={cloneDeep:require("lodash
/*
* A nesting graph creates dummy nodes for the tops and bottoms of subgraphs,
* adds appropriate edges to ensure that all cluster nodes are placed between
* these boundries, and ensures that the graph is connected.
* these boundaries, and ensures that the graph is connected.
*
* In addition we ensure, through the use of the minlen property, that nodes
* and subgraph border nodes to not end up on the same rank.
@ -179,7 +179,7 @@ g.graph().nodeRankFactor=nodeSep}function dfs(g,root,nodeSep,weight,height,depth
_.forEach(g[relationship](v),function(e){var u=e.v===v?e.w:e.v,edge=result.edge(u,v),weight=!_.isUndefined(edge)?edge.weight:0;result.setEdge(u,v,{weight:g.edge(e).weight+weight})});if(_.has(node,"minRank")){result.setNode(v,{borderLeft:node.borderLeft[rank],borderRight:node.borderRight[rank]})}}});return result}function createRootNode(g){var v;while(g.hasNode(v=_.uniqueId("_root")));return v}},{"../graphlib":7,"../lodash":10}],16:[function(require,module,exports){"use strict";var _=require("../lodash");module.exports=crossCount;
/*
* A function that takes a layering (an array of layers, each with an array of
* ordererd nodes) and a graph and returns a weighted crossing count.
* ordered nodes) and a graph and returns a weighted crossing count.
*
* Pre-conditions:
*
@ -232,11 +232,11 @@ var cc=0;_.forEach(southEntries.forEach(function(entry){var index=entry.pos+firs
* constraint graph this function will resolve any conflicts between the
* constraint graph and the barycenters for the entries. If the barycenters for
* an entry would violate a constraint in the constraint graph then we coalesce
* the nodes in the conflict into a new node that respects the contraint and
* the nodes in the conflict into a new node that respects the constraint and
* aggregates barycenter and weight information.
*
* This implementation is based on the description in Forster, "A Fast and
* Simple Hueristic for Constrained Two-Level Crossing Reduction," thought it
* Simple Heuristic for Constrained Two-Level Crossing Reduction," though it
* differs in some specific details.
*
* Pre-conditions:
@ -482,7 +482,7 @@ rank=0}return label.rank=rank}_.forEach(g.sources(),dfs)}
* ({x, y, width, height}) if it were pointing at the rectangle's center.
*/function intersectRect(rect,point){var x=rect.x;var y=rect.y;
// Rectangle intersection algorithm from:
// http://math.stackexchange.com/questions/108113/find-edge-between-two-boxes
// https://math.stackexchange.com/questions/108113/find-edge-between-two-boxes
var dx=point.x-x;var dy=point.y-y;var w=rect.width/2;var h=rect.height/2;if(!dx&&!dy){throw new Error("Not possible to find intersection inside of the rectangle")}var sx,sy;if(Math.abs(dy)*w>Math.abs(dx)*h){
// Intersection is top or bottom of rect.
if(dy<0){h=-h}sx=h*dx/dy;sy=h}else{
@ -538,7 +538,7 @@ var offset=_.min(_.map(g.nodes(),function(v){return g.node(v).rank}));var layers
*/
var lib=require("./lib");module.exports={Graph:lib.Graph,json:require("./lib/json"),alg:require("./lib/alg"),version:lib.version}},{"./lib":47,"./lib/alg":38,"./lib/json":48}],32:[function(require,module,exports){var _=require("../lodash");module.exports=components;function components(g){var visited={};var cmpts=[];var cmpt;function dfs(v){if(_.has(visited,v))return;visited[v]=true;cmpt.push(v);_.each(g.successors(v),dfs);_.each(g.predecessors(v),dfs)}_.each(g.nodes(),function(v){cmpt=[];dfs(v);if(cmpt.length){cmpts.push(cmpt)}});return cmpts}},{"../lodash":49}],33:[function(require,module,exports){var _=require("../lodash");module.exports=dfs;
/*
* A helper that preforms a pre- or post-order traversal on the input graph
* A helper that performs a pre- or post-order traversal on the input graph
* and returns the nodes in the order they were visited. If the graph is
* undirected then this algorithm will navigate using neighbors. If the graph
* is directed then this algorithm will navigate using successors.
@ -1114,7 +1114,7 @@ function baseHasIn(object,key){return object!=null&&key in Object(object)}module
function baseIsNaN(value){return value!==value}module.exports=baseIsNaN},{}],102:[function(require,module,exports){var isFunction=require("./isFunction"),isMasked=require("./_isMasked"),isObject=require("./isObject"),toSource=require("./_toSource");
/**
* Used to match `RegExp`
* [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns).
* [syntax characters](https://262.ecma-international.org/7.0/#sec-patterns).
*/var reRegExpChar=/[\\^$.*+?()[\]{}|]/g;
/** Used to detect host constructors (Safari). */var reIsHostCtor=/^\[object .+?Constructor\]$/;
/** Used for built-in method references. */var funcProto=Function.prototype,objectProto=Object.prototype;
@ -1718,7 +1718,7 @@ var freeGlobal=typeof global=="object"&&global&&global.Object===Object&&global;m
/** Used to check objects for own properties. */var hasOwnProperty=objectProto.hasOwnProperty;
/**
* Used to resolve the
* [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)
* [`toStringTag`](https://262.ecma-international.org/7.0/#sec-object.prototype.tostring)
* of values.
*/var nativeObjectToString=objectProto.toString;
/** Built-in value references. */var symToStringTag=Symbol?Symbol.toStringTag:undefined;

View file

@ -64,22 +64,35 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
}
rootAttrs := dagreGraphAttrs{
ranksep: 100,
edgesep: 40,
nodesep: 60,
}
isHorizontal := false
switch g.Root.Attributes.Direction.Value {
case "down":
rootAttrs.rankdir = "TB"
case "right":
rootAttrs.rankdir = "LR"
isHorizontal = true
case "left":
rootAttrs.rankdir = "RL"
isHorizontal = true
case "up":
rootAttrs.rankdir = "BT"
default:
rootAttrs.rankdir = "TB"
}
maxLabelSize := 0
for _, edge := range g.Edges {
size := edge.LabelDimensions.Width
if !isHorizontal {
size = edge.LabelDimensions.Height
}
maxLabelSize = go2.Max(maxLabelSize, size)
}
rootAttrs.ranksep = go2.Max(100, maxLabelSize+40)
configJS := setGraphAttrs(rootAttrs)
if _, err := vm.RunString(configJS); err != nil {
return err
@ -90,7 +103,14 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
for _, obj := range g.Objects {
id := obj.AbsID()
idToObj[id] = obj
loadScript += generateAddNodeLine(id, int(obj.Width), int(obj.Height))
height := obj.Height
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if obj.Attributes.Shape.Value == d2target.ShapeImage || obj.Attributes.Icon != nil {
height += float64(*obj.LabelHeight) + label.PADDING
}
}
loadScript += generateAddNodeLine(id, int(obj.Width), int(height))
if obj.Parent != g.Root {
loadScript += generateAddParentLine(id, obj.Parent.AbsID())
}
@ -151,8 +171,12 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
} else if obj.Attributes.Shape.Value == d2target.ShapeImage || obj.Attributes.Icon != nil {
obj.LabelPosition = go2.Pointer(string(label.OutsideTopCenter))
} else if obj.Attributes.Shape.Value == d2target.ShapeImage {
obj.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
// remove the extra height we added to the node when passing to dagre
obj.Height -= float64(*obj.LabelHeight) + label.PADDING
} else if obj.Attributes.Icon != nil {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
} else {
obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
@ -261,7 +285,7 @@ func escapeID(id string) string {
// fixes \\
id = strings.ReplaceAll(id, "\\", `\\`)
// replaces \n with \\n whenever \n is not preceded by \ (does not replace \\n)
re := regexp.MustCompile(`[^\\](\n)`)
re := regexp.MustCompile(`[^\\]\n`)
id = re.ReplaceAllString(id, `\\n`)
// avoid an unescaped \r becoming a \n in the layout result
id = strings.ReplaceAll(id, "\r", `\r`)

View file

@ -9,9 +9,10 @@ import (
_ "embed"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/dop251/goja"
"oss.terrastruct.com/util-go/xdefer"
"oss.terrastruct.com/util-go/go2"
@ -20,6 +21,7 @@ import (
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
)
//go:embed elk.js
@ -40,11 +42,12 @@ type ELKNode struct {
}
type ELKLabel struct {
Text string `json:"text"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Text string `json:"text"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
LayoutOptions *ELKLayoutOptions `json:"layoutOptions,omitempty"`
}
type ELKPoint struct {
@ -75,13 +78,16 @@ type ELKGraph struct {
}
type ELKLayoutOptions struct {
Algorithm string `json:"elk.algorithm,omitempty"`
HierarchyHandling string `json:"elk.hierarchyHandling,omitempty"`
NodeSpacing float64 `json:"spacing.nodeNodeBetweenLayers,omitempty"`
Padding string `json:"elk.padding,omitempty"`
EdgeNodeSpacing float64 `json:"spacing.edgeNodeBetweenLayers,omitempty"`
Direction string `json:"elk.direction"`
SelfLoopSpacing float64 `json:"elk.spacing.nodeSelfLoop"`
Algorithm string `json:"elk.algorithm,omitempty"`
HierarchyHandling string `json:"elk.hierarchyHandling,omitempty"`
NodeSpacing float64 `json:"spacing.nodeNodeBetweenLayers,omitempty"`
Padding string `json:"elk.padding,omitempty"`
EdgeNodeSpacing float64 `json:"spacing.edgeNodeBetweenLayers,omitempty"`
Direction string `json:"elk.direction"`
SelfLoopSpacing float64 `json:"elk.spacing.nodeSelfLoop"`
InlineEdgeLabels bool `json:"elk.edgeLabels.inline,omitempty"`
ConsiderModelOrder string `json:"elk.layered.considerModelOrder.strategy,omitempty"`
ForceNodeModelOrder bool `json:"elk.layered.crossingMinimization.forceNodeModelOrder,omitempty"`
}
func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
@ -104,11 +110,12 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
elkGraph := &ELKGraph{
ID: "root",
LayoutOptions: &ELKLayoutOptions{
Algorithm: "layered",
HierarchyHandling: "INCLUDE_CHILDREN",
NodeSpacing: 100.0,
EdgeNodeSpacing: 50.0,
SelfLoopSpacing: 50.0,
Algorithm: "layered",
HierarchyHandling: "INCLUDE_CHILDREN",
NodeSpacing: 100.0,
EdgeNodeSpacing: 50.0,
SelfLoopSpacing: 50.0,
ConsiderModelOrder: "NODES_AND_EDGES",
},
}
switch g.Root.Attributes.Direction.Value {
@ -139,15 +146,23 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
}
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
height := obj.Height
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if obj.Attributes.Shape.Value == d2target.ShapeImage || obj.Attributes.Icon != nil {
height += float64(*obj.LabelHeight) + label.PADDING
}
}
n := &ELKNode{
ID: obj.AbsID(),
Width: obj.Width,
Height: obj.Height,
Height: height,
}
if len(obj.ChildrenArray) > 0 {
n.LayoutOptions = &ELKLayoutOptions{
Padding: "[top=75,left=75,bottom=75,right=75]",
Padding: "[top=75,left=75,bottom=75,right=75]",
ForceNodeModelOrder: true,
}
}
@ -178,6 +193,9 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
Text: edge.Attributes.Label.Value,
Width: float64(edge.LabelDimensions.Width),
Height: float64(edge.LabelDimensions.Height),
LayoutOptions: &ELKLayoutOptions{
InlineEdgeLabels: true,
},
})
}
elkGraph.Edges = append(elkGraph.Edges, e)
@ -240,15 +258,18 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
parentX = parent.TopLeft.X
parentY = parent.TopLeft.Y
}
obj.TopLeft = geo.NewPoint(math.Round(parentX+n.X), math.Round(parentY+n.Y))
obj.TopLeft = geo.NewPoint(parentX+n.X, parentY+n.Y)
obj.Width = n.Width
obj.Height = n.Height
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
} else if obj.Attributes.Shape.Value == d2target.ShapeImage || obj.Attributes.Icon != nil {
obj.LabelPosition = go2.Pointer(string(label.OutsideTopCenter))
} else if obj.Attributes.Shape.Value == d2target.ShapeImage {
obj.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
obj.Height -= float64(*obj.LabelHeight) + label.PADDING
} else if obj.Attributes.Icon != nil {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
} else {
obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
@ -288,6 +309,14 @@ func Layout(ctx context.Context, g *d2graph.Graph) (err error) {
})
}
startIndex, endIndex := 0, len(points)-1
srcShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(edge.Src.Attributes.Shape.Value)], edge.Src.Box)
dstShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(edge.Dst.Attributes.Shape.Value)], edge.Dst.Box)
// trace the edge to the specific shape's border
points[startIndex] = shape.TraceToShapeBorder(srcShape, points[startIndex], points[startIndex+1])
points[endIndex] = shape.TraceToShapeBorder(dstShape, points[endIndex], points[endIndex-1])
if edge.Attributes.Label.Value != "" {
edge.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
}

146
d2layouts/d2near/layout.go Normal file
View file

@ -0,0 +1,146 @@
// d2near applies near keywords when they're constants
// Intended to be run as the last stage of layout after the diagram has already undergone layout
package d2near
import (
"context"
"math"
"strings"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/util-go/go2"
)
const pad = 20
// Layout finds the shapes which are assigned constant near keywords and places them.
func Layout(ctx context.Context, g *d2graph.Graph, constantNears []*d2graph.Object) error {
if len(constantNears) == 0 {
return nil
}
// Imagine the graph has two long texts, one at top center and one at top left.
// Top left should go left enough to not collide with center.
// So place the center ones first, then the later ones will consider them for bounding box
for _, processCenters := range []bool{true, false} {
for _, obj := range constantNears {
if processCenters == strings.Contains(d2graph.Key(obj.Attributes.NearKey)[0], "center") {
obj.TopLeft = geo.NewPoint(place(obj))
}
}
for _, obj := range constantNears {
if processCenters == strings.Contains(d2graph.Key(obj.Attributes.NearKey)[0], "center") {
// The z-index for constant nears does not matter, as it will not collide
g.Objects = append(g.Objects, obj)
obj.Parent.Children[obj.ID] = obj
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray, obj)
}
}
}
// These shapes skipped core layout, which means they also skipped label placements
for _, obj := range constantNears {
if obj.Attributes.Shape.Value == d2target.ShapeImage {
obj.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
} else if obj.Attributes.Icon != nil {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
} else {
obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
}
return nil
}
// place returns the position of obj, taking into consideration its near value and the diagram
func place(obj *d2graph.Object) (float64, float64) {
tl, br := boundingBox(obj.Graph)
w := br.X - tl.X
h := br.Y - tl.Y
switch d2graph.Key(obj.Attributes.NearKey)[0] {
case "top-left":
return tl.X - obj.Width - pad, tl.Y - obj.Height - pad
case "top-center":
return tl.X + w/2 - obj.Width/2, tl.Y - obj.Height - pad
case "top-right":
return br.X + pad, tl.Y - obj.Height - pad
case "center-left":
return tl.X - obj.Width - pad, tl.Y + h/2 - obj.Height/2
case "center-right":
return br.X + pad, tl.Y + h/2 - obj.Height/2
case "bottom-left":
return tl.X - obj.Width - pad, br.Y + pad
case "bottom-center":
return br.X - w/2 - obj.Width/2, br.Y + pad
case "bottom-right":
return br.X + pad, br.Y + pad
}
return 0, 0
}
// WithoutConstantNears plucks out the graph objects which have "near" set to a constant value
// This is to be called before layout engines so they don't take part in regular positioning
func WithoutConstantNears(ctx context.Context, g *d2graph.Graph) (nears []*d2graph.Object) {
for i := 0; i < len(g.Objects); i++ {
obj := g.Objects[i]
if obj.Attributes.NearKey == nil {
continue
}
_, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
if isKey {
continue
}
_, isConst := d2graph.NearConstants[d2graph.Key(obj.Attributes.NearKey)[0]]
if isConst {
nears = append(nears, obj)
g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
i--
delete(obj.Parent.Children, obj.ID)
for i := 0; i < len(obj.Parent.ChildrenArray); i++ {
if obj.Parent.ChildrenArray[i] == obj {
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray[:i], obj.Parent.ChildrenArray[i+1:]...)
break
}
}
}
}
return nears
}
// boundingBox gets the center of the graph as defined by shapes
// The bounds taking into consideration only shapes gives more of a feeling of true center
// It differs from d2target.BoundingBox which needs to include every visible thing
func boundingBox(g *d2graph.Graph) (tl, br *geo.Point) {
if len(g.Objects) == 0 {
return geo.NewPoint(0, 0), geo.NewPoint(0, 0)
}
x1 := math.Inf(1)
y1 := math.Inf(1)
x2 := math.Inf(-1)
y2 := math.Inf(-1)
for _, obj := range g.Objects {
if obj.Attributes.NearKey != nil {
// Top left should not be MORE top than top-center
// But it should go more left if top-center label extends beyond bounds of diagram
switch d2graph.Key(obj.Attributes.NearKey)[0] {
case "top-center", "bottom-center":
x1 = math.Min(x1, obj.TopLeft.X)
x2 = math.Max(x2, obj.TopLeft.X+obj.Width)
case "center-left", "center-right":
y1 = math.Min(y1, obj.TopLeft.Y)
y2 = math.Max(y2, obj.TopLeft.Y+obj.Height)
}
} else {
x1 = math.Min(x1, obj.TopLeft.X)
y1 = math.Min(y1, obj.TopLeft.Y)
x2 = math.Max(x2, obj.TopLeft.X+obj.Width)
y2 = math.Max(y2, obj.TopLeft.Y+obj.Height)
}
}
return geo.NewPoint(x1, y1), geo.NewPoint(x2, y2)
}

View file

@ -5,24 +5,14 @@ import (
"sort"
"strings"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/util-go/go2"
)
// Layout runs the sequence diagram layout engine on objects of shape sequence_diagram
//
// 1. Traverse graph from root, skip objects with shape not `sequence_diagram`
// 2. Construct a sequence diagram from all descendant objects and edges
// 3. Remove those objects and edges from the main graph
// 4. Run layout on sequence diagrams
// 5. Set the resulting dimensions to the main graph shape
// 6. Run core layouts (still without sequence diagram innards)
// 7. Put back sequence diagram innards in correct location
func Layout(ctx context.Context, g *d2graph.Graph, layout func(ctx context.Context, g *d2graph.Graph) error) error {
func WithoutSequenceDiagrams(ctx context.Context, g *d2graph.Graph) (map[string]*sequenceDiagram, map[string]int, map[string]int, error) {
objectsToRemove := make(map[*d2graph.Object]struct{})
edgesToRemove := make(map[*d2graph.Edge]struct{})
sequenceDiagrams := make(map[string]*sequenceDiagram)
@ -42,7 +32,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, layout func(ctx context.Conte
sd, err := layoutSequenceDiagram(g, obj)
if err != nil {
return err
return nil, nil, nil, err
}
obj.Children = make(map[string]*d2graph.Object)
obj.ChildrenArray = nil
@ -70,8 +60,27 @@ func Layout(ctx context.Context, g *d2graph.Graph, layout func(ctx context.Conte
layoutEdges, edgeOrder := getLayoutEdges(g, edgesToRemove)
g.Edges = layoutEdges
layoutObjects, objectOrder := getLayoutObjects(g, objectsToRemove)
// TODO this isn't a proper deletion because the objects still appear as children of the object
g.Objects = layoutObjects
return sequenceDiagrams, objectOrder, edgeOrder, nil
}
// Layout runs the sequence diagram layout engine on objects of shape sequence_diagram
//
// 1. Traverse graph from root, skip objects with shape not `sequence_diagram`
// 2. Construct a sequence diagram from all descendant objects and edges
// 3. Remove those objects and edges from the main graph
// 4. Run layout on sequence diagrams
// 5. Set the resulting dimensions to the main graph shape
// 6. Run core layouts (still without sequence diagram innards)
// 7. Put back sequence diagram innards in correct location
func Layout(ctx context.Context, g *d2graph.Graph, layout func(ctx context.Context, g *d2graph.Graph) error) error {
sequenceDiagrams, objectOrder, edgeOrder, err := WithoutSequenceDiagrams(ctx, g)
if err != nil {
return err
}
if g.Root.IsSequenceDiagram() {
// the sequence diagram is the only layout engine if the whole diagram is
// shape: sequence_diagram
@ -89,7 +98,7 @@ func layoutSequenceDiagram(g *d2graph.Graph, obj *d2graph.Object) (*sequenceDiag
var edges []*d2graph.Edge
for _, edge := range g.Edges {
// both Src and Dst must be inside the sequence diagram
if strings.HasPrefix(edge.Src.AbsID(), obj.AbsID()) && strings.HasPrefix(edge.Dst.AbsID(), obj.AbsID()) {
if obj == g.Root || (strings.HasPrefix(edge.Src.AbsID(), obj.AbsID()+".") && strings.HasPrefix(edge.Dst.AbsID(), obj.AbsID()+".")) {
edges = append(edges, edge)
}
}

View file

@ -74,6 +74,7 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
// Groups may have more nested groups
for len(queue) > 0 {
curr := queue[0]
curr.LabelPosition = go2.Pointer(string(label.InsideTopLeft))
groups = append(groups, curr)
queue = queue[1:]
queue = append(queue, curr.ChildrenArray...)
@ -299,14 +300,19 @@ func (sd *sequenceDiagram) placeActors() {
// │
// │
func (sd *sequenceDiagram) addLifelineEdges() {
lastRoute := sd.messages[len(sd.messages)-1].Route
endY := 0.
for _, p := range lastRoute {
endY = math.Max(endY, p.Y)
if len(sd.messages) > 0 {
lastRoute := sd.messages[len(sd.messages)-1].Route
for _, p := range lastRoute {
endY = math.Max(endY, p.Y)
}
}
for _, note := range sd.notes {
endY = math.Max(endY, note.TopLeft.Y+note.Height)
}
for _, actor := range sd.actors {
endY = math.Max(endY, actor.TopLeft.Y+actor.Height)
}
endY += sd.yStep
for _, actor := range sd.actors {
@ -461,8 +467,8 @@ func (sd *sequenceDiagram) routeMessages() error {
} else {
return fmt.Errorf("could not find center of %s", message.Dst.AbsID())
}
isToDescendant := strings.HasPrefix(message.Dst.AbsID(), message.Src.AbsID())
isFromDescendant := strings.HasPrefix(message.Src.AbsID(), message.Dst.AbsID())
isToDescendant := strings.HasPrefix(message.Dst.AbsID(), message.Src.AbsID()+".")
isFromDescendant := strings.HasPrefix(message.Src.AbsID(), message.Dst.AbsID()+".")
isSelfMessage := message.Src == message.Dst
if isSelfMessage || isToDescendant || isFromDescendant {

View file

@ -9,7 +9,9 @@ import (
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2exporter"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2near"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/textmeasure"
)
@ -20,7 +22,13 @@ type CompileOptions struct {
Ruler *textmeasure.Ruler
Layout func(context.Context, *d2graph.Graph) error
ThemeID int64
// FontFamily controls the font family used for all texts that are not the following:
// - code
// - latex
// - pre-measured (web setting)
// TODO maybe some will want to configure code font too, but that's much lower priority
FontFamily *d2fonts.FontFamily
ThemeID int64
}
func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target.Diagram, *d2graph.Graph, error) {
@ -36,19 +44,30 @@ func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target
}
if len(g.Objects) > 0 {
err = g.SetDimensions(opts.MeasuredTexts, opts.Ruler)
err = g.SetDimensions(opts.MeasuredTexts, opts.Ruler, opts.FontFamily)
if err != nil {
return nil, nil, err
}
if layout, err := getLayout(opts); err != nil {
coreLayout, err := getLayout(opts)
if err != nil {
return nil, nil, err
} else if err := d2sequence.Layout(ctx, g, layout); err != nil {
}
constantNears := d2near.WithoutConstantNears(ctx, g)
err = d2sequence.Layout(ctx, g, coreLayout)
if err != nil {
return nil, nil, err
}
err = d2near.Layout(ctx, g, constantNears)
if err != nil {
return nil, nil, err
}
}
diagram, err := d2exporter.Export(ctx, g, opts.ThemeID)
diagram, err := d2exporter.Export(ctx, g, opts.ThemeID, opts.FontFamily)
return diagram, g, err
}

View file

@ -8,9 +8,9 @@ import (
"io"
"github.com/spf13/pflag"
"oss.terrastruct.com/util-go/xmain"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/util-go/xmain"
)
// Serve returns a xmain.RunFunc that will invoke the plugin p as necessary to service the

View file

@ -1,6 +1,7 @@
// d2fonts holds fonts for renderings
// TODO write a script to do this as part of CI
// Currently using an online converter: https://dopiaza.org/tools/datauri/index.php
package d2fonts
import (
@ -8,7 +9,7 @@ import (
"strings"
)
type FontFamily int
type FontFamily string
type FontStyle string
type Font struct {
@ -38,8 +39,9 @@ const (
FONT_STYLE_BOLD FontStyle = "bold"
FONT_STYLE_ITALIC FontStyle = "italic"
SourceSansPro FontFamily = iota
SourceCodePro FontFamily = iota
SourceSansPro FontFamily = "SourceSansPro"
SourceCodePro FontFamily = "SourceCodePro"
HandDrawn FontFamily = "HandDrawn"
)
var FontSizes = []int{
@ -61,6 +63,7 @@ var FontStyles = []FontStyle{
var FontFamilies = []FontFamily{
SourceSansPro,
SourceCodePro,
HandDrawn,
}
//go:embed encoded/SourceSansPro-Regular.txt
@ -75,6 +78,12 @@ var sourceSansProItalicBase64 string
//go:embed encoded/SourceCodePro-Regular.txt
var sourceCodeProRegularBase64 string
//go:embed encoded/ArchitectsDaughter-Regular.txt
var architectsDaughterRegularBase64 string
//go:embed encoded/FuzzyBubbles-Bold.txt
var fuzzyBubblesBoldBase64 string
//go:embed ttf/*
var fontFacesFS embed.FS
@ -99,6 +108,19 @@ func init() {
Family: SourceCodePro,
Style: FONT_STYLE_REGULAR,
}: sourceCodeProRegularBase64,
{
Family: HandDrawn,
Style: FONT_STYLE_REGULAR,
}: architectsDaughterRegularBase64,
{
Family: HandDrawn,
Style: FONT_STYLE_ITALIC,
// This font has no italic, so just reuse regular
}: architectsDaughterRegularBase64,
{
Family: HandDrawn,
Style: FONT_STYLE_BOLD,
}: fuzzyBubblesBoldBase64,
}
for k, v := range FontEncodings {
@ -138,4 +160,24 @@ func init() {
Family: SourceSansPro,
Style: FONT_STYLE_ITALIC,
}] = b
b, err = fontFacesFS.ReadFile("ttf/ArchitectsDaughter-Regular.ttf")
if err != nil {
panic(err)
}
FontFaces[Font{
Family: HandDrawn,
Style: FONT_STYLE_REGULAR,
}] = b
FontFaces[Font{
Family: HandDrawn,
Style: FONT_STYLE_ITALIC,
}] = b
b, err = fontFacesFS.ReadFile("ttf/FuzzyBubbles-Bold.ttf")
if err != nil {
panic(err)
}
FontFaces[Font{
Family: HandDrawn,
Style: FONT_STYLE_BOLD,
}] = b
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View file

@ -8,6 +8,7 @@ import (
"strconv"
"github.com/dop251/goja"
"oss.terrastruct.com/util-go/xdefer"
)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
const root = {
ownerDocument: {
createElementNS: (ns, tagName) => {
const children = [];
const attrs = {};
const style = {};
return {
style,
tagName,
attrs,
setAttribute: (key, value) => (attrs[key] = value),
appendChild: (node) => children.push(node),
children,
};
},
},
};
const rc = rough.svg(root, { seed: 1 });
let node;

View file

@ -0,0 +1,498 @@
package d2sketch
import (
"encoding/json"
"fmt"
"strings"
_ "embed"
"github.com/dop251/goja"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/svg"
"oss.terrastruct.com/util-go/go2"
)
//go:embed fillpattern.svg
var fillPattern string
//go:embed rough.js
var roughJS string
//go:embed setup.js
var setupJS string
type Runner goja.Runtime
var baseRoughProps = `fillWeight: 2.0,
hachureGap: 16,
fillStyle: "solid",
bowing: 2,
seed: 1,`
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
}
if _, err := vm.RunString(setupJS); err != nil {
return nil, err
}
r := Runner(*vm)
return &r, nil
}
// DefineFillPattern adds a reusable pattern that is overlayed on shapes with
// fill. This gives it a subtle streaky effect that subtly looks hand-drawn but
// not distractingly so.
func DefineFillPattern() string {
return fmt.Sprintf(`<defs>
<pattern id="streaks"
x="0" y="0" width="100" height="100"
patternUnits="userSpaceOnUse" >
%s
</pattern>
</defs>`, fillPattern)
}
func shapeStyle(shape d2target.Shape) string {
out := ""
if shape.Type == d2target.ShapeSQLTable || shape.Type == d2target.ShapeClass {
out += fmt.Sprintf(`fill:%s;`, shape.Stroke)
out += fmt.Sprintf(`stroke:%s;`, shape.Fill)
} else {
out += fmt.Sprintf(`fill:%s;`, shape.Fill)
out += fmt.Sprintf(`stroke:%s;`, shape.Stroke)
}
out += fmt.Sprintf(`opacity:%f;`, shape.Opacity)
out += fmt.Sprintf(`stroke-width:%d;`, shape.StrokeWidth)
if shape.StrokeDash != 0 {
dashSize, gapSize := svg.GetStrokeDashAttributes(float64(shape.StrokeWidth), shape.StrokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
}
return out
}
func Rect(r *Runner, shape d2target.Shape) (string, error) {
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPaths(r, js)
if err != nil {
return "", err
}
output := ""
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shapeStyle(shape),
)
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%d" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height,
)
return output, nil
}
func Oval(r *Runner, shape d2target.Shape) (string, error) {
js := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
fill: "%s",
stroke: "%s",
strokeWidth: %d,
%s
});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPaths(r, js)
if err != nil {
return "", err
}
output := ""
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shapeStyle(shape),
)
}
output += fmt.Sprintf(
`<ellipse class="sketch-overlay" transform="translate(%d %d)" rx="%d" ry="%d" />`,
shape.Pos.X+shape.Width/2, shape.Pos.Y+shape.Height/2, shape.Width/2, shape.Height/2,
)
return output, nil
}
// TODO need to personalize this per shape like we do in Terrastruct app
func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
output := ""
for _, path := range paths {
js := fmt.Sprintf(`node = rc.path("%s", {
fill: "%s",
stroke: "%s",
strokeWidth: %d,
%s
});`, path, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
sketchPaths, err := computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range sketchPaths {
output += fmt.Sprintf(
`<path class="shape" d="%s" style="%s" />`,
p, shapeStyle(shape),
)
}
for _, p := range sketchPaths {
output += fmt.Sprintf(
`<path class="sketch-overlay" d="%s" />`,
p,
)
}
}
return output, nil
}
func connectionStyle(connection d2target.Connection) string {
out := ""
out += fmt.Sprintf(`stroke:%s;`, connection.Stroke)
out += fmt.Sprintf(`opacity:%f;`, connection.Opacity)
out += fmt.Sprintf(`stroke-width:%d;`, connection.StrokeWidth)
if connection.StrokeDash != 0 {
dashSize, gapSize := svg.GetStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
}
return out
}
func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) {
roughness := 1.0
js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness)
paths, err := computeRoughPaths(r, js)
if err != nil {
return "", err
}
output := ""
for _, p := range paths {
output += fmt.Sprintf(
`<path class="connection" fill="none" d="%s" style="%s" %s/>`,
p, connectionStyle(connection), attrs,
)
}
return output, nil
}
// TODO cleanup
func Table(r *Runner, shape d2target.Shape) (string, error) {
output := ""
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shapeStyle(shape),
)
}
box := geo.NewBox(
geo.NewPoint(float64(shape.Pos.X), float64(shape.Pos.Y)),
float64(shape.Width),
float64(shape.Height),
)
rowHeight := box.Height / float64(1+len(shape.SQLTable.Columns))
headerBox := geo.NewBox(box.TopLeft, box.Width, rowHeight)
js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, {
fill: "%s",
%s
});`, shape.Width, rowHeight, shape.Fill, baseRoughProps)
paths, err = computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range paths {
output += fmt.Sprintf(
`<path class="class_header" transform="translate(%d %d)" d="%s" style="fill:%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.Fill,
)
}
if shape.Label != "" {
tl := label.InsideMiddleLeft.GetPointOnBox(
headerBox,
20,
float64(shape.LabelWidth),
float64(shape.LabelHeight),
)
output += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
"text",
tl.X,
tl.Y+float64(shape.LabelHeight)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
"start",
4+shape.FontSize,
shape.Stroke,
),
svg.EscapeText(shape.Label),
)
}
var longestNameWidth int
for _, f := range shape.Columns {
longestNameWidth = go2.Max(longestNameWidth, f.Name.LabelWidth)
}
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
rowBox.TopLeft.Y += headerBox.Height
for _, f := range shape.Columns {
nameTL := label.InsideMiddleLeft.GetPointOnBox(
rowBox,
d2target.NamePadding,
rowBox.Width,
float64(shape.FontSize),
)
constraintTR := label.InsideMiddleRight.GetPointOnBox(
rowBox,
d2target.TypePadding,
0,
float64(shape.FontSize),
)
output += strings.Join([]string{
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
nameTL.X,
nameTL.Y+float64(shape.FontSize)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", float64(shape.FontSize), shape.PrimaryAccentColor),
svg.EscapeText(f.Name.Label),
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
nameTL.X+float64(longestNameWidth)+2*d2target.NamePadding,
nameTL.Y+float64(shape.FontSize)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", float64(shape.FontSize), shape.NeutralAccentColor),
svg.EscapeText(f.Type.Label),
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
constraintTR.X,
constraintTR.Y+float64(shape.FontSize)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;letter-spacing:2px;", "end", float64(shape.FontSize), shape.SecondaryAccentColor),
f.ConstraintAbbr(),
),
}, "\n")
rowBox.TopLeft.Y += rowHeight
js = fmt.Sprintf(`node = rc.line(%f, %f, %f, %f, {
%s
});`, rowBox.TopLeft.X, rowBox.TopLeft.Y, rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y, baseRoughProps)
paths, err = computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range paths {
output += fmt.Sprintf(
`<path d="%s" style="fill:%s" />`,
p, shape.Fill,
)
}
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%d" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height,
)
return output, nil
}
func Class(r *Runner, shape d2target.Shape) (string, error) {
output := ""
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shapeStyle(shape),
)
}
box := geo.NewBox(
geo.NewPoint(float64(shape.Pos.X), float64(shape.Pos.Y)),
float64(shape.Width),
float64(shape.Height),
)
rowHeight := box.Height / float64(2+len(shape.Class.Fields)+len(shape.Class.Methods))
headerBox := geo.NewBox(box.TopLeft, box.Width, 2*rowHeight)
js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, {
fill: "%s",
%s
});`, shape.Width, headerBox.Height, shape.Fill, baseRoughProps)
paths, err = computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range paths {
output += fmt.Sprintf(
`<path class="class_header" transform="translate(%d %d)" d="%s" style="fill:%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.Fill,
)
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%f" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, headerBox.Height,
)
if shape.Label != "" {
tl := label.InsideMiddleCenter.GetPointOnBox(
headerBox,
0,
float64(shape.LabelWidth),
float64(shape.LabelHeight),
)
output += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
"text-mono",
tl.X+float64(shape.LabelWidth)/2,
tl.Y+float64(shape.LabelHeight)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
"middle",
4+shape.FontSize,
shape.Stroke,
),
svg.EscapeText(shape.Label),
)
}
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
rowBox.TopLeft.Y += headerBox.Height
for _, f := range shape.Fields {
output += classRow(shape, rowBox, f.VisibilityToken(), f.Name, f.Type, float64(shape.FontSize))
rowBox.TopLeft.Y += rowHeight
}
js = fmt.Sprintf(`node = rc.line(%f, %f, %f, %f, {
%s
});`, rowBox.TopLeft.X, rowBox.TopLeft.Y, rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y, baseRoughProps)
paths, err = computeRoughPaths(r, js)
if err != nil {
return "", err
}
for _, p := range paths {
output += fmt.Sprintf(
`<path class="class_header" d="%s" style="fill:%s" />`,
p, shape.Fill,
)
}
for _, m := range shape.Methods {
output += classRow(shape, rowBox, m.VisibilityToken(), m.Name, m.Return, float64(shape.FontSize))
rowBox.TopLeft.Y += rowHeight
}
return output, nil
}
func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText string, fontSize float64) string {
output := ""
prefixTL := label.InsideMiddleLeft.GetPointOnBox(
box,
d2target.PrefixPadding,
box.Width,
fontSize,
)
typeTR := label.InsideMiddleRight.GetPointOnBox(
box,
d2target.TypePadding,
0,
fontSize,
)
output += strings.Join([]string{
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, shape.PrimaryAccentColor),
prefix,
),
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X+d2target.PrefixWidth,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, shape.Fill),
svg.EscapeText(nameText),
),
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
typeTR.X,
typeTR.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;", "end", fontSize, shape.SecondaryAccentColor),
svg.EscapeText(typeText),
),
}, "\n")
return output
}
func computeRoughPaths(r *Runner, js string) ([]string, error) {
if _, err := r.run(js); err != nil {
return nil, err
}
return extractPaths(r)
}
type attrs struct {
D string `json:"d"`
}
type node struct {
Attrs attrs `json:"attrs"`
}
func extractPaths(r *Runner) ([]string, error) {
val, err := r.run("JSON.stringify(node.children)")
if err != nil {
return nil, err
}
var nodes []node
err = json.Unmarshal([]byte(val.String()), &nodes)
if err != nil {
return nil, err
}
var paths []string
for _, n := range nodes {
paths = append(paths, n.Attrs.D)
}
return paths, nil
}

View file

@ -0,0 +1,344 @@
package d2sketch_test
import (
"context"
"encoding/xml"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"cdr.dev/slog"
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
func TestSketch(t *testing.T) {
t.Parallel()
tcs := []testCase{
{
name: "basic",
script: `a -> b
`,
},
{
name: "child to child",
script: `winter.snow -> summer.sun
`,
},
{
name: "connection label",
script: `a -> b: hello
`,
},
{
name: "twitter",
script: `timeline mixer: "" {
explanation: |md
## **Timeline mixer**
- Inject ads, who-to-follow, onboarding
- Conversation module
- Cursoring,pagination
- Tweat deduplication
- Served data logging
|
}
People discovery: "People discovery \nservice"
admixer: Ad mixer {
fill: "#c1a2f3"
}
onboarding service: "Onboarding \nservice"
timeline mixer -> People discovery
timeline mixer -> onboarding service
timeline mixer -> admixer
container0: "" {
graphql
comment
tlsapi
}
container0.graphql: GraphQL\nFederated Strato Column {
shape: image
icon: https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/GraphQL_Logo.svg/1200px-GraphQL_Logo.svg.png
}
container0.comment: |md
## Tweet/user content hydration, visibility filtering
|
container0.tlsapi: TLS-API (being deprecated)
container0.graphql -> timeline mixer
timeline mixer <- container0.tlsapi
twitter fe: "Twitter Frontend " {
icon: https://icons.terrastruct.com/social/013-twitter-1.svg
shape: image
}
twitter fe -> container0.graphql: iPhone web
twitter fe -> container0.tlsapi: HTTP Android
web: Web {
icon: https://icons.terrastruct.com/azure/Web%20Service%20Color/App%20Service%20Domains.svg
shape: image
}
Iphone: {
icon: 'https://ss7.vzw.com/is/image/VerizonWireless/apple-iphone-12-64gb-purple-53017-mjn13ll-a?$device-lg$'
shape: image
}
Android: {
icon: https://cdn4.iconfinder.com/data/icons/smart-phones-technologies/512/android-phone.png
shape: image
}
web -> twitter fe
timeline scorer: "Timeline\nScorer" {
fill: "#ffdef1"
}
home ranker: Home Ranker
timeline service: Timeline Service
timeline mixer -> timeline scorer: Thrift RPC
timeline mixer -> home ranker: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
timeline mixer -> timeline service
home mixer: Home mixer {
# fill: "#c1a2f3"
}
container0.graphql -> home mixer: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
home mixer -> timeline scorer
home mixer -> home ranker: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
home mixer -> timeline service
manhattan 2: Manhattan
gizmoduck: Gizmoduck
socialgraph: Social graph
tweetypie: Tweety Pie
home mixer -> manhattan 2
home mixer -> gizmoduck
home mixer -> socialgraph
home mixer -> tweetypie
Iphone -> twitter fe
Android -> twitter fe
prediction service2: Prediction Service {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
home scorer: Home Scorer {
fill: "#ffdef1"
}
manhattan: Manhattan
memcache: Memcache {
icon: https://d1q6f0aelx0por.cloudfront.net/product-logos/de041504-0ddb-43f6-b89e-fe04403cca8d-memcached.png
}
fetch: Fetch {
multiple: true
shape: step
}
feature: Feature {
multiple: true
shape: step
}
scoring: Scoring {
multiple: true
shape: step
}
fetch -> feature
feature -> scoring
prediction service: Prediction Service {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
scoring -> prediction service
fetch -> container2.crmixer
home scorer -> manhattan: ""
home scorer -> memcache: ""
home scorer -> prediction service2
home ranker -> home scorer
home ranker -> container2.crmixer: Candidate Fetch
container2: "" {
style.stroke: "#000E3D"
style.fill: "#ffffff"
crmixer: CrMixer {
style.fill: "#F7F8FE"
}
earlybird: EarlyBird
utag: Utag
space: Space
communities: Communities
}
etc: ...etc
home scorer -> etc: Feature Hydration
feature -> manhattan
feature -> memcache
feature -> etc: Candidate sources
`,
},
{
name: "all_shapes",
script: `
rectangle: {shape: "rectangle"}
square: {shape: "square"}
page: {shape: "page"}
parallelogram: {shape: "parallelogram"}
document: {shape: "document"}
cylinder: {shape: "cylinder"}
queue: {shape: "queue"}
package: {shape: "package"}
step: {shape: "step"}
callout: {shape: "callout"}
stored_data: {shape: "stored_data"}
person: {shape: "person"}
diamond: {shape: "diamond"}
oval: {shape: "oval"}
circle: {shape: "circle"}
hexagon: {shape: "hexagon"}
cloud: {shape: "cloud"}
rectangle -> square -> page
parallelogram -> document -> cylinder
queue -> package -> step
callout -> stored_data -> person
diamond -> oval -> circle
hexagon -> cloud
`,
},
{
name: "sql_tables",
script: `users: {
shape: sql_table
id: int
name: string
email: string
password: string
last_login: datetime
}
products: {
shape: sql_table
id: int
price: decimal
sku: string
name: string
}
orders: {
shape: sql_table
id: int
user_id: int
product_id: int
}
shipments: {
shape: sql_table
id: int
order_id: int
tracking_number: string {constraint: primary_key}
status: string
}
users.id <-> orders.user_id
products.id <-> orders.product_id
shipments.order_id <-> orders.id`,
},
{
name: "class",
script: `manager: BatchManager {
shape: class
-num: int
-timeout: int
-pid
+getStatus(): Enum
+getJobs(): "Job[]"
+setTimeout(seconds int)
}
`,
},
}
runa(t, tcs)
}
type testCase struct {
name string
script string
skip bool
}
func runa(t *testing.T, tcs []testCase) {
for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if tc.skip {
t.Skip()
}
t.Parallel()
run(t, tc)
})
}
}
func run(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
ctx = log.Leveled(ctx, slog.LevelDebug)
ruler, err := textmeasure.NewRuler()
if !tassert.Nil(t, err) {
return
}
diagram, _, err := d2lib.Compile(ctx, tc.script, &d2lib.CompileOptions{
Ruler: ruler,
ThemeID: 0,
Layout: d2dagrelayout.Layout,
FontFamily: go2.Pointer(d2fonts.HandDrawn),
})
if !tassert.Nil(t, err) {
return
}
dataPath := filepath.Join("testdata", strings.TrimPrefix(t.Name(), "TestSketch/"))
pathGotSVG := filepath.Join(dataPath, "sketch.got.svg")
svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
Sketch: true,
})
assert.Success(t, err)
err = os.MkdirAll(dataPath, 0755)
assert.Success(t, err)
err = ioutil.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
defer os.Remove(pathGotSVG)
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
assert.Success(t, err)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 298 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 196 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 250 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 196 KiB

View file

@ -0,0 +1,136 @@
{
"name": "",
"fontFamily": "HandDrawn",
"shapes": [
{
"id": "a",
"type": "",
"pos": {
"x": 1,
"y": 0
},
"width": 114,
"height": 126,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#F7F8FE",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "a",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 14,
"labelHeight": 26,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
{
"id": "b",
"type": "",
"pos": {
"x": 0,
"y": 226
},
"width": 115,
"height": 126,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#F7F8FE",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "b",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 15,
"labelHeight": 26,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
}
],
"connections": [
{
"id": "(a -> b)[0]",
"src": "a",
"srcArrow": "none",
"srcLabel": "",
"dst": "b",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "hello",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 31,
"labelHeight": 23,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 57.5,
"y": 126
},
{
"x": 57.5,
"y": 166
},
{
"x": 57.5,
"y": 186
},
{
"x": 57.5,
"y": 226
}
],
"isCurve": true,
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
}
]
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 246 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 384 KiB

View file

@ -0,0 +1,186 @@
// appendix.go writes appendices/footnotes to SVG
// Intended to be run only for static exports, like PNG or PDF.
// SVG exports are already interactive.
package appendix
import (
"fmt"
"regexp"
"strconv"
"strings"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/util-go/go2"
)
// ┌──────────────┐
// │ │
// │ DIAGRAM │
// │ │
// PAD_ │ │
// SIDES │ │
// │ │ │
// │ └──────────────┘
// ▼ ◄────── PAD_TOP
//
// ─────────────────────────
//
//
// 1. asdfasdf
//
// ◄──── SPACER
// 2. qwerqwer
//
//
const (
PAD_TOP = 50
PAD_SIDES = 40
SPACER = 20
FONT_SIZE = 16
ICON_RADIUS = 16
)
var viewboxRegex = regexp.MustCompile(`viewBox=\"([0-9\- ]+)\"`)
var widthRegex = regexp.MustCompile(`width=\"([0-9]+)\"`)
var heightRegex = regexp.MustCompile(`height=\"([0-9]+)\"`)
func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []byte {
svg := string(in)
appendix, w, h := generateAppendix(diagram, ruler, svg)
if h == 0 {
return in
}
viewboxMatch := viewboxRegex.FindStringSubmatch(svg)
viewboxRaw := viewboxMatch[1]
viewboxSlice := strings.Split(viewboxRaw, " ")
viewboxPadLeft, _ := strconv.Atoi(viewboxSlice[0])
viewboxWidth, _ := strconv.Atoi(viewboxSlice[2])
viewboxHeight, _ := strconv.Atoi(viewboxSlice[3])
tl, br := diagram.BoundingBox()
seperator := fmt.Sprintf(`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#0A0F25" />`,
tl.X-PAD_SIDES, br.Y+PAD_TOP, go2.IntMax(w, br.X)+PAD_SIDES, br.Y+PAD_TOP)
appendix = seperator + appendix
w -= viewboxPadLeft
w += PAD_SIDES * 2
if viewboxWidth < w {
viewboxWidth = w
}
viewboxHeight += h + PAD_TOP
newViewbox := fmt.Sprintf(`viewBox="%s %s %s %s"`, viewboxSlice[0], viewboxSlice[1], strconv.Itoa(viewboxWidth), strconv.Itoa(viewboxHeight))
widthMatch := widthRegex.FindStringSubmatch(svg)
heightMatch := heightRegex.FindStringSubmatch(svg)
newWidth := fmt.Sprintf(`width="%s"`, strconv.Itoa(viewboxWidth))
newHeight := fmt.Sprintf(`height="%s"`, strconv.Itoa(viewboxHeight))
svg = strings.Replace(svg, viewboxMatch[0], newViewbox, 1)
svg = strings.Replace(svg, widthMatch[0], newWidth, 1)
svg = strings.Replace(svg, heightMatch[0], newHeight, 1)
if !strings.Contains(svg, `font-family: "font-regular"`) {
appendix += fmt.Sprintf(`<style type="text/css"><![CDATA[
.text {
font-family: "font-regular";
}
@font-face {
font-family: font-regular;
src: url("%s");
}
]]></style>`, d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
}
if !strings.Contains(svg, `font-family: "font-bold"`) {
appendix += fmt.Sprintf(`<style type="text/css"><![CDATA[
.text-bold {
font-family: "font-bold";
}
@font-face {
font-family: font-bold;
src: url("%s");
}
]]></style>`, d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_BOLD)])
}
closingIndex := strings.LastIndex(svg, "</svg>")
svg = svg[:closingIndex] + appendix + svg[closingIndex:]
i := 1
for _, s := range diagram.Shapes {
if s.Tooltip != "" {
// The clip-path has a unique ID, so this won't replace any user icons
// In the existing SVG, the transform places it top-left, so we adjust
svg = strings.Replace(svg, d2svg.TooltipIcon, generateNumberedIcon(i, 0, ICON_RADIUS), 1)
i++
}
if s.Link != "" {
svg = strings.Replace(svg, d2svg.LinkIcon, generateNumberedIcon(i, 0, ICON_RADIUS), 1)
i++
}
}
return []byte(svg)
}
func generateAppendix(diagram *d2target.Diagram, ruler *textmeasure.Ruler, svg string) (string, int, int) {
tl, br := diagram.BoundingBox()
maxWidth, totalHeight := 0, 0
var lines []string
i := 1
for _, s := range diagram.Shapes {
for _, txt := range []string{s.Tooltip, s.Link} {
if txt != "" {
line, w, h := generateLine(i, br.Y+(PAD_TOP*2)+totalHeight, txt, ruler)
i++
lines = append(lines, line)
maxWidth = go2.IntMax(maxWidth, w)
totalHeight += h + SPACER
}
}
}
return fmt.Sprintf(`<g x="%d" y="%d" width="%d" height="100%%">%s</g>
`, tl.X, br.Y, (br.X - tl.X), strings.Join(lines, "\n")), maxWidth, totalHeight
}
func generateNumberedIcon(i, x, y int) string {
line := fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="white" stroke="#DEE1EB" />`,
x+ICON_RADIUS, y, ICON_RADIUS)
line += fmt.Sprintf(`<text class="text-bold" x="%d" y="%d" style="font-size: %dpx;text-anchor:middle;">%d</text>`,
x+ICON_RADIUS, y+5, FONT_SIZE, i)
return line
}
func generateLine(i, y int, text string, ruler *textmeasure.Ruler) (string, int, int) {
mtext := &d2target.MText{
Text: text,
FontSize: FONT_SIZE,
}
dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil)
line := fmt.Sprintf(`<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
0, y, generateNumberedIcon(i, 0, 0))
line += fmt.Sprintf(`<text class="text" x="%d" y="%d" style="font-size: %dpx;">%s</text>`,
ICON_RADIUS*3, y, FONT_SIZE, d2svg.RenderText(text, ICON_RADIUS*3, float64(dims.Height)))
return line, dims.Width + ICON_RADIUS*3, dims.Height
}

View file

@ -0,0 +1,151 @@
package appendix_test
import (
"context"
"encoding/xml"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"cdr.dev/slog"
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/d2svg/appendix"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
func TestAppendix(t *testing.T) {
t.Parallel()
tcs := []testCase{
{
name: "tooltip_wider_than_diagram",
script: `x: { tooltip: Total abstinence is easier than perfect moderation }
y: { tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
`,
},
{
name: "diagram_wider_than_tooltip",
script: `shape: sequence_diagram
customer
issuer
store: { tooltip: Like starbucks or something }
acquirer: { tooltip: I'm not sure what this is }
network
customer bank
store bank
customer: {shape: person}
customer bank: {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/858/858170.png
}
store bank: {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/858/858170.png
}
initial transaction: {
customer -> store: 1 banana please
store -> customer: '$10 dollars'
}
customer.internal -> customer.internal: "thinking: wow, inflation"
customer.internal -> customer bank: checks bank account
customer bank -> customer.internal: 'Savings: $11'
customer."An error in judgement is about to occur"
customer -> store: I can do that, here's my card
payment processor behind the scenes: {
store -> acquirer: Run this card
acquirer -> network: Process to card issuer
simplified: {
network -> issuer: Process this payment
issuer -> customer bank: '$10 debit'
acquirer -> store bank: '$10 credit'
}
}
`,
},
{
name: "links",
script: `x: { link: https://d2lang.com }
y: { link: https://terrastruct.com; tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
`,
},
}
runa(t, tcs)
}
type testCase struct {
name string
script string
skip bool
}
func runa(t *testing.T, tcs []testCase) {
for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if tc.skip {
t.Skip()
}
t.Parallel()
run(t, tc)
})
}
}
func run(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
ctx = log.Leveled(ctx, slog.LevelDebug)
ruler, err := textmeasure.NewRuler()
if !tassert.Nil(t, err) {
return
}
diagram, _, err := d2lib.Compile(ctx, tc.script, &d2lib.CompileOptions{
Ruler: ruler,
ThemeID: 0,
Layout: d2dagrelayout.Layout,
})
if !tassert.Nil(t, err) {
return
}
dataPath := filepath.Join("testdata", strings.TrimPrefix(t.Name(), "TestAppendix/"))
pathGotSVG := filepath.Join(dataPath, "sketch.got.svg")
svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
})
assert.Success(t, err)
svgBytes = appendix.Append(diagram, ruler, svgBytes)
err = os.MkdirAll(dataPath, 0755)
assert.Success(t, err)
err = ioutil.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
defer os.Remove(pathGotSVG)
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
assert.Success(t, err)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 803 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 650 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 650 KiB

View file

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

View file

@ -5,10 +5,10 @@ package d2svg
import (
"bytes"
_ "embed"
"encoding/xml"
"errors"
"fmt"
"hash/fnv"
"html"
"io"
"sort"
"strings"
@ -25,32 +25,50 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2latex"
"oss.terrastruct.com/d2/d2renderers/d2sketch"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
"oss.terrastruct.com/d2/lib/svg"
"oss.terrastruct.com/d2/lib/textmeasure"
)
const (
padding = 100
DEFAULT_PADDING = 100
MIN_ARROWHEAD_STROKE_WIDTH = 2
threeDeeOffset = 15
appendixIconRadius = 16
)
var multipleOffset = geo.NewVector(10, -10)
//go:embed tooltip.svg
var TooltipIcon string
//go:embed link.svg
var LinkIcon string
//go:embed style.css
var styleCSS string
//go:embed sketchstyle.css
var sketchStyleCSS string
//go:embed github-markdown.css
var mdCSS string
func setViewbox(writer io.Writer, diagram *d2target.Diagram) (width int, height int) {
type RenderOpts struct {
Pad int
Sketch bool
}
func setViewbox(writer io.Writer, diagram *d2target.Diagram, pad int) (width int, height int) {
tl, br := diagram.BoundingBox()
w := br.X - tl.X + padding*2
h := br.Y - tl.Y + padding*2
w := br.X - tl.X + pad*2
h := br.Y - tl.Y + pad*2
// TODO minify
// TODO background stuff. e.g. dotted, grid, colors
@ -58,7 +76,7 @@ func setViewbox(writer io.Writer, diagram *d2target.Diagram) (width int, height
<svg
style="background: white;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="%d" height="%d" viewBox="%d %d %d %d">`, w, h, tl.X-padding, tl.Y-padding, w, h)
width="%d" height="%d" viewBox="%d %d %d %d">`, w, h, tl.X-pad, tl.Y-pad, w, h)
return w, h
}
@ -346,8 +364,8 @@ func makeLabelMask(labelTL *geo.Point, width, height int) string {
)
}
func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape) (labelMask string) {
fmt.Fprintf(writer, `<g id="%s">`, escapeText(connection.ID))
func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, _ error) {
fmt.Fprintf(writer, `<g id="%s">`, svg.EscapeText(connection.ID))
var markerStart string
if connection.SrcArrow != d2target.NoArrowhead {
id := arrowheadMarkerID(false, connection)
@ -383,43 +401,26 @@ func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Co
labelTL.Y = math.Round(labelTL.Y)
if label.Position(connection.LabelPosition).IsOnEdge() {
strokeWidth := float64(connection.StrokeWidth)
tl, br := geo.Route(connection.Route).GetBoundingBox()
tl.X -= strokeWidth
tl.Y -= strokeWidth
br.X += strokeWidth
br.Y += strokeWidth
if connection.SrcArrow != d2target.NoArrowhead {
width, height := arrowheadDimensions(connection.SrcArrow, strokeWidth)
tl.X -= width
tl.Y -= height
br.X += width
br.Y += height
}
if connection.DstArrow != d2target.NoArrowhead {
width, height := arrowheadDimensions(connection.DstArrow, strokeWidth)
tl.X -= width
tl.Y -= height
br.X += width
br.Y += height
}
tl.X = math.Min(tl.X, labelTL.X)
tl.Y = math.Min(tl.Y, labelTL.Y)
br.X = math.Max(br.X, labelTL.X+float64(connection.LabelWidth))
br.Y = math.Max(br.Y, labelTL.Y+float64(connection.LabelHeight))
labelMask = makeLabelMask(labelTL, connection.LabelWidth, connection.LabelHeight)
}
}
fmt.Fprintf(writer, `<path d="%s" class="connection" style="fill:none;%s" %s%smask="url(#%s)"/>`,
pathData(connection, idToShape),
connectionStyle(connection),
path := pathData(connection, idToShape)
attrs := fmt.Sprintf(`%s%smask="url(#%s)"`,
markerStart,
markerEnd,
labelMaskID,
)
if sketchRunner != nil {
out, err := d2sketch.Connection(sketchRunner, connection, path, attrs)
if err != nil {
return "", err
}
fmt.Fprintf(writer, out)
} else {
fmt.Fprintf(writer, `<path d="%s" class="connection" style="fill:none;%s" %s/>`,
path, connectionStyle(connection), attrs)
}
if connection.Label != "" {
fontClass := "text"
@ -432,6 +433,11 @@ func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Co
if connection.Color != "" {
fontColor = connection.Color
}
if connection.Fill != "" {
fmt.Fprintf(writer, `<rect x="%f" y="%f" width="%d" height="%d" style="fill:%s" />`,
labelTL.X, labelTL.Y, connection.LabelWidth, connection.LabelHeight, connection.Fill)
}
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", connection.FontSize, fontColor)
x := labelTL.X + float64(connection.LabelWidth)/2
y := labelTL.Y + float64(connection.FontSize)
@ -439,7 +445,7 @@ func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Co
fontClass,
x, y,
textStyle,
renderText(connection.Label, x, float64(connection.LabelHeight)),
RenderText(connection.Label, x, float64(connection.LabelHeight)),
)
}
@ -475,7 +481,7 @@ func renderArrowheadLabel(connection d2target.Connection, text string, position,
return fmt.Sprintf(`<text class="text-italic" x="%f" y="%f" style="%s">%s</text>`,
x, y,
textStyle,
renderText(text, x, height),
RenderText(text, x, height),
)
}
@ -538,7 +544,7 @@ func render3dRect(targetShape d2target.Shape) string {
strings.Join(borderSegments, " "), borderStyle)
// create mask from border stroke, to cut away from the shape fills
maskID := fmt.Sprintf("border-mask-%v", escapeText(targetShape.ID))
maskID := fmt.Sprintf("border-mask-%v", svg.EscapeText(targetShape.ID))
borderMask := strings.Join([]string{
fmt.Sprintf(`<defs><mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
maskID, targetShape.Pos.X, targetShape.Pos.Y-threeDeeOffset, targetShape.Width+threeDeeOffset, targetShape.Height+threeDeeOffset,
@ -584,8 +590,13 @@ func render3dRect(targetShape d2target.Shape) string {
return borderMask + mainRect + renderedSides + renderedBorder
}
func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string, err error) {
fmt.Fprintf(writer, `<g id="%s">`, escapeText(targetShape.ID))
func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, err error) {
closingTag := "</g>"
if targetShape.Link != "" {
fmt.Fprintf(writer, `<a href="%s" xlink:href="%[1]s">`, targetShape.Link)
closingTag += "</a>"
}
fmt.Fprintf(writer, `<g id="%s">`, svg.EscapeText(targetShape.ID))
tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y))
width := float64(targetShape.Width)
height := float64(targetShape.Height)
@ -620,22 +631,48 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
switch targetShape.Type {
case d2target.ShapeClass:
drawClass(writer, targetShape)
fmt.Fprintf(writer, `</g></g>`)
if sketchRunner != nil {
out, err := d2sketch.Class(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprintf(writer, out)
} else {
drawClass(writer, targetShape)
}
fmt.Fprintf(writer, `</g>`)
fmt.Fprintf(writer, closingTag)
return labelMask, nil
case d2target.ShapeSQLTable:
drawTable(writer, targetShape)
fmt.Fprintf(writer, `</g></g>`)
if sketchRunner != nil {
out, err := d2sketch.Table(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprintf(writer, out)
} else {
drawTable(writer, targetShape)
}
fmt.Fprintf(writer, `</g>`)
fmt.Fprintf(writer, closingTag)
return labelMask, nil
case d2target.ShapeOval:
if targetShape.Multiple {
fmt.Fprint(writer, renderOval(multipleTL, width, height, style))
}
fmt.Fprint(writer, renderOval(tl, width, height, style))
if sketchRunner != nil {
out, err := d2sketch.Oval(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprintf(writer, out)
} else {
fmt.Fprint(writer, renderOval(tl, width, height, style))
}
case d2target.ShapeImage:
fmt.Fprintf(writer, `<image href="%s" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Icon.String(),
html.EscapeString(targetShape.Icon.String()),
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
// TODO should standardize "" to rectangle
@ -647,8 +684,16 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X+10, targetShape.Pos.Y-10, targetShape.Width, targetShape.Height, style)
}
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
if sketchRunner != nil {
out, err := d2sketch.Rect(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprintf(writer, out)
} else {
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
}
}
case d2target.ShapeText, d2target.ShapeCode:
default:
@ -659,11 +704,20 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
}
}
for _, pathData := range s.GetSVGPathData() {
fmt.Fprintf(writer, `<path d="%s" style="%s"/>`, pathData, style)
if sketchRunner != nil {
out, err := d2sketch.Paths(sketchRunner, targetShape, s.GetSVGPathData())
if err != nil {
return "", err
}
fmt.Fprintf(writer, out)
} else {
for _, pathData := range s.GetSVGPathData() {
fmt.Fprintf(writer, `<path d="%s" style="%s"/>`, pathData, style)
}
}
}
// Closes the class=shape
fmt.Fprintf(writer, `</g>`)
if targetShape.Icon != nil && targetShape.Type != d2target.ShapeImage {
@ -679,7 +733,7 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
tl := iconPosition.GetPointOnBox(box, label.PADDING, float64(iconSize), float64(iconSize))
fmt.Fprintf(writer, `<image href="%s" x="%f" y="%f" width="%d" height="%d" />`,
targetShape.Icon.String(),
html.EscapeString(targetShape.Icon.String()),
tl.X,
tl.Y,
iconSize,
@ -703,9 +757,11 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
} else if targetShape.Italic {
fontClass += "-italic"
}
if targetShape.Underline {
fontClass += " text-underline"
}
switch targetShape.Type {
case d2target.ShapeCode:
if targetShape.Type == d2target.ShapeCode {
lexer := lexers.Get(targetShape.Language)
if lexer == nil {
return labelMask, fmt.Errorf("code snippet lexer for %s not found", targetShape.Language)
@ -746,29 +802,36 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
fmt.Fprint(writer, "</text>")
}
fmt.Fprintf(writer, "</g></g>")
case d2target.ShapeText:
if targetShape.Language == "latex" {
render, err := d2latex.Render(targetShape.Label)
if err != nil {
return labelMask, err
}
fmt.Fprintf(writer, `<g transform="translate(%f %f)" style="opacity:%f">`, box.TopLeft.X, box.TopLeft.Y, targetShape.Opacity)
fmt.Fprint(writer, render)
fmt.Fprintf(writer, "</g>")
} else {
render, err := textmeasure.RenderMarkdown(targetShape.Label)
if err != nil {
return labelMask, err
}
fmt.Fprintf(writer, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`,
box.TopLeft.X, box.TopLeft.Y, targetShape.Width, targetShape.Height,
)
// we need the self closing form in this svg/xhtml context
render = strings.ReplaceAll(render, "<hr>", "<hr />")
fmt.Fprintf(writer, `<div xmlns="http://www.w3.org/1999/xhtml" class="md">%v</div>`, render)
fmt.Fprint(writer, `</foreignObject></g>`)
} else if targetShape.Type == d2target.ShapeText && targetShape.Language == "latex" {
render, err := d2latex.Render(targetShape.Label)
if err != nil {
return labelMask, err
}
default:
fmt.Fprintf(writer, `<g transform="translate(%f %f)" style="opacity:%f">`, box.TopLeft.X, box.TopLeft.Y, targetShape.Opacity)
fmt.Fprint(writer, render)
fmt.Fprintf(writer, "</g>")
} else if targetShape.Type == d2target.ShapeText && targetShape.Language != "" {
render, err := textmeasure.RenderMarkdown(targetShape.Label)
if err != nil {
return labelMask, err
}
fmt.Fprintf(writer, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`,
box.TopLeft.X, box.TopLeft.Y, targetShape.Width, targetShape.Height,
)
// we need the self closing form in this svg/xhtml context
render = strings.ReplaceAll(render, "<hr>", "<hr />")
var mdStyle string
if targetShape.Fill != "" {
mdStyle = fmt.Sprintf("background-color:%s;", targetShape.Fill)
}
if targetShape.Stroke != "" {
mdStyle += fmt.Sprintf("color:%s;", targetShape.Stroke)
}
fmt.Fprintf(writer, `<div xmlns="http://www.w3.org/1999/xhtml" class="md" style="%s">%v</div>`, mdStyle, render)
fmt.Fprint(writer, `</foreignObject></g>`)
} else {
fontColor := "black"
if targetShape.Color != "" {
fontColor = targetShape.Color
@ -781,26 +844,40 @@ func drawShape(writer io.Writer, targetShape d2target.Shape) (labelMask string,
fontClass,
x, y,
textStyle,
renderText(targetShape.Label, x, float64(targetShape.LabelHeight)),
RenderText(targetShape.Label, x, float64(targetShape.LabelHeight)),
)
if targetShape.Blend {
labelMask = makeLabelMask(labelTL, targetShape.LabelWidth, targetShape.LabelHeight-d2graph.INNER_LABEL_PADDING)
}
}
}
fmt.Fprintf(writer, `</g>`)
rightPadForTooltip := 0
if targetShape.Tooltip != "" {
rightPadForTooltip = 2 * appendixIconRadius
fmt.Fprintf(writer, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
targetShape.Pos.X+targetShape.Width-appendixIconRadius,
targetShape.Pos.Y-appendixIconRadius,
TooltipIcon,
)
fmt.Fprintf(writer, `<title>%s</title>`, targetShape.Tooltip)
}
if targetShape.Link != "" {
fmt.Fprintf(writer, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
targetShape.Pos.X+targetShape.Width-appendixIconRadius-rightPadForTooltip,
targetShape.Pos.Y-appendixIconRadius,
LinkIcon,
)
}
fmt.Fprintf(writer, closingTag)
return labelMask, nil
}
func escapeText(text string) string {
buf := new(bytes.Buffer)
_ = xml.EscapeText(buf, []byte(text))
return buf.String()
}
func renderText(text string, x, height float64) string {
func RenderText(text string, x, height float64) string {
if !strings.Contains(text, "\n") {
return escapeText(text)
return svg.EscapeText(text)
}
rendered := []string{}
lines := strings.Split(text, "\n")
@ -809,7 +886,7 @@ func renderText(text string, x, height float64) string {
if i == 0 {
dy = 0
}
escaped := escapeText(line)
escaped := svg.EscapeText(line)
if escaped == "" {
// if there are multiple newlines in a row we still need text for the tspan to render
escaped = " "
@ -822,12 +899,20 @@ func renderText(text string, x, height float64) string {
func shapeStyle(shape d2target.Shape) string {
out := ""
out += fmt.Sprintf(`fill:%s;`, shape.Fill)
out += fmt.Sprintf(`stroke:%s;`, shape.Stroke)
if shape.Type == d2target.ShapeSQLTable || shape.Type == d2target.ShapeClass {
// Fill is used for header fill in these types
// This fill property is just background of rows
out += fmt.Sprintf(`fill:%s;`, shape.Stroke)
// Stroke (border) of these shapes should match the header fill
out += fmt.Sprintf(`stroke:%s;`, shape.Fill)
} else {
out += fmt.Sprintf(`fill:%s;`, shape.Fill)
out += fmt.Sprintf(`stroke:%s;`, shape.Stroke)
}
out += fmt.Sprintf(`opacity:%f;`, shape.Opacity)
out += fmt.Sprintf(`stroke-width:%d;`, shape.StrokeWidth)
if shape.StrokeDash != 0 {
dashSize, gapSize := getStrokeDashAttributes(float64(shape.StrokeWidth), shape.StrokeDash)
dashSize, gapSize := svg.GetStrokeDashAttributes(float64(shape.StrokeWidth), shape.StrokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
}
@ -841,27 +926,20 @@ func connectionStyle(connection d2target.Connection) string {
out += fmt.Sprintf(`opacity:%f;`, connection.Opacity)
out += fmt.Sprintf(`stroke-width:%d;`, connection.StrokeWidth)
if connection.StrokeDash != 0 {
dashSize, gapSize := getStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash)
dashSize, gapSize := svg.GetStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
}
return out
}
func getStrokeDashAttributes(strokeWidth, dashGapSize float64) (float64, float64) {
// as the stroke width gets thicker, the dash gap gets smaller
scale := math.Log10(-0.6*strokeWidth+10.6)*0.5 + 0.5
scaledDashSize := strokeWidth * dashGapSize
scaledGapSize := scale * scaledDashSize
return scaledDashSize, scaledGapSize
}
func embedFonts(buf *bytes.Buffer) {
func embedFonts(buf *bytes.Buffer, fontFamily *d2fonts.FontFamily) {
content := buf.String()
buf.WriteString(`<style type="text/css"><![CDATA[`)
triggers := []string{
`class="text"`,
`class="text `,
`class="md"`,
}
@ -875,7 +953,35 @@ func embedFonts(buf *bytes.Buffer) {
font-family: font-regular;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_REGULAR)])
break
}
}
triggers = []string{
`text-underline`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
buf.WriteString(`
.text-underline {
text-decoration: underline;
}`)
break
}
}
triggers = []string{
`appendix-icon`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
buf.WriteString(`
.appendix-icon {
filter: drop-shadow(0px 0px 32px rgba(31, 36, 58, 0.1));
}`)
break
}
}
@ -896,7 +1002,7 @@ func embedFonts(buf *bytes.Buffer) {
font-family: font-bold;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_BOLD)])
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_BOLD)])
break
}
}
@ -917,7 +1023,7 @@ func embedFonts(buf *bytes.Buffer) {
font-family: font-italic;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_ITALIC)])
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_ITALIC)])
break
}
}
@ -949,15 +1055,32 @@ func embedFonts(buf *bytes.Buffer) {
}
// TODO minify output at end
func Render(diagram *d2target.Diagram) ([]byte, error) {
buf := &bytes.Buffer{}
w, h := setViewbox(buf, diagram)
func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
var sketchRunner *d2sketch.Runner
pad := DEFAULT_PADDING
if opts != nil {
pad = opts.Pad
if opts.Sketch {
var err error
sketchRunner, err = d2sketch.InitSketchVM()
if err != nil {
return nil, err
}
}
}
buf := &bytes.Buffer{}
w, h := setViewbox(buf, diagram, pad)
styleCSS2 := ""
if sketchRunner != nil {
styleCSS2 = "\n" + sketchStyleCSS
}
buf.WriteString(fmt.Sprintf(`<style type="text/css">
<![CDATA[
%s
%s%s
]]>
</style>`, styleCSS))
</style>`, styleCSS, styleCSS2))
hasMarkdown := false
for _, s := range diagram.Shapes {
@ -969,6 +1092,9 @@ func Render(diagram *d2target.Diagram) ([]byte, error) {
if hasMarkdown {
fmt.Fprintf(buf, `<style type="text/css">%s</style>`, mdCSS)
}
if sketchRunner != nil {
fmt.Fprintf(buf, d2sketch.DefineFillPattern())
}
// only define shadow filter if a shape uses it
for _, s := range diagram.Shapes {
@ -1003,37 +1129,38 @@ func Render(diagram *d2target.Diagram) ([]byte, error) {
markers := map[string]struct{}{}
for _, obj := range allObjects {
if c, is := obj.(d2target.Connection); is {
labelMask := drawConnection(buf, labelMaskID, c, markers, idToShape)
labelMask, err := drawConnection(buf, labelMaskID, c, markers, idToShape, sketchRunner)
if err != nil {
return nil, err
}
if labelMask != "" {
labelMasks = append(labelMasks, labelMask)
}
} else if s, is := obj.(d2target.Shape); is {
labelMask, err := drawShape(buf, s)
labelMask, err := drawShape(buf, s, sketchRunner)
if err != nil {
return nil, err
} else if labelMask != "" {
labelMasks = append(labelMasks, labelMask)
}
} else {
return nil, fmt.Errorf("unknow object of type %T", obj)
return nil, fmt.Errorf("unknown object of type %T", obj)
}
}
if len(labelMasks) > 0 {
fmt.Fprint(buf, strings.Join([]string{
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="0" y="0" width="%d" height="%d">`,
labelMaskID, w, h,
),
fmt.Sprintf(`<rect x="0" y="0" width="%d" height="%d" fill="white"></rect>`,
w,
h,
),
strings.Join(labelMasks, "\n"),
`</mask>`,
}, "\n"))
}
// Note: we always want this since we reference it on connections even if there end up being no masked labels
fmt.Fprint(buf, strings.Join([]string{
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
labelMaskID, -pad, -pad, w, h,
),
fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`,
-pad, -pad, w, h,
),
strings.Join(labelMasks, "\n"),
`</mask>`,
}, "\n"))
embedFonts(buf)
embedFonts(buf, diagram.FontFamily)
buf.WriteString(`</svg>`)
return buf.Bytes(), nil

View file

@ -257,6 +257,7 @@
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
font-family: "font-regular";
}
.md h2 {

View file

@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3440_35088111)">
<path d="M16 31.1109C24.3456 31.1109 31.1111 24.3454 31.1111 15.9998C31.1111 7.65415 24.3456 0.888672 16 0.888672C7.65436 0.888672 0.888885 7.65415 0.888885 15.9998C0.888885 24.3454 7.65436 31.1109 16 31.1109Z" fill="white" stroke="#DEE1EB"/>
<path d="M14.3909 16.7965C14.7364 17.2584 15.1772 17.6406 15.6834 17.9171C16.1896 18.1938 16.7494 18.3582 17.3248 18.3993C17.9001 18.4405 18.4777 18.3575 19.0181 18.1559C19.5586 17.9543 20.0492 17.6389 20.4571 17.2309L22.8708 14.8173C23.6036 14.0586 24.0089 13.0425 23.9998 11.9877C23.9906 10.933 23.5676 9.92404 22.8217 9.17821C22.0759 8.43237 21.067 8.00931 20.0123 8.00015C18.9575 7.99098 17.9413 8.39644 17.1827 9.1292L15.7988 10.505" stroke="#2E3346" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.609 15.1874C17.2635 14.7255 16.8227 14.3433 16.3165 14.0667C15.8103 13.7902 15.2505 13.6257 14.6752 13.5845C14.0998 13.5433 13.5223 13.6263 12.9819 13.8279C12.4414 14.0295 11.9506 14.345 11.5428 14.753L9.1292 17.1666C8.39644 17.9252 7.99098 18.9414 8.00015 19.9962C8.00931 21.0509 8.43237 22.0598 9.17821 22.8056C9.92405 23.5515 10.933 23.9745 11.9877 23.9837C13.0425 23.9928 14.0586 23.5875 14.8173 22.8547L16.193 21.4788" stroke="#2E3346" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_3440_35088111">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,4 @@
.sketch-overlay {
fill: url(#streaks);
mix-blend-mode: overlay;
}

View file

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

View file

@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3427_35082111)">
<path d="M16 31.1109C24.3456 31.1109 31.1111 24.3454 31.1111 15.9998C31.1111 7.65415 24.3456 0.888672 16 0.888672C7.65436 0.888672 0.888885 7.65415 0.888885 15.9998C0.888885 24.3454 7.65436 31.1109 16 31.1109Z" fill="white" stroke="#DEE1EB"/>
<path d="M16 26C21.5228 26 26 21.5228 26 16C26 10.4772 21.5228 6 16 6C10.4772 6 6 10.4772 6 16C6 21.5228 10.4772 26 16 26Z" stroke="#2E3346" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 19.998V15.998" stroke="#2E3346" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 12H16.0098" stroke="#2E3346" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_3427_35082111">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 926 B

View file

@ -6,6 +6,11 @@ import (
"oss.terrastruct.com/d2/d2renderers/d2fonts"
)
const (
PrefixPadding = 10
PrefixWidth = 20
)
type Class struct {
Fields []ClassField `json:"fields"`
Methods []ClassMethod `json:"methods"`
@ -27,6 +32,17 @@ func (cf ClassField) Text() *MText {
}
}
func (cf ClassField) VisibilityToken() string {
switch cf.Visibility {
case "protected":
return "#"
case "private":
return "-"
default:
return "+"
}
}
type ClassMethod struct {
Name string `json:"name"`
Return string `json:"return"`
@ -42,3 +58,14 @@ func (cm ClassMethod) Text() *MText {
Shape: "class",
}
}
func (cm ClassMethod) VisibilityToken() string {
switch cm.Visibility {
case "protected":
return "#"
case "private":
return "-"
default:
return "+"
}
}

View file

@ -10,6 +10,7 @@ import (
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
@ -22,8 +23,9 @@ const (
)
type Diagram struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
FontFamily *d2fonts.FontFamily `json:"fontFamily,omitempty"`
Shapes []Shape `json:"shapes"`
Connections []Connection `json:"connections"`
@ -144,6 +146,11 @@ type Shape struct {
ZIndex int `json:"zIndex"`
Level int `json:"level"`
// These are used for special shapes, sql_table and class
PrimaryAccentColor string `json:"primaryAccentColor,omitempty"`
SecondaryAccentColor string `json:"secondaryAccentColor,omitempty"`
NeutralAccentColor string `json:"neutralAccentColor,omitempty"`
}
func (s *Shape) SetType(t string) {
@ -207,6 +214,7 @@ type Connection struct {
StrokeDash float64 `json:"strokeDash"`
StrokeWidth int `json:"strokeWidth"`
Stroke string `json:"stroke"`
Fill string `json:"fill,omitempty"`
Text
LabelPosition string `json:"labelPosition"`

View file

@ -1,9 +1,11 @@
package d2target
import (
"fmt"
import "oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
const (
NamePadding = 10
TypePadding = 20
HeaderPadding = 20
)
type SQLTable struct {
@ -11,18 +13,40 @@ type SQLTable struct {
}
type SQLColumn struct {
Name string `json:"name"`
Type string `json:"type"`
Name Text `json:"name"`
Type Text `json:"type"`
Constraint string `json:"constraint"`
Reference string `json:"reference"`
}
func (c SQLColumn) Text() *MText {
return &MText{
Text: fmt.Sprintf("%s%s%s%s", c.Name, c.Type, c.Constraint, c.Reference),
FontSize: d2fonts.FONT_SIZE_L,
IsBold: false,
IsItalic: false,
Shape: "sql_table",
func (c SQLColumn) Texts() []*MText {
return []*MText{
{
Text: c.Name.Label,
FontSize: d2fonts.FONT_SIZE_L,
IsBold: false,
IsItalic: false,
Shape: "sql_table",
},
{
Text: c.Type.Label,
FontSize: d2fonts.FONT_SIZE_L,
IsBold: false,
IsItalic: false,
Shape: "sql_table",
},
}
}
func (c SQLColumn) ConstraintAbbr() string {
switch c.Constraint {
case "primary_key":
return "PK"
case "foreign_key":
return "FK"
case "unique":
return "UNQ"
default:
return ""
}
}

View file

@ -1,19 +1,14 @@
# Contributing
<!-- toc -->
- [CI](#ci)
- [Flow](#flow)
- [Logistics](#logistics)
- [Dev](#dev)
* [Content](#content)
* [Tests](#tests)
+ [Running tests](#running-tests)
+ [Chaos tests](#chaos-tests)
* [Documentation](#documentation)
* [Questions](#questions)
<!-- tocstop -->
- <a href="#ci" id="toc-ci">CI</a>
- <a href="#flow" id="toc-flow">Flow</a>
- <a href="#logistics" id="toc-logistics">Logistics</a>
- <a href="#dev" id="toc-dev">Dev</a>
- <a href="#content" id="toc-content">Content</a>
- <a href="#tests" id="toc-tests">Tests</a>
- <a href="#documentation" id="toc-documentation">Documentation</a>
- <a href="#questions" id="toc-questions">Questions</a>
## CI
@ -44,12 +39,6 @@ The simplified D2 flow at a package level looks like:
## Logistics
- **Important**: Contributions to D2 require a CLA. We will never relicense D2, but we
need to retain full copyright for any modifications we might need to make in our
commercial offerings. Please email cla@terrastruct.com with your name and Github
username stating that you agree to [Terrastruct's
CLA](https://terrastruct-site-assets.s3.us-west-1.amazonaws.com/documents/terrastruct_cla.pdf).
You only have to do this the first time you contribute.
- D2 uses Issues as TODOs. No auto-closing on staleness.
- Branch off `master`.
- Prefix pull request titles with a short descriptor of the domain, e.g. `d2renderer: Add

View file

@ -6,14 +6,17 @@ You may install `d2` through any of the following methods.
- <a href="#installsh" id="toc-installsh">install.sh</a>
- <a href="#security" id="toc-security">Security</a>
- <a href="#macos-homebrew" id="toc-macos-homebrew">macOS (Homebrew)</a>
- <a href="#linux" id="toc-linux">Linux</a>
- <a href="#void-linux" id="toc-void-linux">Void Linux</a>
- <a href="#standalone" id="toc-standalone">Standalone</a>
- <a href="#manual" id="toc-manual">Manual</a>
- <a href="#prefix" id="toc-prefix">PREFIX</a>
- <a href="#from-source" id="toc-from-source">From source</a>
- <a href="#source-release" id="toc-source-release">Source Release</a>
- <a href="#windows" id="toc-windows">Windows</a>
- <a href="#msys2" id="toc-msys2">MSYS2</a>
- <a href="#release-archives" id="toc-release-archives">Release archives</a>
- <a href="#wsl" id="toc-wsl">WSL</a>
- <a href="#docker" id="toc-docker">Docker</a>
- <a href="#coming-soon" id="toc-coming-soon">Coming soon</a>
## install.sh
@ -71,13 +74,30 @@ but that is coming soon. [#315](https://github.com/terrastruct/d2/issues/315)
If you're on macOS, you can install with `brew`.
```sh
brew tap terrastruct/d2
brew install d2
```
> The install script above does this automatically if you have `brew` installed and
> are running it on macOS.
You can also install from source with:
```d2
brew install d2 --HEAD
```
## Linux
The following distributions have packages for d2:
### Void Linux
All supported platforms:
```sh
xbps-install d2
```
## Standalone
We publish standalone release archives for every release on Github.
@ -138,9 +158,11 @@ You can always install from source:
go install oss.terrastruct.com/d2@latest
```
You need at least Go v1.18
### Source Release
To install a proper release from source clone the repository and then:
To install a release from source clone the repository and then:
```sh
./ci/release/build.sh --install
@ -149,35 +171,30 @@ To install a proper release from source clone the repository and then:
```
Installing a real release will also install manpages and in the future other assets like
fonts and icons. Furthermore, when installing a non versioned commit, installing a proper
release will ensure that `d2 --version` works correctly by embedding the commit hash into
the `d2` binary.
fonts and icons. Furthermore, when installing a non versioned commit, installing a release
will ensure that `d2 --version` works correctly by embedding the commit hash into the `d2`
binary.
Remember, you need at least Go v1.18
## Windows
d2 builds and runs on Windows:
We have prebuilt releases of d2 available for Windows via `.msi` installers. The installer
will add the `d2` binary to your `$PATH` so that you can execute `d2` in `cmd.exe` or
`pwsh.exe`.
We have prebuilt standalone releases for Windows though they're structured in the same way
as our Unix releases. So after extracting a release, you'll have to manually put the d2
binary into your `$PATH` or add the `bin` directory of the release into your `$PATH`.
### Release archives
See https://www.wikihow.com/Change-the-PATH-Environment-Variable-on-Windows
Then you'll be able to call `d2` from the commandline in `cmd.exe` or `pwsh.exe`.
We intend to have a `.msi` release installer sometime soon that handles putting `d2` into
your `$PATH` for you.
### MSYS2
We also have release archives for Windows structured in the same way as our Unix releases
for use with MSYS2.
<img width="1680" alt="Screenshot 2022-12-06 at 2 55 27 AM" src="https://user-images.githubusercontent.com/10180857/205892927-6f3e116c-1c4a-440a-9972-82c306aa9779.png">
We recommend using [MSYS2](https://www.msys2.org/) or [Git
Bash](https://gitforwindows.org/#bash) (Git Bash is based on MSYS2) for an improved
terminal experience.
See [MSYS2](https://www.msys2.org/) or [Git Bash](https://gitforwindows.org/#bash) (Git
Bash is based on MSYS2).
MSYS2 provides a unix style shell environment that is native to Windows (unlike
[Cygwin](https://www.cygwin.com/)). MSYS2 allows `install.sh` to work, enables proper
[Cygwin](https://www.cygwin.com/)). MSYS2 allows `install.sh` to work, enables automatic
installation of our standalone releases via `make install` and makes the manpage
accessible via `man d2`.
@ -192,9 +209,23 @@ under plain Windows.
aka Windows Subsystem for Linux if that's what you prefer. Installation is just like any
other Linux system.
## Docker
https://hub.docker.com/repository/docker/terrastruct/d2
We publish `amd64` and `arm64` images based on `debian:latest` for each release.
Example usage:
```sh
echo 'x -> y' >helloworld.d2
docker run --rm -it -u "$(id -u):$(id -g)" -v "$PWD:/root/src" \
-p 127.0.0.1:8080:8080 terrastruct/d2:v0.1.2 --watch helloworld.d2
# Visit http://127.0.0.1:8080
```
## Coming soon
- Docker image
- rpm and deb packages
- with repositories and standalone
- homebrew core

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -20,6 +20,8 @@ func main() {
Ruler: ruler,
ThemeID: d2themescatalog.GrapeSoda.ID,
})
out, _ := d2svg.Render(diagram)
out, _ := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
})
_ = ioutil.WriteFile(filepath.Join("out.svg"), out, 0600)
}

View file

@ -18,9 +18,11 @@ import (
func main() {
graph, _ := d2compiler.Compile("", strings.NewReader("x -> y"), nil)
ruler, _ := textmeasure.NewRuler()
_ = graph.SetDimensions(nil, ruler)
_ = graph.SetDimensions(nil, ruler, nil)
_ = d2dagrelayout.Layout(context.Background(), graph)
diagram, _ := d2exporter.Export(context.Background(), graph, d2themescatalog.NeutralDefault.ID)
out, _ := d2svg.Render(diagram)
diagram, _ := d2exporter.Export(context.Background(), graph, d2themescatalog.NeutralDefault.ID, nil)
out, _ := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
})
_ = ioutil.WriteFile(filepath.Join("out.svg"), out, 0600)
}

View file

@ -15,7 +15,7 @@ D2 is built to be hackable -- the language has an API built on top of it to make
programmatically.
Modifying the previous example, this example demonstrates how
[d2oracle](../../../d2oracle) can be used to create a new shape, style it programatically
[d2oracle](../../../d2oracle) can be used to create a new shape, style it programmatically
and then output the modified d2 script.
This makes it easy to build functionality on top of D2. Terrastruct uses the
@ -27,4 +27,4 @@ visual interface.
`d2lib` from the first example is just a wrapper around the lower level APIs. They
can be used directly and this example demonstrates such usage.
This shouldn't be necessary for most usecases.
This shouldn't be necessary for most use cases.

View file

@ -17,9 +17,12 @@ import (
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2layouts/d2elklayout"
"oss.terrastruct.com/d2/d2layouts/d2near"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2target"
@ -88,6 +91,28 @@ func runa(t *testing.T, tcs []testCase) {
}
}
// serde exercises serializing and deserializing the graph
// We want to run all the steps leading up to serialization in the course of regular layout
func serde(t *testing.T, tc testCase, ruler *textmeasure.Ruler) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
g, err := d2compiler.Compile("", strings.NewReader(tc.script), &d2compiler.CompileOptions{
UTF16: false,
})
tassert.Nil(t, err)
if len(g.Objects) > 0 {
err = g.SetDimensions(nil, ruler, nil)
tassert.Nil(t, err)
d2near.WithoutConstantNears(ctx, g)
d2sequence.WithoutSequenceDiagrams(ctx, g)
}
b, err := d2graph.SerializeGraph(g)
tassert.Nil(t, err)
var newG d2graph.Graph
err = d2graph.DeserializeGraph(b, &newG)
tassert.Nil(t, err)
}
func run(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
@ -98,6 +123,8 @@ func run(t *testing.T, tc testCase) {
return
}
serde(t, tc, ruler)
layoutsTested := []string{"dagre", "elk"}
for _, layoutName := range layoutsTested {
@ -125,14 +152,21 @@ func run(t *testing.T, tc testCase) {
dataPath := filepath.Join("testdata", strings.TrimPrefix(t.Name(), "TestE2E/"), layoutName)
pathGotSVG := filepath.Join(dataPath, "sketch.got.svg")
svgBytes, err := d2svg.Render(diagram)
svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
})
assert.Success(t, err)
err = os.MkdirAll(dataPath, 0755)
assert.Success(t, err)
err = ioutil.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
defer os.Remove(pathGotSVG)
// if running from e2ereport.sh, we want to keep .got.svg on a failure
forReport := os.Getenv("E2E_REPORT") != ""
if !forReport {
defer os.Remove(pathGotSVG)
}
// Check that it's valid SVG
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
@ -143,6 +177,9 @@ func run(t *testing.T, tc testCase) {
err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
assert.Success(t, err)
}
if forReport {
os.Remove(pathGotSVG)
}
}
}

View file

@ -34,6 +34,255 @@ A->B`,
script: `shape: sequence_diagram
b.1 -> b.1
b.1 -> b.1`,
}, {
name: "sequence_diagram_no_message",
script: `shape: sequence_diagram
a: A
b: B`,
},
{
name: "sequence_diagram_name_crash",
script: `foo: {
shape: sequence_diagram
a -> b
}
foobar: {
shape: sequence_diagram
c -> d
}
foo -> foobar`,
},
{
name: "sql_table_overflow",
script: `
table: sql_table_overflow {
shape: sql_table
short: loooooooooooooooooooong
loooooooooooooooooooong: short
}
table_constrained: sql_table_constrained_overflow {
shape: sql_table
short: loooooooooooooooooooong {
constraint: unique
}
loooooooooooooooooooong: short {
constraint: foreign_key
}
}
`,
},
{
name: "elk_alignment",
script: `
direction: down
build_workflow: lambda-build.yaml {
push: Push to main branch {
style.font-size: 25
}
GHA: GitHub Actions {
style.font-size: 25
}
S3.style.font-size: 25
Terraform.style.font-size: 25
AWS.style.font-size: 25
push -> GHA: Triggers {
style.font-size: 20
}
GHA -> S3: Builds zip and pushes it {
style.font-size: 20
}
S3 <-> Terraform: Pulls zip to deploy {
style.font-size: 20
}
Terraform -> AWS: Changes live lambdas {
style.font-size: 20
}
}
deploy_workflow: lambda-deploy.yaml {
manual: Manual Trigger {
style.font-size: 25
}
GHA: GitHub Actions {
style.font-size: 25
}
AWS.style.font-size: 25
Manual -> GHA: Launches {
style.font-size: 20
}
GHA -> AWS: Builds zip\npushes them to S3.\n\nDeploys lambdas\nusing Terraform {
style.font-size: 20
}
}
apollo_workflow: apollo-deploy.yaml {
apollo: Apollo Repo {
style.font-size: 25
}
GHA: GitHub Actions {
style.font-size: 25
}
AWS.style.font-size: 25
apollo -> GHA: Triggered manually/push to master test test test test test test test {
style.font-size: 20
}
GHA -> AWS: test {
style.font-size: 20
}
}
`,
},
{
name: "dagre_edge_label_spacing",
script: `direction: right
build_workflow: lambda-build.yaml {
push: Push to main branch {
style.font-size: 25
}
GHA: GitHub Actions {
style.font-size: 25
}
S3.style.font-size: 25
Terraform.style.font-size: 25
AWS.style.font-size: 25
push -> GHA: Triggers
GHA -> S3: Builds zip & pushes it
S3 <-> Terraform: Pulls zip to deploy
Terraform -> AWS: Changes the live lambdas
}
`,
},
{
name: "query_param_escape",
script: `my network: {
icon: https://icons.terrastruct.com/infra/019-network.svg?fuga=1&hoge
}
`,
},
{
name: "elk_order",
script: `queue: {
shape: queue
label: ''
M0
M1
M2
M3
M4
M5
M6
}
m0_desc: |md
Oldest message
|
m0_desc -> queue.M0
m2_desc: |md
Offset
|
m2_desc -> queue.M2
m5_desc: |md
Last message
|
m5_desc -> queue.M5
m6_desc: |md
Next message will be\
inserted here
|
m6_desc -> queue.M6
`,
},
{
name: "unnamed_class_table_code",
script: `
class -> users -> code
class: "" {
shape: class
-num: int
-timeout: int
-pid
+getStatus(): Enum
+getJobs(): "Job[]"
+setTimeout(seconds int)
}
users: "" {
shape: sql_table
id: int
name: string
email: string
password: string
last_login: datetime
}
code: |go
a := 5
b := a + 7
fmt.Printf("%d", b)
|
`,
},
{
name: "elk_img_empty_label_panic",
script: `
img: {
label: ""
shape: image
icon: https://icons.terrastruct.com/infra/019-network.svg
}
ico: {
label: ""
icon: https://icons.terrastruct.com/infra/019-network.svg
}
`,
},
{
name: "only_header_class_table",
script: `
class: RefreshAuthorizationPolicyProtocolServerSideTranslatorProtocolBuffer {
shape: class
}
table: RefreshAuthorizationPolicyCache {
shape: sql_table
}
table with short col: RefreshAuthorizationPolicyCache {
shape: sql_table
ok
}
class -> table -> table with short col
`,
},
}

View file

@ -840,7 +840,7 @@ a -> md -> b
name: string
email: string
password: string
last_login: datetime
last_login: datetime { constraint: primary_key }
}
products: {
@ -917,12 +917,13 @@ y: {
}
}
x -> y: {
x -> y: in style {
style: {
stroke: green
opacity: 0.5
stroke-width: 2
stroke-dash: 5
fill: lavender
}
}
`,
@ -1041,6 +1042,7 @@ size S -> size M: custom 15 {
}
size XXXL -> custom 64: custom 48 {
style.font-size: 48
style.fill: lavender
}
`,
}, {
@ -1481,6 +1483,245 @@ a.note: "just\na\nlong\nnote\nhere"`,
script: `shape: sequence_diagram
alice -> bob: what does it mean to be well-adjusted
bob -> alice: The ability to play bridge or golf as if they were games
`,
},
{
name: "markdown_stroke_fill",
script: `
container.md: |md
# a header
a line of text and an
{
indented: "block",
of: "json",
}
walk into a bar.
| {
style.stroke: darkorange
}
container -> no container
no container: |md
they did it in style
|
no container.style: {
stroke: red
fill: "#CEEDEE"
}
`,
},
{
name: "overlapping_image_container_labels",
script: `
root: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
root -> container.root
container: {
root: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
left: {
root: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
inner: {
left: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
right: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
}
root -> inner.left: {
label: to inner left
}
root -> inner.right: {
label: to inner right
}
}
right: {
root: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
inner: {
left: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
right: {
shape: image
icon: https://icons.terrastruct.com/essentials/004-picture.svg
}
}
root -> inner.left: {
label: to inner left
}
root -> inner.right: {
label: to inner right
}
}
root -> left.root: {
label: to left container root
}
root -> right.root: {
label: to right container root
}
}
`,
},
{
name: "constant_near_stress",
script: `x -> y
The top of the mountain: { shape: text; near: top-center }
Joe: { shape: person; near: center-left }
Donald: { shape: person; near: center-right }
bottom: |md
# Cats, no less liquid than their shadows, offer no angles to the wind.
If we can't fix it, it ain't broke.
Dieters live life in the fasting lane.
| { near: bottom-center }
i am top left: { shape: text; near: top-left }
i am top right: { shape: text; near: top-right }
i am bottom left: { shape: text; near: bottom-left }
i am bottom right: { shape: text; near: bottom-right }
`,
},
{
name: "constant_near_title",
script: `title: |md
# A winning strategy
| { near: top-center }
poll the people -> results
results -> unfavorable -> poll the people
results -> favorable -> will of the people
`,
},
{
name: "text_font_sizes",
script: `bear: { shape: text; style.font-size: 22; style.bold: true }
mama bear: { shape: text; style.font-size: 28; style.italic: true }
papa bear: { shape: text; style.font-size: 32; style.underline: true }
mama bear -> bear
papa bear -> bear
`,
},
{
name: "tooltips",
script: `x: { tooltip: Total abstinence is easier than perfect moderation }
y: { tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
`,
},
{
name: "links",
script: `x: { link: https://d2lang.com }
y: { link: https://terrastruct.com; tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
`,
},
{
name: "unnamed_only_width",
script: `
class -> users -> code -> package -> no width
class: "" {
shape: class
-num: int
-timeout: int
-pid
+getStatus(): Enum
+getJobs(): "Job[]"
+setTimeout(seconds int)
}
users: "" {
shape: sql_table
id: int
name: string
email: string
password: string
last_login: datetime
}
code: |go
a := 5
b := a + 7
fmt.Printf("%d", b)
|
package: "" { shape: package }
no width: ""
class.width: 512
users.width: 512
code.width: 512
package.width: 512
`,
},
{
name: "unnamed_only_height",
script: `
class -> users -> code -> package -> no height
class: "" {
shape: class
-num: int
-timeout: int
-pid
+getStatus(): Enum
+getJobs(): "Job[]"
+setTimeout(seconds int)
}
users: "" {
shape: sql_table
id: int
name: string
email: string
password: string
last_login: datetime
}
code: |go
a := 5
b := a + 7
fmt.Printf("%d", b)
|
package: "" { shape: package }
no height: ""
class.height: 512
users.height: 512
code.height: 512
package.height: 512
`,
},
}

View file

@ -0,0 +1,440 @@
{
"name": "",
"fontFamily": "SourceSansPro",
"shapes": [
{
"id": "build_workflow",
"type": "",
"pos": {
"x": 0,
"y": 0
},
"width": 2148,
"height": 237,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#E3E9FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "lambda-build.yaml",
"fontSize": 28,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": false,
"underline": false,
"labelWidth": 226,
"labelHeight": 41,
"labelPosition": "INSIDE_TOP_CENTER",
"zIndex": 0,
"level": 1
},
{
"id": "build_workflow.push",
"type": "",
"pos": {
"x": 105,
"y": 50
},
"width": 330,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "Push to main branch",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 230,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.GHA",
"type": "",
"pos": {
"x": 644,
"y": 50
},
"width": 269,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "GitHub Actions",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 169,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.S3",
"type": "",
"pos": {
"x": 1122,
"y": 50
},
"width": 131,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "S3",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 31,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.Terraform",
"type": "",
"pos": {
"x": 1462,
"y": 50
},
"width": 218,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "Terraform",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 118,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.AWS",
"type": "",
"pos": {
"x": 1889,
"y": 50
},
"width": 155,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "AWS",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 55,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
}
],
"connections": [
{
"id": "build_workflow.(push -> GHA)[0]",
"src": "build_workflow.push",
"srcArrow": "none",
"srcLabel": "",
"dst": "build_workflow.GHA",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Triggers",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 54,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 435.5,
"y": 118.5
},
{
"x": 518.3,
"y": 118.5
},
{
"x": 559.9,
"y": 118.5
},
{
"x": 643.5,
"y": 118.5
}
],
"isCurve": true,
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
},
{
"id": "build_workflow.(GHA -> S3)[0]",
"src": "build_workflow.GHA",
"srcArrow": "none",
"srcLabel": "",
"dst": "build_workflow.S3",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Builds zip & pushes it",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 138,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 913.5,
"y": 118.5
},
{
"x": 996.3,
"y": 118.5
},
{
"x": 1037.9,
"y": 118.5
},
{
"x": 1121.5,
"y": 118.5
}
],
"isCurve": true,
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
},
{
"id": "build_workflow.(S3 <-> Terraform)[0]",
"src": "build_workflow.S3",
"srcArrow": "triangle",
"srcLabel": "",
"dst": "build_workflow.Terraform",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Pulls zip to deploy",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 119,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 1253.5,
"y": 118.5
},
{
"x": 1336.3,
"y": 118.5
},
{
"x": 1377.9,
"y": 118.5
},
{
"x": 1461.5,
"y": 118.5
}
],
"isCurve": true,
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
},
{
"id": "build_workflow.(Terraform -> AWS)[0]",
"src": "build_workflow.Terraform",
"srcArrow": "none",
"srcLabel": "",
"dst": "build_workflow.AWS",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Changes the live lambdas",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 169,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 1680.5,
"y": 118.5
},
{
"x": 1763.3,
"y": 118.5
},
{
"x": 1804.9,
"y": 118.5
},
{
"x": 1888.5,
"y": 118.5
}
],
"isCurve": true,
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
}
]
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 794 KiB

View file

@ -0,0 +1,404 @@
{
"name": "",
"fontFamily": "SourceSansPro",
"shapes": [
{
"id": "build_workflow",
"type": "",
"pos": {
"x": 12,
"y": 12
},
"width": 1893,
"height": 287,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#E3E9FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "lambda-build.yaml",
"fontSize": 28,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": false,
"underline": false,
"labelWidth": 226,
"labelHeight": 41,
"labelPosition": "INSIDE_TOP_CENTER",
"zIndex": 0,
"level": 1
},
{
"id": "build_workflow.push",
"type": "",
"pos": {
"x": 87,
"y": 87
},
"width": 330,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "Push to main branch",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 230,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.GHA",
"type": "",
"pos": {
"x": 511,
"y": 87
},
"width": 269,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "GitHub Actions",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 169,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.S3",
"type": "",
"pos": {
"x": 958,
"y": 87
},
"width": 131,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "S3",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 31,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.Terraform",
"type": "",
"pos": {
"x": 1248,
"y": 87
},
"width": 218,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "Terraform",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 118,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
},
{
"id": "build_workflow.AWS",
"type": "",
"pos": {
"x": 1675,
"y": 87
},
"width": 155,
"height": 137,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#EDF0FD",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "AWS",
"fontSize": 25,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 55,
"labelHeight": 37,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 2
}
],
"connections": [
{
"id": "build_workflow.(push -> GHA)[0]",
"src": "build_workflow.push",
"srcArrow": "none",
"srcLabel": "",
"dst": "build_workflow.GHA",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Triggers",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 54,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 417,
"y": 155.5
},
{
"x": 511,
"y": 155.5
}
],
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
},
{
"id": "build_workflow.(GHA -> S3)[0]",
"src": "build_workflow.GHA",
"srcArrow": "none",
"srcLabel": "",
"dst": "build_workflow.S3",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Builds zip & pushes it",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 138,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 780,
"y": 155.5
},
{
"x": 958,
"y": 155.5
}
],
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
},
{
"id": "build_workflow.(S3 <-> Terraform)[0]",
"src": "build_workflow.S3",
"srcArrow": "triangle",
"srcLabel": "",
"dst": "build_workflow.Terraform",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Pulls zip to deploy",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 119,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 1089,
"y": 155.5
},
{
"x": 1248,
"y": 155.5
}
],
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
},
{
"id": "build_workflow.(Terraform -> AWS)[0]",
"src": "build_workflow.Terraform",
"srcArrow": "none",
"srcLabel": "",
"dst": "build_workflow.AWS",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "Changes the live lambdas",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 169,
"labelHeight": 21,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 1466,
"y": 155.5
},
{
"x": 1675,
"y": 155.5
}
],
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
}
]
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 794 KiB

View file

@ -1,5 +1,6 @@
{
"name": "",
"fontFamily": "SourceSansPro",
"shapes": [
{
"id": "\"ninety\\nnine\"",

View file

@ -18,7 +18,10 @@ width="1427" height="568" viewBox="-100 -100 1427 568"><style type="text/css">
}
]]>
</style><g id="&#34;ninety\nnine&#34;"><g class="shape" ><rect x="0" y="0" width="151" height="142" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="75.500000" y="66.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25"><tspan x="75.500000" dy="0.000000">ninety</tspan><tspan x="75.500000" dy="21.000000">nine</tspan></text></g><g id="eighty&#xD;eight"><g class="shape" ><rect x="211" y="8" width="151" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="286.500000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">eighty&#xD;eight</text></g><g id="&#34;seventy&#xD;\nseven&#34;"><g class="shape" ><rect x="422" y="0" width="162" height="142" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="503.000000" y="66.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25"><tspan x="503.000000" dy="0.000000">seventy&#xD;</tspan><tspan x="503.000000" dy="21.000000">seven</tspan></text></g><g id="&#34;a\\yode&#34;"><g class="shape" ><rect x="644" y="8" width="154" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="721.000000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">a\yode</text></g><g id="there"><g class="shape" ><rect x="864" y="242" width="143" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="935.500000" y="308.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">there</text></g><g id="&#39;a\&#34;ode&#39;"><g class="shape" ><rect x="858" y="8" width="154" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="935.000000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">a\&#34;ode</text></g><g id="&#34;a\\node&#34;"><g class="shape" ><rect x="1072" y="8" width="155" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="1149.500000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">a\node</text></g><g id="(&#34;a\\yode&#34; -&gt; there)[0]"><marker id="mk-3990223579" markerWidth="10.000000" markerHeight="12.000000" refX="7.000000" refY="6.000000" viewBox="0.000000 0.000000 10.000000 12.000000" orient="auto" markerUnits="userSpaceOnUse"> <polygon class="connection" fill="#0D32B2" stroke-width="2" points="0.000000,0.000000 10.000000,6.000000 0.000000,12.000000" /> </marker><path d="M 721.000000 136.000000 C 721.000000 180.400000 749.500000 207.049065 859.962840 265.377574" class="connection" style="fill:none;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" marker-end="url(#mk-3990223579)" mask="url(#4049001157)"/></g><g id="(&#39;a\&#34;ode&#39; -&gt; there)[0]"><path d="M 935.000000 136.000000 C 935.000000 180.400000 935.000000 202.000000 935.000000 238.000000" class="connection" style="fill:none;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" marker-end="url(#mk-3990223579)" mask="url(#4049001157)"/></g><g id="(&#34;a\\node&#34; -&gt; there)[0]"><path d="M 1149.500000 136.000000 C 1149.500000 180.400000 1120.900000 207.000000 1010.042356 265.142121" class="connection" style="fill:none;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" marker-end="url(#mk-3990223579)" mask="url(#4049001157)"/></g><style type="text/css"><![CDATA[
</style><g id="&#34;ninety\nnine&#34;"><g class="shape" ><rect x="0" y="0" width="151" height="142" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="75.500000" y="66.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25"><tspan x="75.500000" dy="0.000000">ninety</tspan><tspan x="75.500000" dy="21.000000">nine</tspan></text></g><g id="eighty&#xD;eight"><g class="shape" ><rect x="211" y="8" width="151" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="286.500000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">eighty&#xD;eight</text></g><g id="&#34;seventy&#xD;\nseven&#34;"><g class="shape" ><rect x="422" y="0" width="162" height="142" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="503.000000" y="66.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25"><tspan x="503.000000" dy="0.000000">seventy&#xD;</tspan><tspan x="503.000000" dy="21.000000">seven</tspan></text></g><g id="&#34;a\\yode&#34;"><g class="shape" ><rect x="644" y="8" width="154" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="721.000000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">a\yode</text></g><g id="there"><g class="shape" ><rect x="864" y="242" width="143" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="935.500000" y="308.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">there</text></g><g id="&#39;a\&#34;ode&#39;"><g class="shape" ><rect x="858" y="8" width="154" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="935.000000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">a\&#34;ode</text></g><g id="&#34;a\\node&#34;"><g class="shape" ><rect x="1072" y="8" width="155" height="126" style="fill:#F7F8FE;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" /></g><text class="text-bold" x="1149.500000" y="74.000000" style="text-anchor:middle;font-size:16px;fill:#0A0F25">a\node</text></g><g id="(&#34;a\\yode&#34; -&gt; there)[0]"><marker id="mk-3990223579" markerWidth="10.000000" markerHeight="12.000000" refX="7.000000" refY="6.000000" viewBox="0.000000 0.000000 10.000000 12.000000" orient="auto" markerUnits="userSpaceOnUse"> <polygon class="connection" fill="#0D32B2" stroke-width="2" points="0.000000,0.000000 10.000000,6.000000 0.000000,12.000000" /> </marker><path d="M 721.000000 136.000000 C 721.000000 180.400000 749.500000 207.049065 859.962840 265.377574" class="connection" style="fill:none;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" marker-end="url(#mk-3990223579)" mask="url(#4049001157)"/></g><g id="(&#39;a\&#34;ode&#39; -&gt; there)[0]"><path d="M 935.000000 136.000000 C 935.000000 180.400000 935.000000 202.000000 935.000000 238.000000" class="connection" style="fill:none;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" marker-end="url(#mk-3990223579)" mask="url(#4049001157)"/></g><g id="(&#34;a\\node&#34; -&gt; there)[0]"><path d="M 1149.500000 136.000000 C 1149.500000 180.400000 1120.900000 207.000000 1010.042356 265.142121" class="connection" style="fill:none;stroke:#0D32B2;opacity:1.000000;stroke-width:2;" marker-end="url(#mk-3990223579)" mask="url(#4049001157)"/></g><mask id="4049001157" maskUnits="userSpaceOnUse" x="-100" y="-100" width="1427" height="568">
<rect x="-100" y="-100" width="1427" height="568" fill="white"></rect>
</mask><style type="text/css"><![CDATA[
.text-bold {
font-family: "font-bold";
}

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 327 KiB

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