Merge branch 'master' into f/add-play-cmd
2
.github/workflows/ci.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
env:
|
||||
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: d2chaos
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ d2renderers/d2latex/polyfills.js
|
|||
d2renderers/d2latex/setup.js
|
||||
d2renderers/d2sketch/rough.js
|
||||
lib/png/generate_png.js
|
||||
d2js
|
||||
|
|
|
|||
5
Makefile
|
|
@ -1,7 +1,7 @@
|
|||
.POSIX:
|
||||
|
||||
.PHONY: all
|
||||
all: fmt gen lint build test
|
||||
all: fmt gen js lint build test
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
|
|
@ -21,3 +21,6 @@ test: fmt
|
|||
.PHONY: race
|
||||
race: fmt
|
||||
prefix "$@" ./ci/test.sh --race ./...
|
||||
.PHONY: js
|
||||
js: gen
|
||||
cd d2js/js && prefix "$@" ./make.sh all
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ let us know and we'll be happy to include it here!
|
|||
|
||||
### Community plugins
|
||||
|
||||
- **Tree-sitter grammar**: [https://git.pleshevski.ru/pleshevskiy/tree-sitter-d2](https://git.pleshevski.ru/pleshevskiy/tree-sitter-d2)
|
||||
- **Tree-sitter grammar**: [https://github.com/ravsii/tree-sitter-d2](https://github.com/ravsii/tree-sitter-d2)
|
||||
- **Emacs major mode**: [https://github.com/andorsk/d2-mode](https://github.com/andorsk/d2-mode)
|
||||
- **Goldmark extension**: [https://github.com/FurqanSoftware/goldmark-d2](https://github.com/FurqanSoftware/goldmark-d2)
|
||||
- **Telegram bot**: [https://github.com/meinside/telegram-d2-bot](https://github.com/meinside/telegram-d2-bot)
|
||||
|
|
|
|||
28
ci/peek-wasm-size.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
|
||||
OUTPUT_FILE="main.wasm"
|
||||
SOURCE_PACKAGE="./d2js"
|
||||
|
||||
echo "Building WASM file..."
|
||||
GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o "$OUTPUT_FILE" "$SOURCE_PACKAGE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Build successful."
|
||||
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
FILE_SIZE_BYTES=$(stat -f%z "$OUTPUT_FILE")
|
||||
FILE_SIZE_MB=$(echo "scale=2; $FILE_SIZE_BYTES / 1024 / 1024" | bc)
|
||||
|
||||
echo "File size of $OUTPUT_FILE: $FILE_SIZE_MB MB"
|
||||
else
|
||||
echo "File $OUTPUT_FILE not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deleting $OUTPUT_FILE..."
|
||||
rm "$OUTPUT_FILE"
|
||||
echo "File deleted."
|
||||
else
|
||||
echo "Build failed."
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
#### Features 🚀
|
||||
|
||||
- Animations: `style.animated: true` is supported on shapes [#2250](https://github.com/terrastruct/d2/pull/2250)
|
||||
- Connections now support `link` [#1955](https://github.com/terrastruct/d2/pull/1955)
|
||||
- Vars: vars in markdown blocks are substituted [#2218](https://github.com/terrastruct/d2/pull/2218)
|
||||
- Markdown: Github-flavored tables work in `md` blocks [#2221](https://github.com/terrastruct/d2/pull/2221)
|
||||
- Render: adds box arrowheads [#2227](https://github.com/terrastruct/d2/issues/2227)
|
||||
- `d2 fmt` now supports a `--check` flag [#2253](https://github.com/terrastruct/d2/pull/2253)
|
||||
- CLI: PNG output to stdout is supported using `--stdout-format png -` [#2291](https://github.com/terrastruct/d2/pull/2291)
|
||||
- Globs: `&connected` and `&leaf` filters are implemented [#2299](https://github.com/terrastruct/d2/pull/2299)
|
||||
- CLI: add --no-xml-tag for direct HTML embedding [#2302](https://github.com/terrastruct/d2/pull/2302)
|
||||
|
||||
#### Improvements 🧹
|
||||
|
||||
|
|
@ -11,8 +18,17 @@
|
|||
- This is a breaking change. Previously Latex blocks required escaping the backslash. So
|
||||
for older D2 versions, you should remove the excess backslashes.
|
||||
- Links: non-http url scheme links are supported (e.g. `x.link: vscode://file/`) [#2237](https://github.com/terrastruct/d2/issues/2237)
|
||||
- Compiler: reserved keywords with missing values error instead of silently doing nothing [#2251](https://github.com/terrastruct/d2/pull/2251)
|
||||
- Render: SVG outputs conform to stricter HTML standards, e.g. no duplicate ids [#2273](https://github.com/terrastruct/d2/issues/2273)
|
||||
- Themes: theme names are consistently cased [#2322](https://github.com/terrastruct/d2/pull/2322)
|
||||
- Nears: constant nears avoid collision with edge routes [#2327](https://github.com/terrastruct/d2/pull/2327)
|
||||
|
||||
#### Bugfixes ⛑️
|
||||
|
||||
- Imports: fixes using substitutions in `icon` values [#2207](https://github.com/terrastruct/d2/pull/2207)
|
||||
- Markdown: fixes ampersands in URLs in markdown [#2219](https://github.com/terrastruct/d2/pull/2219)
|
||||
- Globs: fixes edge case where globs with imported boards would create empty boards [#2247](https://github.com/terrastruct/d2/pull/2247)
|
||||
- Sequence diagrams: fixes alignment of notes when self messages are above it [#2264](https://github.com/terrastruct/d2/pull/2264)
|
||||
- Null: fixes `null`ing a connection with absolute syntax [#2318](https://github.com/terrastruct/d2/issues/2318)
|
||||
- Gradients: works with connection fills [#2326](https://github.com/terrastruct/d2/pull/2326)
|
||||
- Latex: fixes backslashes doubling on successive parses [#2328](https://github.com/terrastruct/d2/pull/2328)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
.Nm d2
|
||||
.Op Fl -watch Ar false
|
||||
.Op Fl -theme Em 0
|
||||
.Op Fl -salt Ar string
|
||||
.Ar file.d2
|
||||
.Op Ar file.svg | file.png
|
||||
.Nm d2
|
||||
|
|
@ -125,12 +126,21 @@ In watch mode, images used in icons are cached for subsequent compilations. This
|
|||
.It Fl -timeout Ar 120
|
||||
The maximum number of seconds that D2 runs for before timing out and exiting. When rendering a large diagram, it is recommended to increase this value
|
||||
.Ns .
|
||||
.It Fl -check Ar false
|
||||
Check that the specified files are formatted correctly
|
||||
.Ns .
|
||||
.It Fl -salt Ar string
|
||||
Add a salt value to ensure the output uses unique IDs. This is useful when generating multiple identical diagrams to be included in the same HTML doc, so that duplicate id's do not cause invalid HTML. The salt value is a string that will be appended to IDs in the output.
|
||||
.Ns .
|
||||
.It Fl h , -help
|
||||
Print usage information and exit
|
||||
.Ns .
|
||||
.It Fl v , -version
|
||||
Print version information and exit
|
||||
.Ns .
|
||||
.It Fl -stdout-format Ar string
|
||||
Set the output format when writing to stdout. Supported formats are: png, svg. Only used when output is set to stdout (-)
|
||||
.Ns .
|
||||
.El
|
||||
.Sh SUBCOMMANDS
|
||||
.Bl -tag -width Fl
|
||||
|
|
@ -145,7 +155,62 @@ Lists available themes
|
|||
.Ns .
|
||||
.It Ar fmt Ar file.d2 ...
|
||||
Format all passed files
|
||||
.It Fl -no-xml-tag Ar false
|
||||
Omit XML tag (<?xml ...?>) from output SVG files. Useful when generating SVGs for direct HTML embedding
|
||||
.Ns .
|
||||
.Ns .
|
||||
.El
|
||||
.Sh ENVIRONMENT VARIABLES
|
||||
Many flags can also be set with environment variables.
|
||||
.Bl -tag -width Ds
|
||||
.It Ev Sy D2_WATCH
|
||||
See -w[atch] flag.
|
||||
.It Ev Sy D2_LAYOUT
|
||||
See -l[ayout] flag.
|
||||
.It Ev Sy D2_THEME
|
||||
See -t[heme] flag.
|
||||
.It Ev Sy D2_DARK_THEME
|
||||
See --dark-theme flag.
|
||||
.It Ev Sy D2_PAD
|
||||
See --pad flag.
|
||||
.It Ev Sy D2_CENTER
|
||||
See --center flag.
|
||||
.It Ev Sy D2_SKETCH
|
||||
See -s[ketch] flag.
|
||||
.It Ev Sy D2_BUNDLE
|
||||
See -b[undle] flag.
|
||||
.It Ev Sy D2_FORCE_APPENDIX
|
||||
See --force-appendix flag.
|
||||
.It Ev Sy D2_FONT_REGULAR
|
||||
See --font-regular flag.
|
||||
.It Ev Sy D2_FONT_ITALIC
|
||||
See --font-italic flag.
|
||||
.It Ev Sy D2_FONT_BOLD
|
||||
See --font-bold flag.
|
||||
.It Ev Sy D2_FONT_SEMIBOLD
|
||||
See --font-semibold flag.
|
||||
.It Ev Sy D2_ANIMATE_INTERVAL
|
||||
See --animate-interval flag.
|
||||
.It Ev Sy D2_TIMEOUT
|
||||
See --timeout flag.
|
||||
.It Ev Sy D2_CHECK
|
||||
See --check flag.
|
||||
.El
|
||||
.Bl -tag -width Ds
|
||||
.It Ev Sy DEBUG
|
||||
See -d[ebug] flag.
|
||||
.It Ev Sy IMG_CACHE
|
||||
See --img-cache flag.
|
||||
.It Ev Sy HOST
|
||||
See -h[ost] flag.
|
||||
.It Ev Sy PORT
|
||||
See -p[ort] flag.
|
||||
.It Ev Sy BROWSER
|
||||
See --browser flag.
|
||||
.It Ev Sy D2_STDOUT_FORMAT
|
||||
See --stdout-format flag.
|
||||
.It Ev Sy D2_NO_XML_TAG
|
||||
See --no-xml-tag flag.
|
||||
.El
|
||||
.Sh SEE ALSO
|
||||
.Xr d2plugin-tala 1
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ var ReservedKeywords map[string]struct{}
|
|||
// Non Style/Holder keywords.
|
||||
var SimpleReservedKeywords = map[string]struct{}{
|
||||
"label": {},
|
||||
"desc": {},
|
||||
"shape": {},
|
||||
"icon": {},
|
||||
"constraint": {},
|
||||
|
|
@ -31,17 +30,17 @@ var SimpleReservedKeywords = map[string]struct{}{
|
|||
|
||||
// ReservedKeywordHolders are reserved keywords that are meaningless on its own and must hold composites
|
||||
var ReservedKeywordHolders = map[string]struct{}{
|
||||
"style": {},
|
||||
"source-arrowhead": {},
|
||||
"target-arrowhead": {},
|
||||
"style": {},
|
||||
}
|
||||
|
||||
// CompositeReservedKeywords are reserved keywords that can hold composites
|
||||
var CompositeReservedKeywords = map[string]struct{}{
|
||||
"classes": {},
|
||||
"constraint": {},
|
||||
"label": {},
|
||||
"icon": {},
|
||||
"source-arrowhead": {},
|
||||
"target-arrowhead": {},
|
||||
"classes": {},
|
||||
"constraint": {},
|
||||
"label": {},
|
||||
"icon": {},
|
||||
}
|
||||
|
||||
// StyleKeywords are reserved keywords which cannot exist outside of the "style" keyword
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package d2cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type exportExtension string
|
||||
|
|
@ -14,6 +16,24 @@ const SVG exportExtension = ".svg"
|
|||
|
||||
var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF}
|
||||
|
||||
var STDOUT_FORMAT_MAP = map[string]exportExtension{
|
||||
"png": PNG,
|
||||
"svg": SVG,
|
||||
}
|
||||
|
||||
var SUPPORTED_STDOUT_FORMATS = []string{"png", "svg"}
|
||||
|
||||
func getOutputFormat(stdoutFormatFlag *string, outputPath string) (exportExtension, error) {
|
||||
if stdoutFormatFlag != nil && *stdoutFormatFlag != "" {
|
||||
format := strings.ToLower(*stdoutFormatFlag)
|
||||
if ext, ok := STDOUT_FORMAT_MAP[format]; ok {
|
||||
return ext, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s is not a supported format. Supported formats are: %s", *stdoutFormatFlag, SUPPORTED_STDOUT_FORMATS)
|
||||
}
|
||||
return getExportExtension(outputPath), nil
|
||||
}
|
||||
|
||||
func getExportExtension(outputPath string) exportExtension {
|
||||
ext := filepath.Ext(outputPath)
|
||||
for _, kext := range SUPPORTED_EXTENSIONS {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
func TestOutputFormat(t *testing.T) {
|
||||
type testCase struct {
|
||||
stdoutFormatFlag string
|
||||
outputPath string
|
||||
extension exportExtension
|
||||
supportsDarkTheme bool
|
||||
|
|
@ -41,6 +42,15 @@ func TestOutputFormat(t *testing.T) {
|
|||
requiresAnimationInterval: false,
|
||||
requiresPngRender: false,
|
||||
},
|
||||
{
|
||||
stdoutFormatFlag: "png",
|
||||
outputPath: "-",
|
||||
extension: PNG,
|
||||
supportsDarkTheme: false,
|
||||
supportsAnimation: false,
|
||||
requiresAnimationInterval: false,
|
||||
requiresPngRender: true,
|
||||
},
|
||||
{
|
||||
outputPath: "/out.png",
|
||||
extension: PNG,
|
||||
|
|
@ -78,7 +88,8 @@ func TestOutputFormat(t *testing.T) {
|
|||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.outputPath, func(t *testing.T) {
|
||||
extension := getExportExtension(tc.outputPath)
|
||||
extension, err := getOutputFormat(&tc.stdoutFormatFlag, tc.outputPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.extension, extension)
|
||||
assert.Equal(t, tc.supportsAnimation, extension.supportsAnimation())
|
||||
assert.Equal(t, tc.supportsDarkTheme, extension.supportsDarkTheme())
|
||||
|
|
|
|||
24
d2cli/fmt.go
|
|
@ -12,9 +12,10 @@ import (
|
|||
|
||||
"oss.terrastruct.com/d2/d2format"
|
||||
"oss.terrastruct.com/d2/d2parser"
|
||||
"oss.terrastruct.com/d2/lib/log"
|
||||
)
|
||||
|
||||
func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
|
||||
func fmtCmd(ctx context.Context, ms *xmain.State, check bool) (err error) {
|
||||
defer xdefer.Errorf(&err, "failed to fmt")
|
||||
|
||||
ms.Opts = xmain.NewOpts(ms.Env, ms.Opts.Flags.Args()[1:])
|
||||
|
|
@ -22,6 +23,8 @@ func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
|
|||
return xmain.UsageErrorf("fmt must be passed at least one file to be formatted")
|
||||
}
|
||||
|
||||
unformattedCount := 0
|
||||
|
||||
for _, inputPath := range ms.Opts.Args {
|
||||
if inputPath != "-" {
|
||||
inputPath = ms.AbsPath(inputPath)
|
||||
|
|
@ -43,10 +46,25 @@ func fmtCmd(ctx context.Context, ms *xmain.State) (err error) {
|
|||
|
||||
output := []byte(d2format.Format(m))
|
||||
if !bytes.Equal(output, input) {
|
||||
if err := ms.WritePath(inputPath, output); err != nil {
|
||||
return err
|
||||
if check {
|
||||
unformattedCount += 1
|
||||
log.Warn(ctx, inputPath)
|
||||
} else {
|
||||
if err := ms.WritePath(inputPath, output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if unformattedCount > 0 {
|
||||
pluralFiles := "file"
|
||||
if unformattedCount > 1 {
|
||||
pluralFiles = "files"
|
||||
}
|
||||
|
||||
return xmain.ExitErrorf(1, "found %d unformatted %s. Run d2 fmt to fix.", unformattedCount, pluralFiles)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdoutFormatFlag := ms.Opts.String("", "stdout-format", "", "", "output format when writing to stdout (svg, png). Usage: d2 input.d2 --stdout-format png - > output.png")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
browserFlag := ms.Opts.String("BROWSER", "browser", "", "", "browser executable that watch opens. Setting to 0 opens no browser.")
|
||||
centerFlag, err := ms.Opts.Bool("D2_CENTER", "center", "c", false, "center the SVG in the containing viewbox, such as your browser screen")
|
||||
if err != nil {
|
||||
|
|
@ -119,6 +124,18 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
fontBoldFlag := ms.Opts.String("D2_FONT_BOLD", "font-bold", "", "", "path to .ttf file to use for the bold font. If none provided, Source Sans Pro Bold is used.")
|
||||
fontSemiboldFlag := ms.Opts.String("D2_FONT_SEMIBOLD", "font-semibold", "", "", "path to .ttf file to use for the semibold font. If none provided, Source Sans Pro Semibold is used.")
|
||||
|
||||
checkFlag, err := ms.Opts.Bool("D2_CHECK", "check", "", false, "check that the specified files are formatted correctly.")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
noXMLTagFlag, err := ms.Opts.Bool("D2_NO_XML_TAG", "no-xml-tag", "", false, "omit XML tag (<?xml ...?>) from output SVG files. Useful when generating SVGs for direct HTML embedding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
saltFlag := ms.Opts.String("", "salt", "", "", "Add a salt value to ensure the output uses unique IDs. This is useful when generating multiple identical diagrams to be included in the same HTML doc, so that duplicate IDs do not cause invalid HTML. The salt value is a string that will be appended to IDs in the output.")
|
||||
|
||||
plugins, err := d2plugin.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -153,7 +170,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
themesCmd(ctx, ms)
|
||||
return nil
|
||||
case "fmt":
|
||||
return fmtCmd(ctx, ms)
|
||||
return fmtCmd(ctx, ms, *checkFlag)
|
||||
case "play":
|
||||
return playCmd(ctx, ms)
|
||||
case "version":
|
||||
|
|
@ -215,7 +232,12 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
if filepath.Ext(outputPath) == ".ppt" {
|
||||
return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?")
|
||||
}
|
||||
outputFormat := getExportExtension(outputPath)
|
||||
|
||||
outputFormat, err := getOutputFormat(stdoutFormatFlag, outputPath)
|
||||
if err != nil {
|
||||
return xmain.UsageErrorf("%v", err)
|
||||
}
|
||||
|
||||
if outputPath != "-" {
|
||||
outputPath = ms.AbsPath(outputPath)
|
||||
if *animateIntervalFlag > 0 && !outputFormat.supportsAnimation() {
|
||||
|
|
@ -305,6 +327,8 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
ThemeID: themeFlag,
|
||||
DarkThemeID: darkThemeFlag,
|
||||
Scale: scale,
|
||||
NoXMLTag: noXMLTagFlag,
|
||||
Salt: saltFlag,
|
||||
}
|
||||
|
||||
if *watchFlag {
|
||||
|
|
@ -327,6 +351,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
forceAppendix: *forceAppendixFlag,
|
||||
pw: pw,
|
||||
fontFamily: fontFamily,
|
||||
outputFormat: outputFormat,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -357,7 +382,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
|
|||
ctx, cancel := timelib.WithTimeout(ctx, time.Minute*2)
|
||||
defer cancel()
|
||||
|
||||
_, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page)
|
||||
_, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page, outputFormat)
|
||||
if err != nil {
|
||||
if written {
|
||||
return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), err)
|
||||
|
|
@ -432,7 +457,7 @@ func RouterResolver(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plu
|
|||
}
|
||||
}
|
||||
|
||||
func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
|
||||
func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page, ext exportExtension) (_ []byte, written bool, _ error) {
|
||||
start := time.Now()
|
||||
input, err := ms.ReadPath(inputPath)
|
||||
if err != nil {
|
||||
|
|
@ -497,7 +522,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
|
|||
plugin, _ := d2plugin.FindPlugin(ctx, plugins, *opts.Layout)
|
||||
|
||||
if animateInterval > 0 {
|
||||
masterID, err := diagram.HashID()
|
||||
masterID, err := diagram.HashID(renderOpts.Salt)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
|
@ -524,7 +549,6 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
|
|||
return nil, false, err
|
||||
}
|
||||
|
||||
ext := getExportExtension(outputPath)
|
||||
switch ext {
|
||||
case GIF:
|
||||
svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, inputPath, diagram)
|
||||
|
|
@ -600,9 +624,9 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
|
|||
var boards [][]byte
|
||||
var err error
|
||||
if noChildren {
|
||||
boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
|
||||
boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext)
|
||||
} else {
|
||||
boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
|
||||
boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
|
|
@ -741,7 +765,7 @@ func relink(currDiagramPath string, d *d2target.Diagram, linkToOutput map[string
|
|||
return nil
|
||||
}
|
||||
|
||||
func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
|
||||
func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, ext exportExtension) ([][]byte, error) {
|
||||
if diagram.Name != "" {
|
||||
ext := filepath.Ext(outputPath)
|
||||
outputPath = strings.TrimSuffix(outputPath, ext)
|
||||
|
|
@ -787,21 +811,21 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
|
|||
|
||||
var boards [][]byte
|
||||
for _, dl := range diagram.Layers {
|
||||
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl)
|
||||
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boards = append(boards, childrenBoards...)
|
||||
}
|
||||
for _, dl := range diagram.Scenarios {
|
||||
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl)
|
||||
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boards = append(boards, childrenBoards...)
|
||||
}
|
||||
for _, dl := range diagram.Steps {
|
||||
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl)
|
||||
childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl, ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -810,7 +834,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
|
|||
|
||||
if !diagram.IsFolderOnly {
|
||||
start := time.Now()
|
||||
out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram)
|
||||
out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram, ext)
|
||||
if err != nil {
|
||||
return boards, err
|
||||
}
|
||||
|
|
@ -824,9 +848,9 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
|
|||
return boards, nil
|
||||
}
|
||||
|
||||
func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
|
||||
func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([][]byte, error) {
|
||||
start := time.Now()
|
||||
out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
|
||||
out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, outputFormat)
|
||||
if err != nil {
|
||||
return [][]byte{}, err
|
||||
}
|
||||
|
|
@ -837,15 +861,16 @@ func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration
|
|||
return [][]byte{out}, nil
|
||||
}
|
||||
|
||||
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
|
||||
toPNG := getExportExtension(outputPath) == PNG
|
||||
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([]byte, error) {
|
||||
toPNG := outputFormat == PNG
|
||||
|
||||
var scale *float64
|
||||
if opts.Scale != nil {
|
||||
scale = opts.Scale
|
||||
} else if toPNG {
|
||||
scale = go2.Pointer(1.)
|
||||
}
|
||||
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
|
||||
renderOpts := &d2svg.RenderOpts{
|
||||
Pad: opts.Pad,
|
||||
Sketch: opts.Sketch,
|
||||
Center: opts.Center,
|
||||
|
|
@ -854,8 +879,11 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts
|
|||
DarkThemeID: opts.DarkThemeID,
|
||||
ThemeOverrides: opts.ThemeOverrides,
|
||||
DarkThemeOverrides: opts.DarkThemeOverrides,
|
||||
NoXMLTag: opts.NoXMLTag,
|
||||
Salt: opts.Salt,
|
||||
Scale: scale,
|
||||
})
|
||||
}
|
||||
svg, err := d2svg.Render(diagram, renderOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -876,12 +904,12 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts
|
|||
bundleErr = multierr.Combine(bundleErr, bundleErr2)
|
||||
}
|
||||
if forceAppendix && !toPNG {
|
||||
svg = appendix.Append(diagram, ruler, svg)
|
||||
svg = appendix.Append(diagram, renderOpts, ruler, svg)
|
||||
}
|
||||
|
||||
out := svg
|
||||
if toPNG {
|
||||
svg := appendix.Append(diagram, ruler, svg)
|
||||
svg := appendix.Append(diagram, renderOpts, ruler, svg)
|
||||
|
||||
if !bundle {
|
||||
var bundleErr2 error
|
||||
|
|
@ -939,7 +967,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
|
|||
scale = go2.Pointer(1.)
|
||||
}
|
||||
|
||||
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
|
||||
renderOpts := &d2svg.RenderOpts{
|
||||
Pad: opts.Pad,
|
||||
Sketch: opts.Sketch,
|
||||
Center: opts.Center,
|
||||
|
|
@ -948,7 +976,8 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
|
|||
DarkThemeID: opts.DarkThemeID,
|
||||
ThemeOverrides: opts.ThemeOverrides,
|
||||
DarkThemeOverrides: opts.DarkThemeOverrides,
|
||||
})
|
||||
}
|
||||
svg, err = d2svg.Render(diagram, renderOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -966,7 +995,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
|
|||
if bundleErr != nil {
|
||||
return svg, bundleErr
|
||||
}
|
||||
svg = appendix.Append(diagram, ruler, svg)
|
||||
svg = appendix.Append(diagram, renderOpts, ruler, svg)
|
||||
|
||||
pngImg, err := ConvertSVG(ms, page, svg)
|
||||
if err != nil {
|
||||
|
|
@ -1045,7 +1074,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
|
|||
|
||||
var err error
|
||||
|
||||
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
|
||||
renderOpts := &d2svg.RenderOpts{
|
||||
Pad: opts.Pad,
|
||||
Sketch: opts.Sketch,
|
||||
Center: opts.Center,
|
||||
|
|
@ -1054,7 +1083,8 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
|
|||
DarkThemeID: opts.DarkThemeID,
|
||||
ThemeOverrides: opts.ThemeOverrides,
|
||||
DarkThemeOverrides: opts.DarkThemeOverrides,
|
||||
})
|
||||
}
|
||||
svg, err = d2svg.Render(diagram, renderOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1073,7 +1103,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
|
|||
return nil, bundleErr
|
||||
}
|
||||
|
||||
svg = appendix.Append(diagram, ruler, svg)
|
||||
svg = appendix.Append(diagram, renderOpts, ruler, svg)
|
||||
|
||||
pngImg, err := ConvertSVG(ms, page, svg)
|
||||
if err != nil {
|
||||
|
|
@ -1291,7 +1321,7 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug
|
|||
} else {
|
||||
scale = go2.Pointer(1.)
|
||||
}
|
||||
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
|
||||
renderOpts := &d2svg.RenderOpts{
|
||||
Pad: opts.Pad,
|
||||
Sketch: opts.Sketch,
|
||||
Center: opts.Center,
|
||||
|
|
@ -1300,7 +1330,8 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug
|
|||
DarkThemeID: opts.DarkThemeID,
|
||||
ThemeOverrides: opts.ThemeOverrides,
|
||||
DarkThemeOverrides: opts.DarkThemeOverrides,
|
||||
})
|
||||
}
|
||||
svg, err = d2svg.Render(diagram, renderOpts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
|
@ -1319,7 +1350,7 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug
|
|||
return nil, nil, bundleErr
|
||||
}
|
||||
|
||||
svg = appendix.Append(diagram, ruler, svg)
|
||||
svg = appendix.Append(diagram, renderOpts, ruler, svg)
|
||||
|
||||
pngImg, err := ConvertSVG(ms, page, svg)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function init(reconnectDelay) {
|
|||
const parsedXML = new DOMParser().parseFromString(msg.svg, "text/xml");
|
||||
d2SVG.replaceChildren(parsedXML.documentElement);
|
||||
changeFavicon("/static/favicon.ico");
|
||||
const svgEl = d2SVG.querySelector("#d2-svg");
|
||||
const svgEl = d2SVG.querySelector(".d2-svg");
|
||||
// just use inner SVG in watch mode
|
||||
svgEl.parentElement.replaceWith(svgEl);
|
||||
let width = parseInt(svgEl.getAttribute("width"), 10);
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ type watcherOpts struct {
|
|||
forceAppendix bool
|
||||
pw png.Playwright
|
||||
fontFamily *d2fonts.FontFamily
|
||||
outputFormat exportExtension
|
||||
}
|
||||
|
||||
type watcher struct {
|
||||
|
|
@ -430,7 +431,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
|
|||
if w.boardPath != "" {
|
||||
boardPath = strings.Split(w.boardPath, string(os.PathSeparator))
|
||||
}
|
||||
svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page)
|
||||
svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page, w.outputFormat)
|
||||
w.boardpathMu.Unlock()
|
||||
errs := ""
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -239,8 +239,6 @@ func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.errorf(class.LastRef().AST(), "class missing value")
|
||||
}
|
||||
|
||||
for _, className := range classNames {
|
||||
|
|
@ -341,9 +339,6 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
|
|||
return
|
||||
}
|
||||
c.compileStyle(&obj.Attributes, f.Map())
|
||||
if obj.Style.Animated != nil {
|
||||
c.errorf(obj.Style.Animated.MapKey, `key "animated" can only be applied to edges`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -508,6 +503,8 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
|
|||
default:
|
||||
c.errorf(f.LastPrimaryKey(), "reserved field %v does not accept composite", f.Name.ScalarString())
|
||||
}
|
||||
} else {
|
||||
c.errorf(f.LastRef().AST(), `reserved field "%v" must have a value`, f.Name.ScalarString())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -681,6 +678,13 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
|
|||
case "classes":
|
||||
}
|
||||
|
||||
if attrs.Link != nil && attrs.Label.Value != "" {
|
||||
u, err := url.ParseRequestURI(attrs.Label.Value)
|
||||
if err == nil && u.Host != "" {
|
||||
c.errorf(scalar, "Label cannot be set to URL when link is also set (for security)")
|
||||
}
|
||||
}
|
||||
|
||||
if attrs.Link != nil && attrs.Tooltip != nil {
|
||||
u, err := url.ParseRequestURI(attrs.Tooltip.Value)
|
||||
if err == nil && u.Host != "" {
|
||||
|
|
@ -813,8 +817,6 @@ func (c *compiler) compileEdgeMap(edge *d2graph.Edge, m *d2ir.Map) {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.errorf(class.LastRef().AST(), "class missing value")
|
||||
}
|
||||
|
||||
for _, className := range classNames {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,18 @@ x: {
|
|||
tassert.Equal(t, "200", g.Objects[0].Top.Value)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reserved_missing_values",
|
||||
text: `foobar: {
|
||||
width
|
||||
bottom
|
||||
left
|
||||
right
|
||||
}
|
||||
`,
|
||||
expErr: `d2/testdata/d2compiler/TestCompile/reserved_missing_values.d2:2:3: reserved field "width" must have a value
|
||||
d2/testdata/d2compiler/TestCompile/reserved_missing_values.d2:4:3: reserved field "left" must have a value`,
|
||||
},
|
||||
{
|
||||
name: "positions_negative",
|
||||
text: `hey: {
|
||||
|
|
@ -1219,7 +1231,6 @@ x: {
|
|||
style.animated: true
|
||||
}
|
||||
`,
|
||||
expErr: `d2/testdata/d2compiler/TestCompile/shape_edge_style.d2:3:2: key "animated" can only be applied to edges`,
|
||||
},
|
||||
{
|
||||
name: "edge_invalid_style",
|
||||
|
|
@ -1527,6 +1538,30 @@ x -> y: {
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "glob-connection-steps",
|
||||
|
||||
text: `*.style.stroke: black
|
||||
|
||||
layers: {
|
||||
ok: @ok
|
||||
}
|
||||
`,
|
||||
files: map[string]string{
|
||||
"ok.d2": `
|
||||
steps: {
|
||||
1: {
|
||||
step1
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
assertions: func(t *testing.T, g *d2graph.Graph) {
|
||||
assert.Equal(t, 0, len(g.Steps))
|
||||
assert.Equal(t, 1, len(g.Layers))
|
||||
assert.Equal(t, 1, len(g.Layers[0].Steps))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "import_url_link",
|
||||
|
||||
|
|
@ -1615,6 +1650,27 @@ a.style.fill: null
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no_url_link_and_path_url_label_concurrently",
|
||||
text: `x -> y: https://google.com {link: https://not-google.com }`,
|
||||
expErr: `d2/testdata/d2compiler/TestCompile/no_url_link_and_path_url_label_concurrently.d2:1:35: Label cannot be set to URL when link is also set (for security)`,
|
||||
},
|
||||
{
|
||||
name: "url_link_and_path_url_label_concurrently",
|
||||
text: `x -> y: hello world {link: https://google.com}`,
|
||||
assertions: func(t *testing.T, g *d2graph.Graph) {
|
||||
if len(g.Edges) != 1 {
|
||||
t.Fatal(len(g.Edges))
|
||||
}
|
||||
if g.Edges[0].Link.Value != "https://google.com" {
|
||||
t.Fatal(g.Edges[0].Link.Value)
|
||||
}
|
||||
|
||||
if g.Edges[0].Label.Value != "hello world" {
|
||||
t.Fatal(g.Edges[0].Label.Value)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil_scope_obj_regression",
|
||||
|
||||
|
|
@ -3022,7 +3078,7 @@ object: {
|
|||
name: "no-class-primary",
|
||||
text: `x.class
|
||||
`,
|
||||
expErr: `d2/testdata/d2compiler/TestCompile/no-class-primary.d2:1:3: class missing value`,
|
||||
expErr: `d2/testdata/d2compiler/TestCompile/no-class-primary.d2:1:3: reserved field "class" must have a value`,
|
||||
},
|
||||
{
|
||||
name: "no-class-inside-classes",
|
||||
|
|
@ -3459,6 +3515,25 @@ svc_1."think about A"
|
|||
svc_1.t2 -> b: do with B
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "layer-import-nested-layer",
|
||||
text: `layers: {
|
||||
ok: {...@meow}
|
||||
}
|
||||
`,
|
||||
files: map[string]string{
|
||||
"meow.d2": `layers: {
|
||||
1: {
|
||||
asdf
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
assertions: func(t *testing.T, g *d2graph.Graph) {
|
||||
tassert.Equal(t, "d2/testdata/d2compiler/TestCompile/layer-import-nested-layer.d2", g.Layers[0].AST.Range.Path)
|
||||
tassert.Equal(t, "d2/testdata/d2compiler/TestCompile/meow.d2", g.Layers[0].Layers[0].AST.Range.Path)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
@ -3810,7 +3885,7 @@ a: null
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "edge",
|
||||
name: "basic-edge",
|
||||
run: func(t *testing.T) {
|
||||
g, _ := assertCompile(t, `
|
||||
a -> b
|
||||
|
|
@ -3820,6 +3895,20 @@ a -> b
|
|||
assert.Equal(t, 0, len(g.Edges))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested-edge",
|
||||
run: func(t *testing.T) {
|
||||
g, _ := assertCompile(t, `
|
||||
a.b.c -> a.d.e
|
||||
a.b.c -> a.d.e
|
||||
|
||||
a.(b.c -> d.e)[0]: null
|
||||
(a.b.c -> a.d.e)[1]: null
|
||||
`, "")
|
||||
assert.Equal(t, 5, len(g.Objects))
|
||||
assert.Equal(t, 0, len(g.Edges))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
run: func(t *testing.T) {
|
||||
|
|
@ -5122,6 +5211,43 @@ y.link: https://google.com
|
|||
assert.Equal(t, "true", g.Objects[1].Attributes.Style.Underline.Value)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaf-filter",
|
||||
run: func(t *testing.T) {
|
||||
g, _ := assertCompile(t, `
|
||||
**: {
|
||||
&leaf: false
|
||||
style.fill: red
|
||||
}
|
||||
a.b.c
|
||||
`, ``)
|
||||
assert.Equal(t, "a", g.Objects[0].ID)
|
||||
assert.Equal(t, "red", g.Objects[0].Attributes.Style.Fill.Value)
|
||||
assert.Equal(t, "b", g.Objects[1].ID)
|
||||
assert.Equal(t, "red", g.Objects[1].Attributes.Style.Fill.Value)
|
||||
assert.Equal(t, "c", g.Objects[2].ID)
|
||||
assert.Equal(t, (*d2graph.Scalar)(nil), g.Objects[2].Attributes.Style.Fill)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "connected-filter",
|
||||
run: func(t *testing.T) {
|
||||
g, _ := assertCompile(t, `
|
||||
*: {
|
||||
&connected: true
|
||||
style.fill: red
|
||||
}
|
||||
a -> b
|
||||
c
|
||||
`, ``)
|
||||
assert.Equal(t, "a", g.Objects[0].ID)
|
||||
assert.Equal(t, "red", g.Objects[0].Attributes.Style.Fill.Value)
|
||||
assert.Equal(t, "b", g.Objects[1].ID)
|
||||
assert.Equal(t, "red", g.Objects[1].Attributes.Style.Fill.Value)
|
||||
assert.Equal(t, "c", g.Objects[2].ID)
|
||||
assert.Equal(t, (*d2graph.Scalar)(nil), g.Objects[2].Attributes.Style.Fill)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "glob-filter",
|
||||
run: func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,9 @@ func toShape(obj *d2graph.Object, g *d2graph.Graph) d2target.Shape {
|
|||
if obj.Tooltip != nil {
|
||||
shape.Tooltip = obj.Tooltip.Value
|
||||
}
|
||||
if obj.Style.Animated != nil {
|
||||
shape.Animated, _ = strconv.ParseBool(obj.Style.Animated.Value)
|
||||
}
|
||||
if obj.Link != nil {
|
||||
shape.Link = obj.Link.Value
|
||||
shape.PrettyLink = toPrettyLink(g, obj.Link.Value)
|
||||
|
|
@ -354,6 +357,9 @@ func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection
|
|||
if edge.Style.Font != nil {
|
||||
connection.FontFamily = edge.Style.Font.Value
|
||||
}
|
||||
if edge.Link != nil {
|
||||
connection.Link = edge.Link.Value
|
||||
}
|
||||
connection.Label = text.Text
|
||||
connection.LabelWidth = text.Dimensions.Width
|
||||
connection.LabelHeight = text.Dimensions.Height
|
||||
|
|
|
|||
|
|
@ -303,10 +303,10 @@ a -> b
|
|||
db, err := compile(ctx, bString)
|
||||
assert.JSON(t, nil, err)
|
||||
|
||||
hashA, err := da.HashID()
|
||||
hashA, err := da.HashID(nil)
|
||||
assert.JSON(t, nil, err)
|
||||
|
||||
hashB, err := db.HashID()
|
||||
hashB, err := db.HashID(nil)
|
||||
assert.JSON(t, nil, err)
|
||||
|
||||
assert.NotEqual(t, hashA, hashB)
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ func (c *compiler) overlayClasses(m *Map) {
|
|||
|
||||
for _, lf := range layers.Fields {
|
||||
if lf.Map() == nil || lf.Primary() != nil {
|
||||
c.errorf(lf.References[0].Context_.Key, "invalid layer")
|
||||
continue
|
||||
}
|
||||
l := lf.Map()
|
||||
|
|
@ -443,22 +442,6 @@ func (g *globContext) copyApplied(from *globContext) {
|
|||
}
|
||||
}
|
||||
|
||||
func (g *globContext) prefixed(dst *Map) *globContext {
|
||||
g2 := g.copy()
|
||||
prefix := d2ast.MakeKeyPathString(RelIDA(g2.refctx.ScopeMap, dst))
|
||||
g2.refctx.Key = g2.refctx.Key.Copy()
|
||||
if g2.refctx.Key.Key != nil {
|
||||
prefix.Path = append(prefix.Path, g2.refctx.Key.Key.Path...)
|
||||
}
|
||||
if len(prefix.Path) > 0 {
|
||||
g2.refctx.Key.Key = prefix
|
||||
}
|
||||
if !g2.refctx.Key.HasTripleGlob() && g2.refctx.Key.EdgeKey != nil {
|
||||
prefix.Path = append(prefix.Path, g2.refctx.Key.EdgeKey.Path...)
|
||||
}
|
||||
return g2
|
||||
}
|
||||
|
||||
func (c *compiler) ampersandFilterMap(dst *Map, ast, scopeAST *d2ast.Map) bool {
|
||||
for _, n := range ast.Nodes {
|
||||
switch {
|
||||
|
|
@ -505,12 +488,24 @@ func (c *compiler) compileMap(dst *Map, ast, scopeAST *d2ast.Map) {
|
|||
if NodeBoardKind(dst) == BoardLayer && !dst.Root() {
|
||||
for _, g := range previousGlobs {
|
||||
if g.refctx.Key.HasTripleGlob() {
|
||||
globs = append(globs, g.prefixed(dst))
|
||||
gctx2 := g.copy()
|
||||
gctx2.refctx.ScopeMap = dst
|
||||
globs = append(globs, gctx2)
|
||||
}
|
||||
}
|
||||
} else if NodeBoardKind(dst) == BoardScenario {
|
||||
for _, g := range previousGlobs {
|
||||
g2 := g.prefixed(dst)
|
||||
gctx2 := g.copy()
|
||||
gctx2.refctx.ScopeMap = dst
|
||||
if !g.refctx.Key.HasMultiGlob() {
|
||||
// Triple globs already apply independently to each board
|
||||
gctx2.copyApplied(g)
|
||||
}
|
||||
globs = append(globs, gctx2)
|
||||
}
|
||||
for _, g := range previousGlobs {
|
||||
g2 := g.copy()
|
||||
g2.refctx.ScopeMap = dst
|
||||
// We don't want globs applied in a given scenario to affect future boards
|
||||
// Copying the applied fields and edges keeps the applications scoped to this board
|
||||
// Note that this is different from steps, where applications carry over
|
||||
|
|
@ -522,7 +517,9 @@ func (c *compiler) compileMap(dst *Map, ast, scopeAST *d2ast.Map) {
|
|||
}
|
||||
} else if NodeBoardKind(dst) == BoardStep {
|
||||
for _, g := range previousGlobs {
|
||||
globs = append(globs, g.prefixed(dst))
|
||||
gctx2 := g.copy()
|
||||
gctx2.refctx.ScopeMap = dst
|
||||
globs = append(globs, gctx2)
|
||||
}
|
||||
} else {
|
||||
globs = append(globs, previousGlobs...)
|
||||
|
|
@ -753,6 +750,33 @@ func (c *compiler) ampersandFilter(refctx *RefContext) bool {
|
|||
},
|
||||
}
|
||||
return c._ampersandFilter(f, refctx)
|
||||
case "leaf":
|
||||
raw := refctx.Key.Value.ScalarBox().Unbox().ScalarString()
|
||||
boolVal, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
c.errorf(refctx.Key, `&leaf must be "true" or "false", got %q`, raw)
|
||||
return false
|
||||
}
|
||||
|
||||
f := refctx.ScopeMap.Parent().(*Field)
|
||||
isLeaf := f.Map() == nil || !f.Map().IsContainer()
|
||||
return isLeaf == boolVal
|
||||
case "connected":
|
||||
raw := refctx.Key.Value.ScalarBox().Unbox().ScalarString()
|
||||
boolVal, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
c.errorf(refctx.Key, `&connected must be "true" or "false", got %q`, raw)
|
||||
return false
|
||||
}
|
||||
f := refctx.ScopeMap.Parent().(*Field)
|
||||
isConnected := false
|
||||
for _, r := range f.References {
|
||||
if r.InEdge() {
|
||||
isConnected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return isConnected == boolVal
|
||||
case "label":
|
||||
f := &Field{}
|
||||
n := refctx.ScopeMap.Parent()
|
||||
|
|
|
|||
37
d2ir/d2ir.go
|
|
@ -987,9 +987,25 @@ func (m *Map) DeleteEdge(eid *EdgeID) *Edge {
|
|||
return nil
|
||||
}
|
||||
|
||||
for i, e := range m.Edges {
|
||||
if e.ID.Match(eid) {
|
||||
m.Edges = append(m.Edges[:i], m.Edges[i+1:]...)
|
||||
resolvedEID, resolvedM, common, err := eid.resolve(m)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(common) > 0 {
|
||||
f := resolvedM.GetField(common...)
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
if f.Map() == nil {
|
||||
return nil
|
||||
}
|
||||
return f.Map().DeleteEdge(resolvedEID)
|
||||
}
|
||||
|
||||
for i, e := range resolvedM.Edges {
|
||||
if e.ID.Match(resolvedEID) {
|
||||
resolvedM.Edges = append(resolvedM.Edges[:i], resolvedM.Edges[i+1:]...)
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
|
@ -1395,7 +1411,14 @@ func (f *Field) AST() d2ast.Node {
|
|||
k.Primary = d2ast.MakeValueBox(f.Primary_.AST().(d2ast.Value)).ScalarBox()
|
||||
}
|
||||
if f.Composite != nil {
|
||||
k.Value = d2ast.MakeValueBox(f.Composite.AST().(d2ast.Value))
|
||||
value := f.Composite.AST().(d2ast.Value)
|
||||
if m, ok := value.(*d2ast.Map); ok {
|
||||
path := m.Range.Path
|
||||
// Treat it as multi-line, but not file-map (line 0)
|
||||
m.Range = d2ast.MakeRange(",1:0:0-2:0:0")
|
||||
m.Range.Path = path
|
||||
}
|
||||
k.Value = d2ast.MakeValueBox(value)
|
||||
}
|
||||
|
||||
return k
|
||||
|
|
@ -1458,6 +1481,12 @@ func (m *Map) AST() d2ast.Node {
|
|||
astMap := &d2ast.Map{
|
||||
Range: d2ast.MakeRange(",0:0:0-1:0:0"),
|
||||
}
|
||||
if m.parent != nil && NodeBoardKind(m) != "" {
|
||||
f, ok := m.parent.(*Field)
|
||||
if ok {
|
||||
astMap.Range.Path = f.Name.GetRange().Path
|
||||
}
|
||||
}
|
||||
for _, f := range m.Fields {
|
||||
astMap.Nodes = append(astMap.Nodes, d2ast.MakeMapNodeBox(f.AST().(d2ast.MapNode)))
|
||||
}
|
||||
|
|
|
|||
71
d2js/d2wasm/api.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package d2wasm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
type D2API struct {
|
||||
exports map[string]js.Func
|
||||
}
|
||||
|
||||
func NewD2API() *D2API {
|
||||
return &D2API{
|
||||
exports: make(map[string]js.Func),
|
||||
}
|
||||
}
|
||||
|
||||
func (api *D2API) Register(name string, fn func(args []js.Value) (interface{}, error)) {
|
||||
api.exports[name] = wrapWASMCall(fn)
|
||||
}
|
||||
|
||||
func (api *D2API) ExportTo(target js.Value) {
|
||||
d2Namespace := make(map[string]interface{})
|
||||
for name, fn := range api.exports {
|
||||
d2Namespace[name] = fn
|
||||
}
|
||||
target.Set("d2", js.ValueOf(d2Namespace))
|
||||
}
|
||||
|
||||
func wrapWASMCall(fn func(args []js.Value) (interface{}, error)) js.Func {
|
||||
return js.FuncOf(func(this js.Value, args []js.Value) (result any) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resp := WASMResponse{
|
||||
Error: &WASMError{
|
||||
Message: fmt.Sprintf("panic recovered: %v\n%s", r, debug.Stack()),
|
||||
Code: 500,
|
||||
},
|
||||
}
|
||||
jsonResp, _ := json.Marshal(resp)
|
||||
result = string(jsonResp)
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := fn(args)
|
||||
if err != nil {
|
||||
wasmErr, ok := err.(*WASMError)
|
||||
if !ok {
|
||||
wasmErr = &WASMError{
|
||||
Message: err.Error(),
|
||||
Code: 500,
|
||||
}
|
||||
}
|
||||
resp := WASMResponse{
|
||||
Error: wasmErr,
|
||||
}
|
||||
jsonResp, _ := json.Marshal(resp)
|
||||
return string(jsonResp)
|
||||
}
|
||||
|
||||
resp := WASMResponse{
|
||||
Data: data,
|
||||
}
|
||||
jsonResp, _ := json.Marshal(resp)
|
||||
return string(jsonResp)
|
||||
})
|
||||
}
|
||||
338
d2js/d2wasm/functions.go
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package d2wasm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
|
||||
"oss.terrastruct.com/d2/d2ast"
|
||||
"oss.terrastruct.com/d2/d2compiler"
|
||||
"oss.terrastruct.com/d2/d2format"
|
||||
"oss.terrastruct.com/d2/d2graph"
|
||||
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
|
||||
"oss.terrastruct.com/d2/d2layouts/d2elklayout"
|
||||
"oss.terrastruct.com/d2/d2lib"
|
||||
"oss.terrastruct.com/d2/d2lsp"
|
||||
"oss.terrastruct.com/d2/d2oracle"
|
||||
"oss.terrastruct.com/d2/d2parser"
|
||||
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
||||
"oss.terrastruct.com/d2/d2renderers/d2svg"
|
||||
"oss.terrastruct.com/d2/lib/log"
|
||||
"oss.terrastruct.com/d2/lib/memfs"
|
||||
"oss.terrastruct.com/d2/lib/textmeasure"
|
||||
"oss.terrastruct.com/d2/lib/urlenc"
|
||||
"oss.terrastruct.com/d2/lib/version"
|
||||
"oss.terrastruct.com/util-go/go2"
|
||||
)
|
||||
|
||||
func GetParentID(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing id argument", Code: 400}
|
||||
}
|
||||
|
||||
id := args[0].String()
|
||||
mk, err := d2parser.ParseMapKey(id)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 400}
|
||||
}
|
||||
|
||||
if len(mk.Edges) > 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if mk.Key != nil {
|
||||
if len(mk.Key.Path) == 1 {
|
||||
return "root", nil
|
||||
}
|
||||
mk.Key.Path = mk.Key.Path[:len(mk.Key.Path)-1]
|
||||
return strings.Join(mk.Key.StringIDA(), "."), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func GetObjOrder(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing dsl argument", Code: 400}
|
||||
}
|
||||
|
||||
dsl := args[0].String()
|
||||
g, _, err := d2compiler.Compile("", strings.NewReader(dsl), &d2compiler.CompileOptions{
|
||||
UTF16Pos: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 400}
|
||||
}
|
||||
|
||||
objOrder, err := d2oracle.GetObjOrder(g, nil)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"order": objOrder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetRefRanges(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 4 {
|
||||
return nil, &WASMError{Message: "missing required arguments", Code: 400}
|
||||
}
|
||||
|
||||
var fs map[string]string
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &fs); err != nil {
|
||||
return nil, &WASMError{Message: "invalid fs argument", Code: 400}
|
||||
}
|
||||
|
||||
file := args[1].String()
|
||||
key := args[2].String()
|
||||
|
||||
var boardPath []string
|
||||
if err := json.Unmarshal([]byte(args[3].String()), &boardPath); err != nil {
|
||||
return nil, &WASMError{Message: "invalid boardPath argument", Code: 400}
|
||||
}
|
||||
|
||||
ranges, importRanges, err := d2lsp.GetRefRanges(file, fs, boardPath, key)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
|
||||
return RefRangesResponse{
|
||||
Ranges: ranges,
|
||||
ImportRanges: importRanges,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetELKGraph(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
|
||||
}
|
||||
var input CompileRequest
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
|
||||
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
|
||||
}
|
||||
|
||||
if input.FS == nil {
|
||||
return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
|
||||
}
|
||||
|
||||
if _, ok := input.FS["index"]; !ok {
|
||||
return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
|
||||
}
|
||||
|
||||
fs, err := memfs.New(input.FS)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
|
||||
}
|
||||
|
||||
g, _, err := d2compiler.Compile("", strings.NewReader(input.FS["index"]), &d2compiler.CompileOptions{
|
||||
UTF16Pos: true,
|
||||
FS: fs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 400}
|
||||
}
|
||||
|
||||
ruler, err := textmeasure.NewRuler()
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
|
||||
}
|
||||
err = g.SetDimensions(nil, ruler, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
elk, err := d2elklayout.ConvertGraph(context.Background(), g, nil)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 400}
|
||||
}
|
||||
return elk, nil
|
||||
}
|
||||
|
||||
func Compile(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
|
||||
}
|
||||
var input CompileRequest
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
|
||||
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
|
||||
}
|
||||
|
||||
if input.FS == nil {
|
||||
return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
|
||||
}
|
||||
|
||||
if _, ok := input.FS["index"]; !ok {
|
||||
return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
|
||||
}
|
||||
|
||||
fs, err := memfs.New(input.FS)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
|
||||
}
|
||||
|
||||
ruler, err := textmeasure.NewRuler()
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
|
||||
}
|
||||
ctx := log.WithDefault(context.Background())
|
||||
layoutFunc := d2dagrelayout.DefaultLayout
|
||||
if input.Opts != nil && input.Opts.Layout != nil {
|
||||
switch *input.Opts.Layout {
|
||||
case "dagre":
|
||||
layoutFunc = d2dagrelayout.DefaultLayout
|
||||
case "elk":
|
||||
layoutFunc = d2elklayout.DefaultLayout
|
||||
default:
|
||||
return nil, &WASMError{Message: fmt.Sprintf("layout option '%s' not recognized", *input.Opts.Layout), Code: 400}
|
||||
}
|
||||
}
|
||||
layoutResolver := func(engine string) (d2graph.LayoutGraph, error) {
|
||||
return layoutFunc, nil
|
||||
}
|
||||
|
||||
renderOpts := &d2svg.RenderOpts{}
|
||||
var fontFamily *d2fonts.FontFamily
|
||||
if input.Opts != nil && input.Opts.Sketch != nil && *input.Opts.Sketch {
|
||||
fontFamily = go2.Pointer(d2fonts.HandDrawn)
|
||||
renderOpts.Sketch = input.Opts.Sketch
|
||||
}
|
||||
if input.Opts != nil && input.Opts.ThemeID != nil {
|
||||
renderOpts.ThemeID = input.Opts.ThemeID
|
||||
}
|
||||
diagram, g, err := d2lib.Compile(ctx, input.FS["index"], &d2lib.CompileOptions{
|
||||
UTF16Pos: true,
|
||||
FS: fs,
|
||||
Ruler: ruler,
|
||||
LayoutResolver: layoutResolver,
|
||||
FontFamily: fontFamily,
|
||||
}, renderOpts)
|
||||
if err != nil {
|
||||
if pe, ok := err.(*d2parser.ParseError); ok {
|
||||
return nil, &WASMError{Message: pe.Error(), Code: 400}
|
||||
}
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
|
||||
input.FS["index"] = d2format.Format(g.AST)
|
||||
|
||||
return CompileResponse{
|
||||
FS: input.FS,
|
||||
Diagram: *diagram,
|
||||
Graph: *g,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Render(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
|
||||
}
|
||||
var input RenderRequest
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
|
||||
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
|
||||
}
|
||||
|
||||
if input.Diagram == nil {
|
||||
return nil, &WASMError{Message: "missing 'diagram' field in input JSON", Code: 400}
|
||||
}
|
||||
|
||||
renderOpts := &d2svg.RenderOpts{}
|
||||
if input.Opts != nil && input.Opts.Sketch != nil {
|
||||
renderOpts.Sketch = input.Opts.Sketch
|
||||
}
|
||||
if input.Opts != nil && input.Opts.ThemeID != nil {
|
||||
renderOpts.ThemeID = input.Opts.ThemeID
|
||||
}
|
||||
out, err := d2svg.Render(input.Diagram, renderOpts)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: fmt.Sprintf("render failed: %s", err.Error()), Code: 500}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetBoardAtPosition(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 3 {
|
||||
return nil, &WASMError{Message: "missing required arguments", Code: 400}
|
||||
}
|
||||
|
||||
dsl := args[0].String()
|
||||
line := args[1].Int()
|
||||
column := args[2].Int()
|
||||
|
||||
boardPath, err := d2lsp.GetBoardAtPosition(dsl, d2ast.Position{
|
||||
Line: line,
|
||||
Column: column,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
|
||||
return BoardPositionResponse{BoardPath: boardPath}, nil
|
||||
}
|
||||
|
||||
func Encode(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing script argument", Code: 400}
|
||||
}
|
||||
|
||||
script := args[0].String()
|
||||
encoded, err := urlenc.Encode(script)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
|
||||
return map[string]string{"result": encoded}, nil
|
||||
}
|
||||
|
||||
func Decode(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, &WASMError{Message: "missing script argument", Code: 400}
|
||||
}
|
||||
|
||||
script := args[0].String()
|
||||
script, err := urlenc.Decode(script)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
return map[string]string{"result": script}, nil
|
||||
}
|
||||
|
||||
func GetVersion(args []js.Value) (interface{}, error) {
|
||||
return version.Version, nil
|
||||
}
|
||||
|
||||
func GetCompletions(args []js.Value) (interface{}, error) {
|
||||
if len(args) < 3 {
|
||||
return nil, &WASMError{Message: "missing required arguments", Code: 400}
|
||||
}
|
||||
|
||||
text := args[0].String()
|
||||
line := args[1].Int()
|
||||
column := args[2].Int()
|
||||
|
||||
completions, err := d2lsp.GetCompletionItems(text, line, column)
|
||||
if err != nil {
|
||||
return nil, &WASMError{Message: err.Error(), Code: 500}
|
||||
}
|
||||
|
||||
// Convert to map for JSON serialization
|
||||
items := make([]map[string]interface{}, len(completions))
|
||||
for i, completion := range completions {
|
||||
items[i] = map[string]interface{}{
|
||||
"label": completion.Label,
|
||||
"kind": int(completion.Kind),
|
||||
"detail": completion.Detail,
|
||||
"insertText": completion.InsertText,
|
||||
}
|
||||
}
|
||||
|
||||
return CompletionResponse{
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
58
d2js/d2wasm/types.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package d2wasm
|
||||
|
||||
import (
|
||||
"oss.terrastruct.com/d2/d2ast"
|
||||
"oss.terrastruct.com/d2/d2graph"
|
||||
"oss.terrastruct.com/d2/d2target"
|
||||
)
|
||||
|
||||
type WASMResponse struct {
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *WASMError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type WASMError struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
func (e *WASMError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type RefRangesResponse struct {
|
||||
Ranges []d2ast.Range `json:"ranges"`
|
||||
ImportRanges []d2ast.Range `json:"importRanges"`
|
||||
}
|
||||
|
||||
type BoardPositionResponse struct {
|
||||
BoardPath []string `json:"boardPath"`
|
||||
}
|
||||
|
||||
type CompileRequest struct {
|
||||
FS map[string]string `json:"fs"`
|
||||
Opts *RenderOptions `json:"options"`
|
||||
}
|
||||
|
||||
type RenderOptions struct {
|
||||
Layout *string `json:"layout"`
|
||||
Sketch *bool `json:"sketch"`
|
||||
ThemeID *int64 `json:"themeID"`
|
||||
}
|
||||
|
||||
type CompileResponse struct {
|
||||
FS map[string]string `json:"fs"`
|
||||
Diagram d2target.Diagram `json:"diagram"`
|
||||
Graph d2graph.Graph `json:"graph"`
|
||||
}
|
||||
|
||||
type CompletionResponse struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type RenderRequest struct {
|
||||
Diagram *d2target.Diagram `json:"diagram"`
|
||||
Opts *RenderOptions `json:"options"`
|
||||
}
|
||||
32
d2js/js.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"oss.terrastruct.com/d2/d2js/d2wasm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
api := d2wasm.NewD2API()
|
||||
|
||||
api.Register("getCompletions", d2wasm.GetCompletions)
|
||||
api.Register("getParentID", d2wasm.GetParentID)
|
||||
api.Register("getObjOrder", d2wasm.GetObjOrder)
|
||||
api.Register("getRefRanges", d2wasm.GetRefRanges)
|
||||
api.Register("getELKGraph", d2wasm.GetELKGraph)
|
||||
api.Register("compile", d2wasm.Compile)
|
||||
api.Register("render", d2wasm.Render)
|
||||
api.Register("getBoardAtPosition", d2wasm.GetBoardAtPosition)
|
||||
api.Register("encode", d2wasm.Encode)
|
||||
api.Register("decode", d2wasm.Decode)
|
||||
api.Register("version", d2wasm.GetVersion)
|
||||
|
||||
api.ExportTo(js.Global())
|
||||
|
||||
if cb := js.Global().Get("onWasmInitialized"); !cb.IsUndefined() {
|
||||
cb.Invoke()
|
||||
}
|
||||
select {}
|
||||
}
|
||||
28
d2js/js/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
node_modules
|
||||
.npm
|
||||
bun.lockb
|
||||
|
||||
src/wasm-loader.browser.js
|
||||
wasm/d2.wasm
|
||||
dist/
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
coverage/
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
1
d2js/js/.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
src/platform.browser.js
|
||||
8
d2js/js/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to only the d2.js package will be documented in this file. **Does not
|
||||
include changes to the main d2 project.**
|
||||
|
||||
## [0.1.0] - 2025-01-12
|
||||
|
||||
First public release
|
||||
29
d2js/js/Makefile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
.POSIX:
|
||||
.PHONY: all
|
||||
all: fmt build test cleanup
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: node_modules
|
||||
prefix "$@" ../../ci/sub/bin/fmt.sh
|
||||
prefix "$@" rm -f yarn.lock
|
||||
|
||||
.PHONY: build
|
||||
build: fmt
|
||||
prefix "$@" ./ci/build.sh
|
||||
|
||||
.PHONY: dev
|
||||
dev: build
|
||||
prefix "$@" git checkout -- src/platform.js src/worker.js
|
||||
prefix "$@" bun run dev
|
||||
|
||||
.PHONY: test
|
||||
test: build
|
||||
prefix "$@" bun test:all
|
||||
|
||||
.PHONY: node_modules
|
||||
node_modules:
|
||||
prefix "$@" bun install $${CI:+--frozen-lockfile}
|
||||
|
||||
.PHONY: cleanup
|
||||
cleanup: test
|
||||
prefix "$@" git checkout -- src/platform.js src/worker.js
|
||||
96
d2js/js/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# D2.js
|
||||
|
||||
[](https://www.npmjs.com/package/@terrastruct/d2)
|
||||
[](https://mozilla.org/MPL/2.0/)
|
||||
|
||||
D2.js is a JavaScript wrapper around D2, the modern diagram scripting language. It enables running D2 directly in browsers and Node environments through WebAssembly.
|
||||
|
||||
## Features
|
||||
|
||||
- 🌐 **Universal** - Works in both browser and Node environments
|
||||
- 🚀 **Modern** - Built with ESM modules, with CJS fallback
|
||||
- 🔄 **Isomorphic** - Same API everywhere
|
||||
- ⚡ **Fast** - Powered by WebAssembly for near-native performance
|
||||
- 📦 **Lightweight** - Minimal wrapper around the core D2 engine
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install @terrastruct/d2
|
||||
|
||||
# yarn
|
||||
yarn add @terrastruct/d2
|
||||
|
||||
# pnpm
|
||||
pnpm add @terrastruct/d2
|
||||
|
||||
# bun
|
||||
bun add @terrastruct/d2
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
D2.js uses webworkers to call a WASM file.
|
||||
|
||||
```javascript
|
||||
// Same for Node or browser
|
||||
import { D2 } from '@terrastruct/d2';
|
||||
// Or using a CDN
|
||||
// import { D2 } from 'https://esm.sh/@terrastruct/d2';
|
||||
|
||||
const d2 = new D2();
|
||||
|
||||
const result = await d2.compile('x -> y');
|
||||
const svg = await d2.render(result.diagram);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new D2()`
|
||||
Creates a new D2 instance.
|
||||
|
||||
### `compile(input: string, options?: CompileOptions): Promise<CompileResult>`
|
||||
Compiles D2 markup into an intermediate representation.
|
||||
|
||||
Options:
|
||||
- `layout`: Layout engine to use ('dagre' | 'elk') [default: 'dagre']
|
||||
- `sketch`: Enable sketch mode [default: false]
|
||||
|
||||
### `render(diagram: Diagram, options?: RenderOptions): Promise<string>`
|
||||
Renders a compiled diagram to SVG.
|
||||
|
||||
## Development
|
||||
|
||||
D2.js uses Bun, so install this first.
|
||||
|
||||
### Building from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/terrastruct/d2.git
|
||||
cd d2/d2js/js
|
||||
./make.sh all
|
||||
```
|
||||
|
||||
If you change the main D2 source code, you should regenerate the WASM file:
|
||||
```bash
|
||||
./make.sh build
|
||||
```
|
||||
|
||||
### Running the dev server
|
||||
|
||||
You can browse the examples by running the dev server:
|
||||
|
||||
```bash
|
||||
./make.sh dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000` to see the example page.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the Mozilla Public License Version 2.0.
|
||||
107
d2js/js/build.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { build } from "bun";
|
||||
import { copyFile, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const __dirname = new URL(".", import.meta.url).pathname;
|
||||
const ROOT_DIR = resolve(__dirname);
|
||||
const SRC_DIR = resolve(ROOT_DIR, "src");
|
||||
|
||||
await rm("./dist", { recursive: true, force: true });
|
||||
await mkdir("./dist/browser", { recursive: true });
|
||||
await mkdir("./dist/node-esm", { recursive: true });
|
||||
await mkdir("./dist/node-cjs", { recursive: true });
|
||||
|
||||
const wasmBinary = await readFile("./wasm/d2.wasm");
|
||||
const wasmExecJs = await readFile("./wasm/wasm_exec.js", "utf8");
|
||||
|
||||
await writeFile(
|
||||
join(SRC_DIR, "wasm-loader.browser.js"),
|
||||
`export const wasmBinary = Uint8Array.from(atob("${Buffer.from(wasmBinary).toString(
|
||||
"base64"
|
||||
)}"), c => c.charCodeAt(0));
|
||||
export const wasmExecJs = ${JSON.stringify(wasmExecJs)};`
|
||||
);
|
||||
|
||||
const commonConfig = {
|
||||
minify: true,
|
||||
};
|
||||
|
||||
async function buildDynamicFiles(platform) {
|
||||
const platformContent =
|
||||
platform === "node"
|
||||
? `export * from "./platform.node.js";`
|
||||
: `export * from "./platform.browser.js";`;
|
||||
|
||||
const platformPath = join(SRC_DIR, "platform.js");
|
||||
await writeFile(platformPath, platformContent);
|
||||
|
||||
const workerSource =
|
||||
platform === "node"
|
||||
? join(SRC_DIR, "worker.node.js")
|
||||
: join(SRC_DIR, "worker.browser.js");
|
||||
|
||||
const workerTarget = join(SRC_DIR, "worker.js");
|
||||
const workerContent = await readFile(workerSource, "utf8");
|
||||
await writeFile(workerTarget, workerContent);
|
||||
}
|
||||
|
||||
async function buildAndCopy(buildType) {
|
||||
const configs = {
|
||||
browser: {
|
||||
outdir: resolve(ROOT_DIR, "dist/browser"),
|
||||
splitting: false,
|
||||
format: "esm",
|
||||
target: "browser",
|
||||
platform: "browser",
|
||||
entrypoints: [resolve(SRC_DIR, "index.js")],
|
||||
},
|
||||
"node-esm": {
|
||||
outdir: resolve(ROOT_DIR, "dist/node-esm"),
|
||||
splitting: true,
|
||||
format: "esm",
|
||||
target: "node",
|
||||
platform: "node",
|
||||
entrypoints: [resolve(SRC_DIR, "index.js"), resolve(SRC_DIR, "worker.js")],
|
||||
},
|
||||
"node-cjs": {
|
||||
outdir: resolve(ROOT_DIR, "dist/node-cjs"),
|
||||
splitting: false,
|
||||
format: "cjs",
|
||||
target: "node",
|
||||
platform: "node",
|
||||
entrypoints: [resolve(SRC_DIR, "index.js"), resolve(SRC_DIR, "worker.js")],
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[buildType];
|
||||
await buildDynamicFiles(config.platform);
|
||||
|
||||
const result = await build({
|
||||
...commonConfig,
|
||||
...config,
|
||||
});
|
||||
|
||||
if (!result.outputs || result.outputs.length === 0) {
|
||||
throw new Error(
|
||||
`No outputs generated for ${buildType} build. Result: ${JSON.stringify(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (buildType !== "browser") {
|
||||
await copyFile(resolve(ROOT_DIR, "wasm/d2.wasm"), join(config.outdir, "d2.wasm"));
|
||||
await copyFile(
|
||||
resolve(ROOT_DIR, "wasm/wasm_exec.js"),
|
||||
join(config.outdir, "wasm_exec.js")
|
||||
);
|
||||
await copyFile(resolve(ROOT_DIR, "src/elk.js"), join(config.outdir, "elk.js"));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await buildAndCopy("browser");
|
||||
await buildAndCopy("node-esm");
|
||||
await buildAndCopy("node-cjs");
|
||||
} catch (error) {
|
||||
console.error("Build failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
BIN
d2js/js/bun.lockb
Executable file
19
d2js/js/ci/build.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
. "$(dirname "$0")/../../../ci/sub/lib.sh"
|
||||
cd -- "$(dirname "$0")/.."
|
||||
|
||||
cd ../..
|
||||
sh_c "GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o main.wasm ./d2js"
|
||||
sh_c "mv main.wasm ./d2js/js/wasm/d2.wasm"
|
||||
|
||||
if [ ! -f ./d2js/js/wasm/d2.wasm ]; then
|
||||
echoerr "Error: d2.wasm is missing"
|
||||
exit 1
|
||||
else
|
||||
echo "d2.wasm exists. Size:"
|
||||
ls -lh ./d2js/js/wasm/d2.wasm | awk '{print $5}'
|
||||
fi
|
||||
|
||||
cd d2js/js
|
||||
sh_c bun build.js
|
||||
72
d2js/js/dev-server.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".wasm": "application/wasm",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
let filePath = url.pathname.slice(1); // Remove leading "/"
|
||||
|
||||
if (filePath === "") {
|
||||
filePath = "examples/";
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath);
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await fs.readdir(fullPath);
|
||||
const links = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(fullPath, entry);
|
||||
const isDir = (await fs.stat(entryPath)).isDirectory();
|
||||
const slash = isDir ? "/" : "";
|
||||
return `<li><a href="${filePath}${entry}${slash}">${entry}${slash}</a></li>`;
|
||||
})
|
||||
);
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<h1>Examples</h1>
|
||||
<ul>
|
||||
${links.join("")}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return new Response(html, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
} else {
|
||||
const ext = path.extname(filePath);
|
||||
const mimeType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error serving ${filePath}:`, err);
|
||||
return new Response(`File not found: ${filePath}`, { status: 404 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Server running at http://localhost:3000`);
|
||||
49
d2js/js/examples/basic.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
textarea {
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
}
|
||||
#output {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
#output svg {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<textarea id="input">x -> y</textarea>
|
||||
<button onclick="compile()">Compile</button>
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
<script type="module">
|
||||
import { D2 } from "../dist/browser/index.js";
|
||||
const d2 = new D2();
|
||||
window.compile = async () => {
|
||||
const input = document.getElementById("input").value;
|
||||
try {
|
||||
const result = await d2.compile(input);
|
||||
const svg = await d2.render(result.diagram);
|
||||
document.getElementById("output").innerHTML = svg;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
document.getElementById("output").textContent = err.message;
|
||||
}
|
||||
};
|
||||
compile();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
122
d2js/js/examples/customizable.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 400px;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.options-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.layout-toggle,
|
||||
.sketch-toggle {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.radio-label,
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
#output {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
#output svg {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="controls">
|
||||
<textarea id="input">x -> y</textarea>
|
||||
<div class="options-group">
|
||||
<div class="layout-toggle">
|
||||
<span>Layout:</span>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="layout" value="dagre" checked />
|
||||
Dagre
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="layout" value="elk" />
|
||||
ELK
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sketch-toggle">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="sketch" />
|
||||
Sketch mode
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="compile()">Compile</button>
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
<script type="module">
|
||||
import { D2 } from "../dist/browser/index.js";
|
||||
const d2 = new D2();
|
||||
window.compile = async () => {
|
||||
const input = document.getElementById("input").value;
|
||||
const layout = document.querySelector('input[name="layout"]:checked').value;
|
||||
const sketch = document.getElementById("sketch").checked;
|
||||
try {
|
||||
const result = await d2.compile(input, { layout, sketch });
|
||||
const svg = await d2.render(result.diagram, { sketch });
|
||||
document.getElementById("output").innerHTML = svg;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
document.getElementById("output").textContent = err.message;
|
||||
}
|
||||
};
|
||||
compile();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
23
d2js/js/make.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
if [ ! -e "$(dirname "$0")/../../ci/sub/.git" ]; then
|
||||
set -x
|
||||
git submodule update --init
|
||||
set +x
|
||||
fi
|
||||
. "$(dirname "$0")/../../ci/sub/lib.sh"
|
||||
PATH="$(cd -- "$(dirname "$0")" && pwd)/../../ci/sub/bin:$PATH"
|
||||
cd -- "$(dirname "$0")"
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
if [ -n "${CI-}" ]; then
|
||||
echo "Bun is not installed. Installing Bun..."
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
else
|
||||
echoerr "You need bun to build d2.js: curl -fsSL https://bun.sh/install | bash"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
_make "$@"
|
||||
56
d2js/js/package.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "@terrastruct/d2",
|
||||
"author": "Terrastruct, Inc.",
|
||||
"description": "D2.js is a wrapper around the WASM build of D2, the modern text-to-diagram language.",
|
||||
"version": "0.1.21",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/terrastruct/d2.git",
|
||||
"directory": "d2js/js"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/terrastruct/d2/issues"
|
||||
},
|
||||
"homepage": "https://github.com/terrastruct/d2/tree/master/d2js/js#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"browser": "./dist/browser/index.js",
|
||||
"import": {
|
||||
"browser": "./dist/browser/index.js",
|
||||
"default": "./dist/node-esm/index.js"
|
||||
},
|
||||
"require": "./dist/node-cjs/index.js",
|
||||
"default": "./dist/node-esm/index.js"
|
||||
},
|
||||
"./worker": "./dist/browser/worker.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "./make.sh build",
|
||||
"test": "bun test test/unit",
|
||||
"test:integration": "bun test test/integration",
|
||||
"test:all": "bun run test && bun run test:integration",
|
||||
"dev": "bun --watch dev-server.js",
|
||||
"prepublishOnly": "./make.sh all"
|
||||
},
|
||||
"keywords": [
|
||||
"d2",
|
||||
"d2lang",
|
||||
"diagram",
|
||||
"wasm",
|
||||
"text-to-diagram",
|
||||
"go"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"bun": "latest"
|
||||
}
|
||||
}
|
||||
105805
d2js/js/src/elk.js
Normal file
109
d2js/js/src/index.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { createWorker, loadFile } from "./platform.js";
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
layout: "dagre",
|
||||
sketch: false,
|
||||
};
|
||||
|
||||
export class D2 {
|
||||
constructor() {
|
||||
this.ready = this.init();
|
||||
}
|
||||
|
||||
setupMessageHandler() {
|
||||
const isNode = typeof window === "undefined";
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isNode) {
|
||||
this.worker.on("message", (data) => {
|
||||
if (data.type === "ready") resolve();
|
||||
if (data.type === "error") reject(new Error(data.error));
|
||||
if (data.type === "result" && this.currentResolve) {
|
||||
this.currentResolve(data.data);
|
||||
}
|
||||
if (data.type === "error" && this.currentReject) {
|
||||
this.currentReject(new Error(data.error));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.worker.onmessage = (e) => {
|
||||
if (e.data.type === "ready") resolve();
|
||||
if (e.data.type === "error") reject(new Error(e.data.error));
|
||||
if (e.data.type === "result" && this.currentResolve) {
|
||||
this.currentResolve(e.data.data);
|
||||
}
|
||||
if (e.data.type === "error" && this.currentReject) {
|
||||
this.currentReject(new Error(e.data.error));
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.worker = await createWorker();
|
||||
|
||||
const elkContent = await loadFile("./elk.js");
|
||||
const wasmExecContent = await loadFile("./wasm_exec.js");
|
||||
const wasmBinary = await loadFile("./d2.wasm");
|
||||
|
||||
const isNode = typeof window === "undefined";
|
||||
const messageHandler = this.setupMessageHandler();
|
||||
|
||||
if (isNode) {
|
||||
this.worker.on("error", (error) => {
|
||||
console.error("Worker (node) encountered an error:", error.message || error);
|
||||
});
|
||||
} else {
|
||||
this.worker.onerror = (error) => {
|
||||
console.error("Worker encountered an error:", error.message || error);
|
||||
};
|
||||
}
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "init",
|
||||
data: {
|
||||
wasm: wasmBinary,
|
||||
wasmExecContent: isNode ? wasmExecContent.toString() : null,
|
||||
elkContent: isNode ? elkContent.toString() : null,
|
||||
wasmExecUrl: isNode
|
||||
? null
|
||||
: URL.createObjectURL(
|
||||
new Blob([wasmExecContent], { type: "application/javascript" })
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return messageHandler;
|
||||
}
|
||||
|
||||
async sendMessage(type, data) {
|
||||
await this.ready;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.currentResolve = resolve;
|
||||
this.currentReject = reject;
|
||||
this.worker.postMessage({ type, data });
|
||||
});
|
||||
}
|
||||
|
||||
async compile(input, options = {}) {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const request =
|
||||
typeof input === "string"
|
||||
? { fs: { index: input }, options: opts }
|
||||
: { ...input, options: { ...opts, ...input.options } };
|
||||
return this.sendMessage("compile", request);
|
||||
}
|
||||
|
||||
async render(diagram, options = {}) {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
return this.sendMessage("render", { diagram, options: opts });
|
||||
}
|
||||
|
||||
async encode(script) {
|
||||
return this.sendMessage("encode", script);
|
||||
}
|
||||
|
||||
async decode(encoded) {
|
||||
return this.sendMessage("decode", encoded);
|
||||
}
|
||||
}
|
||||
26
d2js/js/src/platform.browser.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { wasmBinary, wasmExecJs } from "./wasm-loader.browser.js";
|
||||
import workerScript from "./worker.js" with { type: "text" };
|
||||
import elkScript from "./elk.js" with { type: "text" };
|
||||
|
||||
// For the browser version, we build the wasm files into a file (wasm-loader.browser.js)
|
||||
// and loading a file just reads the text, so there's no external dependency calls
|
||||
export async function loadFile(path) {
|
||||
if (path === "./d2.wasm") {
|
||||
return wasmBinary.buffer;
|
||||
}
|
||||
if (path === "./wasm_exec.js") {
|
||||
return new TextEncoder().encode(wasmExecJs).buffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWorker() {
|
||||
let blob = new Blob([wasmExecJs, elkScript, workerScript], {
|
||||
type: "text/javascript;charset=utf-8",
|
||||
});
|
||||
|
||||
const worker = new Worker(URL.createObjectURL(blob), {
|
||||
type: "module",
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
1
d2js/js/src/platform.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./platform.node.js";
|
||||
40
d2js/js/src/platform.node.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
let nodeModules = null;
|
||||
|
||||
async function loadNodeModules() {
|
||||
if (!nodeModules) {
|
||||
nodeModules = {
|
||||
fs: await import("fs/promises"),
|
||||
path: await import("path"),
|
||||
url: await import("url"),
|
||||
worker: await import("worker_threads"),
|
||||
};
|
||||
}
|
||||
return nodeModules;
|
||||
}
|
||||
|
||||
export async function loadFile(path) {
|
||||
const modules = await loadNodeModules();
|
||||
const readFile = modules.fs.readFile;
|
||||
const { join, dirname } = modules.path;
|
||||
const { fileURLToPath } = modules.url;
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
try {
|
||||
return await readFile(join(__dirname, path));
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return await readFile(join(__dirname, "../../../wasm", path.replace("./", "")));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorker() {
|
||||
const modules = await loadNodeModules();
|
||||
const { Worker } = modules.worker;
|
||||
const { join, dirname } = modules.path;
|
||||
const { fileURLToPath } = modules.url;
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const workerPath = join(__dirname, "worker.js");
|
||||
return new Worker(workerPath);
|
||||
}
|
||||
8
d2js/js/src/wasm-loader.node.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { readFile } from "fs/promises";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export async function getWasmBinary() {
|
||||
return readFile(resolve(__dirname, "./d2.wasm"));
|
||||
}
|
||||
76
d2js/js/src/worker.browser.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
let currentPort;
|
||||
let d2;
|
||||
let elk;
|
||||
|
||||
export function setupMessageHandler(isNode, port, initWasm) {
|
||||
currentPort = port;
|
||||
|
||||
const handleMessage = async (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
switch (type) {
|
||||
case "init":
|
||||
try {
|
||||
if (isNode) {
|
||||
eval(data.wasmExecContent);
|
||||
}
|
||||
d2 = await initWasm(data.wasm);
|
||||
elk = new ELK();
|
||||
currentPort.postMessage({ type: "ready" });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "compile":
|
||||
try {
|
||||
// We use Go to get the intermediate ELK graph
|
||||
// Then natively run elk layout
|
||||
// This is due to elk.layout being an async function, which a
|
||||
// single-threaded WASM call cannot complete without giving control back
|
||||
// So we compute it, store it here, then during elk layout, instead
|
||||
// of computing again, we use this variable (and unset it for next call)
|
||||
if (data.options.layout === "elk") {
|
||||
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
|
||||
const elkGraph2 = JSON.parse(elkGraph).data;
|
||||
const layout = await elk.layout(elkGraph2);
|
||||
globalThis.elkResult = layout;
|
||||
}
|
||||
|
||||
const result = await d2.compile(JSON.stringify(data));
|
||||
const response = JSON.parse(result);
|
||||
if (response.error) throw new Error(response.error.message);
|
||||
currentPort.postMessage({ type: "result", data: response.data });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "render":
|
||||
try {
|
||||
const result = await d2.render(JSON.stringify(data));
|
||||
const response = JSON.parse(result);
|
||||
if (response.error) throw new Error(response.error.message);
|
||||
currentPort.postMessage({ type: "result", data: atob(response.data) });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (isNode) {
|
||||
port.on("message", handleMessage);
|
||||
} else {
|
||||
port.onmessage = (e) => handleMessage(e.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function initWasmBrowser(wasmBinary) {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
|
||||
go.run(result.instance);
|
||||
return self.d2;
|
||||
}
|
||||
|
||||
setupMessageHandler(false, self, initWasmBrowser);
|
||||
72
d2js/js/src/worker.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { parentPort } from "node:worker_threads";
|
||||
|
||||
let currentPort;
|
||||
let d2;
|
||||
let elk;
|
||||
|
||||
export function setupMessageHandler(isNode, port, initWasm) {
|
||||
currentPort = port;
|
||||
|
||||
const handleMessage = async (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
switch (type) {
|
||||
case "init":
|
||||
try {
|
||||
if (isNode) {
|
||||
eval(data.wasmExecContent);
|
||||
eval(data.elkContent);
|
||||
}
|
||||
d2 = await initWasm(data.wasm);
|
||||
elk = new ELK();
|
||||
currentPort.postMessage({ type: "ready" });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "compile":
|
||||
try {
|
||||
if (data.options.layout === "elk") {
|
||||
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
|
||||
const elkGraph2 = JSON.parse(elkGraph).data;
|
||||
const layout = await elk.layout(elkGraph2);
|
||||
globalThis.elkResult = layout;
|
||||
}
|
||||
const result = await d2.compile(JSON.stringify(data));
|
||||
const response = JSON.parse(result);
|
||||
if (response.error) throw new Error(response.error.message);
|
||||
currentPort.postMessage({ type: "result", data: response.data });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "render":
|
||||
try {
|
||||
const result = await d2.render(JSON.stringify(data));
|
||||
const response = JSON.parse(result);
|
||||
if (response.error) throw new Error(response.error.message);
|
||||
currentPort.postMessage({ type: "result", data: atob(response.data) });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (isNode) {
|
||||
port.on("message", handleMessage);
|
||||
} else {
|
||||
port.onmessage = (e) => handleMessage(e.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function initWasmNode(wasmBinary) {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
|
||||
go.run(result.instance);
|
||||
return global.d2;
|
||||
}
|
||||
|
||||
setupMessageHandler(true, parentPort, initWasmNode);
|
||||
72
d2js/js/src/worker.node.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { parentPort } from "node:worker_threads";
|
||||
|
||||
let currentPort;
|
||||
let d2;
|
||||
let elk;
|
||||
|
||||
export function setupMessageHandler(isNode, port, initWasm) {
|
||||
currentPort = port;
|
||||
|
||||
const handleMessage = async (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
switch (type) {
|
||||
case "init":
|
||||
try {
|
||||
if (isNode) {
|
||||
eval(data.wasmExecContent);
|
||||
eval(data.elkContent);
|
||||
}
|
||||
d2 = await initWasm(data.wasm);
|
||||
elk = new ELK();
|
||||
currentPort.postMessage({ type: "ready" });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "compile":
|
||||
try {
|
||||
if (data.options.layout === "elk") {
|
||||
const elkGraph = await d2.getELKGraph(JSON.stringify(data));
|
||||
const elkGraph2 = JSON.parse(elkGraph).data;
|
||||
const layout = await elk.layout(elkGraph2);
|
||||
globalThis.elkResult = layout;
|
||||
}
|
||||
const result = await d2.compile(JSON.stringify(data));
|
||||
const response = JSON.parse(result);
|
||||
if (response.error) throw new Error(response.error.message);
|
||||
currentPort.postMessage({ type: "result", data: response.data });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "render":
|
||||
try {
|
||||
const result = await d2.render(JSON.stringify(data));
|
||||
const response = JSON.parse(result);
|
||||
if (response.error) throw new Error(response.error.message);
|
||||
currentPort.postMessage({ type: "result", data: atob(response.data) });
|
||||
} catch (err) {
|
||||
currentPort.postMessage({ type: "error", error: err.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (isNode) {
|
||||
port.on("message", handleMessage);
|
||||
} else {
|
||||
port.onmessage = (e) => handleMessage(e.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function initWasmNode(wasmBinary) {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
|
||||
go.run(result.instance);
|
||||
return global.d2;
|
||||
}
|
||||
|
||||
setupMessageHandler(true, parentPort, initWasmNode);
|
||||
11
d2js/js/test/integration/cjs.test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { expect, test, describe } from "bun:test";
|
||||
|
||||
describe("D2 CJS Integration", () => {
|
||||
test("can require and use CJS build", async () => {
|
||||
const { D2 } = require("../../dist/node-cjs/index.js");
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x -> y");
|
||||
expect(result.diagram).toBeDefined();
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
});
|
||||
11
d2js/js/test/integration/esm.test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { expect, test, describe } from "bun:test";
|
||||
import { D2 } from "../../dist/node-esm/index.js";
|
||||
|
||||
describe("D2 ESM Integration", () => {
|
||||
test("can import and use ESM build", async () => {
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x -> y");
|
||||
expect(result.diagram).toBeDefined();
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
});
|
||||
58
d2js/js/test/unit/basic.test.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { expect, test, describe } from "bun:test";
|
||||
import { D2 } from "../../dist/node-esm/index.js";
|
||||
|
||||
describe("D2 Unit Tests", () => {
|
||||
test("basic compilation works", async () => {
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x -> y");
|
||||
expect(result.diagram).toBeDefined();
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
|
||||
test("elk layout works", async () => {
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x -> y", { layout: "elk" });
|
||||
expect(result.diagram).toBeDefined();
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
|
||||
test("render works", async () => {
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x -> y");
|
||||
const svg = await d2.render(result.diagram);
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("</svg>");
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
|
||||
test("sketch render works", async () => {
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x -> y", { sketch: true });
|
||||
const svg = await d2.render(result.diagram, { sketch: true });
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("</svg>");
|
||||
expect(svg).toContain("sketch-overlay");
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
|
||||
test("latex works", async () => {
|
||||
const d2 = new D2();
|
||||
const result = await d2.compile("x: |latex \\frac{f(x+h)-f(x)}{h} |");
|
||||
const svg = await d2.render(result.diagram);
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("</svg>");
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
|
||||
test("handles syntax errors correctly", async () => {
|
||||
const d2 = new D2();
|
||||
try {
|
||||
await d2.compile("invalid -> -> syntax");
|
||||
throw new Error("Should have thrown syntax error");
|
||||
} catch (err) {
|
||||
expect(err).toBeDefined();
|
||||
expect(err.message).not.toContain("Should have thrown syntax error");
|
||||
}
|
||||
await d2.worker.terminate();
|
||||
}, 20000);
|
||||
});
|
||||
477
d2js/js/wasm/wasm_exec.js
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
"use strict";
|
||||
(() => {
|
||||
const o = () => {
|
||||
const h = new Error("not implemented");
|
||||
return (h.code = "ENOSYS"), h;
|
||||
};
|
||||
if (!globalThis.fs) {
|
||||
let h = "";
|
||||
globalThis.fs = {
|
||||
constants: {
|
||||
O_WRONLY: -1,
|
||||
O_RDWR: -1,
|
||||
O_CREAT: -1,
|
||||
O_TRUNC: -1,
|
||||
O_APPEND: -1,
|
||||
O_EXCL: -1,
|
||||
},
|
||||
writeSync(n, s) {
|
||||
h += y.decode(s);
|
||||
const i = h.lastIndexOf(`
|
||||
`);
|
||||
return (
|
||||
i != -1 && (console.log(h.substring(0, i)), (h = h.substring(i + 1))), s.length
|
||||
);
|
||||
},
|
||||
write(n, s, i, r, f, u) {
|
||||
if (i !== 0 || r !== s.length || f !== null) {
|
||||
u(o());
|
||||
return;
|
||||
}
|
||||
const d = this.writeSync(n, s);
|
||||
u(null, d);
|
||||
},
|
||||
chmod(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
chown(n, s, i, r) {
|
||||
r(o());
|
||||
},
|
||||
close(n, s) {
|
||||
s(o());
|
||||
},
|
||||
fchmod(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
fchown(n, s, i, r) {
|
||||
r(o());
|
||||
},
|
||||
fstat(n, s) {
|
||||
s(o());
|
||||
},
|
||||
fsync(n, s) {
|
||||
s(null);
|
||||
},
|
||||
ftruncate(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
lchown(n, s, i, r) {
|
||||
r(o());
|
||||
},
|
||||
link(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
lstat(n, s) {
|
||||
s(o());
|
||||
},
|
||||
mkdir(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
open(n, s, i, r) {
|
||||
r(o());
|
||||
},
|
||||
read(n, s, i, r, f, u) {
|
||||
u(o());
|
||||
},
|
||||
readdir(n, s) {
|
||||
s(o());
|
||||
},
|
||||
readlink(n, s) {
|
||||
s(o());
|
||||
},
|
||||
rename(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
rmdir(n, s) {
|
||||
s(o());
|
||||
},
|
||||
stat(n, s) {
|
||||
s(o());
|
||||
},
|
||||
symlink(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
truncate(n, s, i) {
|
||||
i(o());
|
||||
},
|
||||
unlink(n, s) {
|
||||
s(o());
|
||||
},
|
||||
utimes(n, s, i, r) {
|
||||
r(o());
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
(globalThis.process ||
|
||||
(globalThis.process = {
|
||||
getuid() {
|
||||
return -1;
|
||||
},
|
||||
getgid() {
|
||||
return -1;
|
||||
},
|
||||
geteuid() {
|
||||
return -1;
|
||||
},
|
||||
getegid() {
|
||||
return -1;
|
||||
},
|
||||
getgroups() {
|
||||
throw o();
|
||||
},
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() {
|
||||
throw o();
|
||||
},
|
||||
cwd() {
|
||||
throw o();
|
||||
},
|
||||
chdir() {
|
||||
throw o();
|
||||
},
|
||||
}),
|
||||
!globalThis.crypto)
|
||||
)
|
||||
throw new Error(
|
||||
"globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"
|
||||
);
|
||||
if (!globalThis.performance)
|
||||
throw new Error(
|
||||
"globalThis.performance is not available, polyfill required (performance.now only)"
|
||||
);
|
||||
if (!globalThis.TextEncoder)
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
if (!globalThis.TextDecoder)
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
const g = new TextEncoder("utf-8"),
|
||||
y = new TextDecoder("utf-8");
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
(this.argv = ["js"]),
|
||||
(this.env = {}),
|
||||
(this.exit = (t) => {
|
||||
t !== 0 && console.warn("exit code:", t);
|
||||
}),
|
||||
(this._exitPromise = new Promise((t) => {
|
||||
this._resolveExitPromise = t;
|
||||
})),
|
||||
(this._pendingEvent = null),
|
||||
(this._scheduledTimeouts = new Map()),
|
||||
(this._nextCallbackTimeoutID = 1);
|
||||
const h = (t, e) => {
|
||||
this.mem.setUint32(t + 0, e, !0),
|
||||
this.mem.setUint32(t + 4, Math.floor(e / 4294967296), !0);
|
||||
},
|
||||
n = (t, e) => {
|
||||
this.mem.setUint32(t + 0, e, !0);
|
||||
},
|
||||
s = (t) => {
|
||||
const e = this.mem.getUint32(t + 0, !0),
|
||||
l = this.mem.getInt32(t + 4, !0);
|
||||
return e + l * 4294967296;
|
||||
},
|
||||
i = (t) => {
|
||||
const e = this.mem.getFloat64(t, !0);
|
||||
if (e === 0) return;
|
||||
if (!isNaN(e)) return e;
|
||||
const l = this.mem.getUint32(t, !0);
|
||||
return this._values[l];
|
||||
},
|
||||
r = (t, e) => {
|
||||
if (typeof e == "number" && e !== 0) {
|
||||
if (isNaN(e)) {
|
||||
this.mem.setUint32(t + 4, 2146959360, !0), this.mem.setUint32(t, 0, !0);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(t, e, !0);
|
||||
return;
|
||||
}
|
||||
if (e === void 0) {
|
||||
this.mem.setFloat64(t, 0, !0);
|
||||
return;
|
||||
}
|
||||
let a = this._ids.get(e);
|
||||
a === void 0 &&
|
||||
((a = this._idPool.pop()),
|
||||
a === void 0 && (a = this._values.length),
|
||||
(this._values[a] = e),
|
||||
(this._goRefCounts[a] = 0),
|
||||
this._ids.set(e, a)),
|
||||
this._goRefCounts[a]++;
|
||||
let c = 0;
|
||||
switch (typeof e) {
|
||||
case "object":
|
||||
e !== null && (c = 1);
|
||||
break;
|
||||
case "string":
|
||||
c = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
c = 3;
|
||||
break;
|
||||
case "function":
|
||||
c = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(t + 4, 2146959360 | c, !0), this.mem.setUint32(t, a, !0);
|
||||
},
|
||||
f = (t) => {
|
||||
const e = s(t + 0),
|
||||
l = s(t + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, e, l);
|
||||
},
|
||||
u = (t) => {
|
||||
const e = s(t + 0),
|
||||
l = s(t + 8),
|
||||
a = new Array(l);
|
||||
for (let c = 0; c < l; c++) a[c] = i(e + c * 8);
|
||||
return a;
|
||||
},
|
||||
d = (t) => {
|
||||
const e = s(t + 0),
|
||||
l = s(t + 8);
|
||||
return y.decode(new DataView(this._inst.exports.mem.buffer, e, l));
|
||||
},
|
||||
m = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: { add: (t, e) => t + e },
|
||||
gojs: {
|
||||
"runtime.wasmExit": (t) => {
|
||||
t >>>= 0;
|
||||
const e = this.mem.getInt32(t + 8, !0);
|
||||
(this.exited = !0),
|
||||
delete this._inst,
|
||||
delete this._values,
|
||||
delete this._goRefCounts,
|
||||
delete this._ids,
|
||||
delete this._idPool,
|
||||
this.exit(e);
|
||||
},
|
||||
"runtime.wasmWrite": (t) => {
|
||||
t >>>= 0;
|
||||
const e = s(t + 8),
|
||||
l = s(t + 16),
|
||||
a = this.mem.getInt32(t + 24, !0);
|
||||
fs.writeSync(e, new Uint8Array(this._inst.exports.mem.buffer, l, a));
|
||||
},
|
||||
"runtime.resetMemoryDataView": (t) => {
|
||||
(t >>>= 0), (this.mem = new DataView(this._inst.exports.mem.buffer));
|
||||
},
|
||||
"runtime.nanotime1": (t) => {
|
||||
(t >>>= 0), h(t + 8, (m + performance.now()) * 1e6);
|
||||
},
|
||||
"runtime.walltime": (t) => {
|
||||
t >>>= 0;
|
||||
const e = new Date().getTime();
|
||||
h(t + 8, e / 1e3), this.mem.setInt32(t + 16, (e % 1e3) * 1e6, !0);
|
||||
},
|
||||
"runtime.scheduleTimeoutEvent": (t) => {
|
||||
t >>>= 0;
|
||||
const e = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++,
|
||||
this._scheduledTimeouts.set(
|
||||
e,
|
||||
setTimeout(() => {
|
||||
for (this._resume(); this._scheduledTimeouts.has(e); )
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event"),
|
||||
this._resume();
|
||||
}, s(t + 8))
|
||||
),
|
||||
this.mem.setInt32(t + 16, e, !0);
|
||||
},
|
||||
"runtime.clearTimeoutEvent": (t) => {
|
||||
t >>>= 0;
|
||||
const e = this.mem.getInt32(t + 8, !0);
|
||||
clearTimeout(this._scheduledTimeouts.get(e)),
|
||||
this._scheduledTimeouts.delete(e);
|
||||
},
|
||||
"runtime.getRandomData": (t) => {
|
||||
(t >>>= 0), crypto.getRandomValues(f(t + 8));
|
||||
},
|
||||
"syscall/js.finalizeRef": (t) => {
|
||||
t >>>= 0;
|
||||
const e = this.mem.getUint32(t + 8, !0);
|
||||
if ((this._goRefCounts[e]--, this._goRefCounts[e] === 0)) {
|
||||
const l = this._values[e];
|
||||
(this._values[e] = null), this._ids.delete(l), this._idPool.push(e);
|
||||
}
|
||||
},
|
||||
"syscall/js.stringVal": (t) => {
|
||||
(t >>>= 0), r(t + 24, d(t + 8));
|
||||
},
|
||||
"syscall/js.valueGet": (t) => {
|
||||
t >>>= 0;
|
||||
const e = Reflect.get(i(t + 8), d(t + 16));
|
||||
(t = this._inst.exports.getsp() >>> 0), r(t + 32, e);
|
||||
},
|
||||
"syscall/js.valueSet": (t) => {
|
||||
(t >>>= 0), Reflect.set(i(t + 8), d(t + 16), i(t + 32));
|
||||
},
|
||||
"syscall/js.valueDelete": (t) => {
|
||||
(t >>>= 0), Reflect.deleteProperty(i(t + 8), d(t + 16));
|
||||
},
|
||||
"syscall/js.valueIndex": (t) => {
|
||||
(t >>>= 0), r(t + 24, Reflect.get(i(t + 8), s(t + 16)));
|
||||
},
|
||||
"syscall/js.valueSetIndex": (t) => {
|
||||
(t >>>= 0), Reflect.set(i(t + 8), s(t + 16), i(t + 24));
|
||||
},
|
||||
"syscall/js.valueCall": (t) => {
|
||||
t >>>= 0;
|
||||
try {
|
||||
const e = i(t + 8),
|
||||
l = Reflect.get(e, d(t + 16)),
|
||||
a = u(t + 32),
|
||||
c = Reflect.apply(l, e, a);
|
||||
(t = this._inst.exports.getsp() >>> 0),
|
||||
r(t + 56, c),
|
||||
this.mem.setUint8(t + 64, 1);
|
||||
} catch (e) {
|
||||
(t = this._inst.exports.getsp() >>> 0),
|
||||
r(t + 56, e),
|
||||
this.mem.setUint8(t + 64, 0);
|
||||
}
|
||||
},
|
||||
"syscall/js.valueInvoke": (t) => {
|
||||
t >>>= 0;
|
||||
try {
|
||||
const e = i(t + 8),
|
||||
l = u(t + 16),
|
||||
a = Reflect.apply(e, void 0, l);
|
||||
(t = this._inst.exports.getsp() >>> 0),
|
||||
r(t + 40, a),
|
||||
this.mem.setUint8(t + 48, 1);
|
||||
} catch (e) {
|
||||
(t = this._inst.exports.getsp() >>> 0),
|
||||
r(t + 40, e),
|
||||
this.mem.setUint8(t + 48, 0);
|
||||
}
|
||||
},
|
||||
"syscall/js.valueNew": (t) => {
|
||||
t >>>= 0;
|
||||
try {
|
||||
const e = i(t + 8),
|
||||
l = u(t + 16),
|
||||
a = Reflect.construct(e, l);
|
||||
(t = this._inst.exports.getsp() >>> 0),
|
||||
r(t + 40, a),
|
||||
this.mem.setUint8(t + 48, 1);
|
||||
} catch (e) {
|
||||
(t = this._inst.exports.getsp() >>> 0),
|
||||
r(t + 40, e),
|
||||
this.mem.setUint8(t + 48, 0);
|
||||
}
|
||||
},
|
||||
"syscall/js.valueLength": (t) => {
|
||||
(t >>>= 0), h(t + 16, parseInt(i(t + 8).length));
|
||||
},
|
||||
"syscall/js.valuePrepareString": (t) => {
|
||||
t >>>= 0;
|
||||
const e = g.encode(String(i(t + 8)));
|
||||
r(t + 16, e), h(t + 24, e.length);
|
||||
},
|
||||
"syscall/js.valueLoadString": (t) => {
|
||||
t >>>= 0;
|
||||
const e = i(t + 8);
|
||||
f(t + 16).set(e);
|
||||
},
|
||||
"syscall/js.valueInstanceOf": (t) => {
|
||||
(t >>>= 0), this.mem.setUint8(t + 24, i(t + 8) instanceof i(t + 16) ? 1 : 0);
|
||||
},
|
||||
"syscall/js.copyBytesToGo": (t) => {
|
||||
t >>>= 0;
|
||||
const e = f(t + 8),
|
||||
l = i(t + 32);
|
||||
if (!(l instanceof Uint8Array || l instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(t + 48, 0);
|
||||
return;
|
||||
}
|
||||
const a = l.subarray(0, e.length);
|
||||
e.set(a), h(t + 40, a.length), this.mem.setUint8(t + 48, 1);
|
||||
},
|
||||
"syscall/js.copyBytesToJS": (t) => {
|
||||
t >>>= 0;
|
||||
const e = i(t + 8),
|
||||
l = f(t + 16);
|
||||
if (!(e instanceof Uint8Array || e instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(t + 48, 0);
|
||||
return;
|
||||
}
|
||||
const a = l.subarray(0, e.length);
|
||||
e.set(a), h(t + 40, a.length), this.mem.setUint8(t + 48, 1);
|
||||
},
|
||||
debug: (t) => {
|
||||
console.log(t);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
async run(h) {
|
||||
if (!(h instanceof WebAssembly.Instance))
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
(this._inst = h),
|
||||
(this.mem = new DataView(this._inst.exports.mem.buffer)),
|
||||
(this._values = [NaN, 0, null, !0, !1, globalThis, this]),
|
||||
(this._goRefCounts = new Array(this._values.length).fill(1 / 0)),
|
||||
(this._ids = new Map([
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[!0, 3],
|
||||
[!1, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
])),
|
||||
(this._idPool = []),
|
||||
(this.exited = !1);
|
||||
let n = 4096;
|
||||
const s = (m) => {
|
||||
const t = n,
|
||||
e = g.encode(m + "\0");
|
||||
return (
|
||||
new Uint8Array(this.mem.buffer, n, e.length).set(e),
|
||||
(n += e.length),
|
||||
n % 8 !== 0 && (n += 8 - (n % 8)),
|
||||
t
|
||||
);
|
||||
},
|
||||
i = this.argv.length,
|
||||
r = [];
|
||||
this.argv.forEach((m) => {
|
||||
r.push(s(m));
|
||||
}),
|
||||
r.push(0),
|
||||
Object.keys(this.env)
|
||||
.sort()
|
||||
.forEach((m) => {
|
||||
r.push(s(`${m}=${this.env[m]}`));
|
||||
}),
|
||||
r.push(0);
|
||||
const u = n;
|
||||
if (
|
||||
(r.forEach((m) => {
|
||||
this.mem.setUint32(n, m, !0), this.mem.setUint32(n + 4, 0, !0), (n += 8);
|
||||
}),
|
||||
n >= 12288)
|
||||
)
|
||||
throw new Error(
|
||||
"total length of command line and environment variables exceeds limit"
|
||||
);
|
||||
this._inst.exports.run(i, u),
|
||||
this.exited && this._resolveExitPromise(),
|
||||
await this._exitPromise;
|
||||
}
|
||||
_resume() {
|
||||
if (this.exited) throw new Error("Go program has already exited");
|
||||
this._inst.exports.resume(), this.exited && this._resolveExitPromise();
|
||||
}
|
||||
_makeFuncWrapper(h) {
|
||||
const n = this;
|
||||
return function () {
|
||||
const s = { id: h, this: this, args: arguments };
|
||||
return (n._pendingEvent = s), n._resume(), s.result;
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -11,8 +11,6 @@ import (
|
|||
|
||||
"log/slog"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
"oss.terrastruct.com/util-go/xdefer"
|
||||
|
||||
"oss.terrastruct.com/util-go/go2"
|
||||
|
|
@ -20,6 +18,7 @@ import (
|
|||
"oss.terrastruct.com/d2/d2graph"
|
||||
"oss.terrastruct.com/d2/d2target"
|
||||
"oss.terrastruct.com/d2/lib/geo"
|
||||
"oss.terrastruct.com/d2/lib/jsrunner"
|
||||
"oss.terrastruct.com/d2/lib/label"
|
||||
"oss.terrastruct.com/d2/lib/log"
|
||||
"oss.terrastruct.com/d2/lib/shape"
|
||||
|
|
@ -80,11 +79,11 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
defer xdefer.Errorf(&err, "failed to dagre layout")
|
||||
|
||||
debugJS := false
|
||||
vm := goja.New()
|
||||
if _, err := vm.RunString(dagreJS); err != nil {
|
||||
runner := jsrunner.NewJSRunner()
|
||||
if _, err := runner.RunString(dagreJS); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vm.RunString(setupJS); err != nil {
|
||||
if _, err := runner.RunString(setupJS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +135,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
}
|
||||
|
||||
configJS := setGraphAttrs(rootAttrs)
|
||||
if _, err := vm.RunString(configJS); err != nil {
|
||||
if _, err := runner.RunString(configJS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -183,11 +182,11 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
log.Debug(ctx, "script", slog.Any("all", setupJS+configJS+loadScript))
|
||||
}
|
||||
|
||||
if _, err := vm.RunString(loadScript); err != nil {
|
||||
if _, err := runner.RunString(loadScript); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := vm.RunString(`dagre.layout(g)`); err != nil {
|
||||
if _, err := runner.RunString(`dagre.layout(g)`); err != nil {
|
||||
if debugJS {
|
||||
log.Warn(ctx, "layout error", slog.Any("err", err))
|
||||
}
|
||||
|
|
@ -195,7 +194,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
}
|
||||
|
||||
for i := range g.Objects {
|
||||
val, err := vm.RunString(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i))
|
||||
val, err := runner.RunString(fmt.Sprintf("JSON.stringify(g.node(g.nodes()[%d]))", i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -216,7 +215,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
}
|
||||
|
||||
for i, edge := range g.Edges {
|
||||
val, err := vm.RunString(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i))
|
||||
val, err := runner.RunString(fmt.Sprintf("JSON.stringify(g.edge(g.edges()[%d]))", i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
10
d2layouts/d2elklayout/elk.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//go:build !js && !wasm
|
||||
|
||||
package d2elklayout
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed elk.js
|
||||
var elkJS string
|
||||
33
d2layouts/d2elklayout/elk.js
vendored
|
|
@ -5,15 +5,7 @@
|
|||
define([], f);
|
||||
} else {
|
||||
var g;
|
||||
if (typeof window !== "undefined") {
|
||||
g = window;
|
||||
} else if (typeof global !== "undefined") {
|
||||
g = global;
|
||||
} else if (typeof self !== "undefined") {
|
||||
g = self;
|
||||
} else {
|
||||
g = this;
|
||||
}
|
||||
g = this;
|
||||
g.ELK = f();
|
||||
}
|
||||
})(function () {
|
||||
|
|
@ -337,9 +329,6 @@
|
|||
|
||||
// -------------- FAKE ELEMENTS GWT ASSUMES EXIST --------------
|
||||
var $wnd = { Error: {} };
|
||||
if (typeof window !== "undefined") $wnd = window;
|
||||
else if (typeof global !== "undefined") $wnd = global; // nodejs
|
||||
else if (typeof self !== "undefined") $wnd = self; // web worker
|
||||
|
||||
var $moduleName, $moduleBase;
|
||||
|
||||
|
|
@ -59795,13 +59784,8 @@
|
|||
}, 0);
|
||||
};
|
||||
}
|
||||
if (typeof document === uke && typeof self !== uke) {
|
||||
var i = new h(self);
|
||||
self.onmessage = i.saveDispatch;
|
||||
} else if (typeof module !== uke && module.exports) {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
module.exports = { default: j, Worker: j };
|
||||
}
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
module.exports = { default: j, Worker: j };
|
||||
}
|
||||
function aae(a) {
|
||||
if (a.N) return;
|
||||
|
|
@ -105682,16 +105666,7 @@
|
|||
// -------------- RUN GWT INITIALIZATION CODE --------------
|
||||
gwtOnLoad(null, "elk", null);
|
||||
}.call(this));
|
||||
}.call(
|
||||
this,
|
||||
typeof global !== "undefined"
|
||||
? global
|
||||
: typeof self !== "undefined"
|
||||
? self
|
||||
: typeof window !== "undefined"
|
||||
? window
|
||||
: {}
|
||||
));
|
||||
}.call(this, {}));
|
||||
},
|
||||
{},
|
||||
],
|
||||
|
|
|
|||
6
d2layouts/d2elklayout/elk_js.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package d2elklayout
|
||||
|
||||
// No embed, since this is already bundled in the js worker
|
||||
var elkJS string
|
||||
|
|
@ -8,15 +8,12 @@ import (
|
|||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
"oss.terrastruct.com/util-go/xdefer"
|
||||
|
||||
"oss.terrastruct.com/util-go/go2"
|
||||
|
|
@ -24,13 +21,11 @@ import (
|
|||
"oss.terrastruct.com/d2/d2graph"
|
||||
"oss.terrastruct.com/d2/d2target"
|
||||
"oss.terrastruct.com/d2/lib/geo"
|
||||
"oss.terrastruct.com/d2/lib/jsrunner"
|
||||
"oss.terrastruct.com/d2/lib/label"
|
||||
"oss.terrastruct.com/d2/lib/shape"
|
||||
)
|
||||
|
||||
//go:embed elk.js
|
||||
var elkJS string
|
||||
|
||||
//go:embed setup.js
|
||||
var setupJS string
|
||||
|
||||
|
|
@ -162,18 +157,20 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
}
|
||||
defer xdefer.Errorf(&err, "failed to ELK layout")
|
||||
|
||||
vm := goja.New()
|
||||
runner := jsrunner.NewJSRunner()
|
||||
|
||||
console := vm.NewObject()
|
||||
if err := vm.Set("console", console); err != nil {
|
||||
return err
|
||||
}
|
||||
if runner.Engine() == jsrunner.Goja {
|
||||
console := runner.NewObject()
|
||||
if err := runner.Set("console", console); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := vm.RunString(elkJS); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vm.RunString(setupJS); err != nil {
|
||||
return err
|
||||
if _, err := runner.RunString(elkJS); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runner.RunString(setupJS); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
elkGraph := &ELKGraph{
|
||||
|
|
@ -443,41 +440,30 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
|
|||
return err
|
||||
}
|
||||
|
||||
loadScript := fmt.Sprintf(`var graph = %s`, raw)
|
||||
var val jsrunner.JSValue
|
||||
if runner.Engine() == jsrunner.Goja {
|
||||
loadScript := fmt.Sprintf(`var graph = %s`, raw)
|
||||
|
||||
if _, err := vm.RunString(loadScript); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runner.RunString(loadScript); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
val, err := vm.RunString(`elk.layout(graph)
|
||||
val, err = runner.RunString(`elk.layout(graph)
|
||||
.then(s => s)
|
||||
.catch(err => err.message)
|
||||
`)
|
||||
|
||||
} else {
|
||||
val, err = runner.MustGet("elkResult")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p := val.Export()
|
||||
result, err := runner.WaitPromise(ctx, val)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("ELK layout error: %v", err)
|
||||
}
|
||||
|
||||
promise := p.(*goja.Promise)
|
||||
|
||||
for promise.State() == goja.PromiseStatePending {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if promise.State() == goja.PromiseStateRejected {
|
||||
return errors.New("ELK: something went wrong")
|
||||
}
|
||||
|
||||
result := promise.Result().Export()
|
||||
|
||||
var jsonOut map[string]interface{}
|
||||
switch out := result.(type) {
|
||||
case string:
|
||||
|
|
|
|||
286
d2layouts/d2elklayout/wasm.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package d2elklayout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"oss.terrastruct.com/d2/d2graph"
|
||||
"oss.terrastruct.com/d2/lib/geo"
|
||||
"oss.terrastruct.com/d2/lib/label"
|
||||
"oss.terrastruct.com/util-go/go2"
|
||||
"oss.terrastruct.com/util-go/xdefer"
|
||||
)
|
||||
|
||||
// This is mostly copy paste from Layout until elk.layout step
|
||||
func ConvertGraph(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (_ *ELKGraph, err error) {
|
||||
if opts == nil {
|
||||
opts = &DefaultOpts
|
||||
}
|
||||
defer xdefer.Errorf(&err, "failed to ELK layout")
|
||||
|
||||
elkGraph := &ELKGraph{
|
||||
ID: "",
|
||||
LayoutOptions: &elkOpts{
|
||||
Thoroughness: 8,
|
||||
EdgeEdgeBetweenLayersSpacing: 50,
|
||||
EdgeNode: edge_node_spacing,
|
||||
HierarchyHandling: "INCLUDE_CHILDREN",
|
||||
FixedAlignment: "BALANCED",
|
||||
ConsiderModelOrder: "NODES_AND_EDGES",
|
||||
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
|
||||
NodeSizeConstraints: "MINIMUM_SIZE",
|
||||
ContentAlignment: "H_CENTER V_CENTER",
|
||||
ConfigurableOpts: ConfigurableOpts{
|
||||
Algorithm: opts.Algorithm,
|
||||
NodeSpacing: opts.NodeSpacing,
|
||||
EdgeNodeSpacing: opts.EdgeNodeSpacing,
|
||||
SelfLoopSpacing: opts.SelfLoopSpacing,
|
||||
},
|
||||
},
|
||||
}
|
||||
if elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
|
||||
// +5 for a tiny bit of padding
|
||||
elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(g.Root, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
|
||||
}
|
||||
switch g.Root.Direction.Value {
|
||||
case "down":
|
||||
elkGraph.LayoutOptions.Direction = Down
|
||||
case "up":
|
||||
elkGraph.LayoutOptions.Direction = Up
|
||||
case "right":
|
||||
elkGraph.LayoutOptions.Direction = Right
|
||||
case "left":
|
||||
elkGraph.LayoutOptions.Direction = Left
|
||||
default:
|
||||
elkGraph.LayoutOptions.Direction = Down
|
||||
}
|
||||
|
||||
// set label and icon positions for ELK
|
||||
for _, obj := range g.Objects {
|
||||
positionLabelsIcons(obj)
|
||||
}
|
||||
|
||||
adjustments := make(map[*d2graph.Object]geo.Spacing)
|
||||
elkNodes := make(map[*d2graph.Object]*ELKNode)
|
||||
elkEdges := make(map[*d2graph.Edge]*ELKEdge)
|
||||
|
||||
// BFS
|
||||
var walk func(*d2graph.Object, *d2graph.Object, func(*d2graph.Object, *d2graph.Object))
|
||||
walk = func(obj, parent *d2graph.Object, fn func(*d2graph.Object, *d2graph.Object)) {
|
||||
if obj.Parent != nil {
|
||||
fn(obj, parent)
|
||||
}
|
||||
for _, ch := range obj.ChildrenArray {
|
||||
walk(ch, obj, fn)
|
||||
}
|
||||
}
|
||||
|
||||
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
|
||||
incoming := 0.
|
||||
outgoing := 0.
|
||||
for _, e := range g.Edges {
|
||||
if e.Src == obj {
|
||||
outgoing++
|
||||
}
|
||||
if e.Dst == obj {
|
||||
incoming++
|
||||
}
|
||||
}
|
||||
if incoming >= 2 || outgoing >= 2 {
|
||||
switch g.Root.Direction.Value {
|
||||
case "right", "left":
|
||||
if obj.Attributes.HeightAttr == nil {
|
||||
obj.Height = math.Max(obj.Height, math.Max(incoming, outgoing)*port_spacing)
|
||||
}
|
||||
default:
|
||||
if obj.Attributes.WidthAttr == nil {
|
||||
obj.Width = math.Max(obj.Width, math.Max(incoming, outgoing)*port_spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if obj.HasLabel() && obj.HasIcon() {
|
||||
// this gives shapes extra height for their label if they also have an icon
|
||||
obj.Height += float64(obj.LabelDimensions.Height + label.PADDING)
|
||||
}
|
||||
|
||||
margin, _ := obj.SpacingOpt(label.PADDING, label.PADDING, false)
|
||||
width := margin.Left + obj.Width + margin.Right
|
||||
height := margin.Top + obj.Height + margin.Bottom
|
||||
adjustments[obj] = margin
|
||||
|
||||
n := &ELKNode{
|
||||
ID: obj.AbsID(),
|
||||
Width: width,
|
||||
Height: height,
|
||||
}
|
||||
|
||||
if len(obj.ChildrenArray) > 0 {
|
||||
n.LayoutOptions = &elkOpts{
|
||||
ForceNodeModelOrder: true,
|
||||
Thoroughness: 8,
|
||||
EdgeEdgeBetweenLayersSpacing: 50,
|
||||
HierarchyHandling: "INCLUDE_CHILDREN",
|
||||
FixedAlignment: "BALANCED",
|
||||
EdgeNode: edge_node_spacing,
|
||||
ConsiderModelOrder: "NODES_AND_EDGES",
|
||||
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
|
||||
NodeSizeConstraints: "MINIMUM_SIZE",
|
||||
ContentAlignment: "H_CENTER V_CENTER",
|
||||
ConfigurableOpts: ConfigurableOpts{
|
||||
NodeSpacing: opts.NodeSpacing,
|
||||
EdgeNodeSpacing: opts.EdgeNodeSpacing,
|
||||
SelfLoopSpacing: opts.SelfLoopSpacing,
|
||||
Padding: opts.Padding,
|
||||
},
|
||||
}
|
||||
if n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
|
||||
n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(obj, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
|
||||
}
|
||||
|
||||
switch elkGraph.LayoutOptions.Direction {
|
||||
case Down, Up:
|
||||
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(height)), int(math.Ceil(width)))
|
||||
case Right, Left:
|
||||
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(width)), int(math.Ceil(height)))
|
||||
}
|
||||
} else {
|
||||
n.LayoutOptions = &elkOpts{
|
||||
SelfLoopDistribution: "EQUALLY",
|
||||
}
|
||||
}
|
||||
|
||||
if obj.IsContainer() {
|
||||
padding := parsePadding(opts.Padding)
|
||||
padding = adjustPadding(obj, width, height, padding)
|
||||
n.LayoutOptions.Padding = padding.String()
|
||||
}
|
||||
|
||||
if obj.HasLabel() {
|
||||
n.Labels = append(n.Labels, &ELKLabel{
|
||||
Text: obj.Label.Value,
|
||||
Width: float64(obj.LabelDimensions.Width),
|
||||
Height: float64(obj.LabelDimensions.Height),
|
||||
})
|
||||
}
|
||||
|
||||
if parent == g.Root {
|
||||
elkGraph.Children = append(elkGraph.Children, n)
|
||||
} else {
|
||||
elkNodes[parent].Children = append(elkNodes[parent].Children, n)
|
||||
}
|
||||
|
||||
if obj.SQLTable != nil {
|
||||
n.LayoutOptions.PortConstraints = "FIXED_POS"
|
||||
columns := obj.SQLTable.Columns
|
||||
colHeight := n.Height / float64(len(columns)+1)
|
||||
n.Ports = make([]*ELKPort, 0, len(columns)*2)
|
||||
var srcSide, dstSide PortSide
|
||||
switch elkGraph.LayoutOptions.Direction {
|
||||
case Left:
|
||||
srcSide, dstSide = West, East
|
||||
default:
|
||||
srcSide, dstSide = East, West
|
||||
}
|
||||
for i, col := range columns {
|
||||
n.Ports = append(n.Ports, &ELKPort{
|
||||
ID: srcPortID(obj, col.Name.Label),
|
||||
Y: float64(i+1)*colHeight + colHeight/2,
|
||||
LayoutOptions: &elkOpts{PortSide: srcSide},
|
||||
})
|
||||
n.Ports = append(n.Ports, &ELKPort{
|
||||
ID: dstPortID(obj, col.Name.Label),
|
||||
Y: float64(i+1)*colHeight + colHeight/2,
|
||||
LayoutOptions: &elkOpts{PortSide: dstSide},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
elkNodes[obj] = n
|
||||
})
|
||||
|
||||
var srcSide, dstSide PortSide
|
||||
switch elkGraph.LayoutOptions.Direction {
|
||||
case Up:
|
||||
srcSide, dstSide = North, South
|
||||
default:
|
||||
srcSide, dstSide = South, North
|
||||
}
|
||||
|
||||
ports := map[struct {
|
||||
obj *d2graph.Object
|
||||
side PortSide
|
||||
}][]*ELKPort{}
|
||||
|
||||
for ei, edge := range g.Edges {
|
||||
var src, dst string
|
||||
|
||||
switch {
|
||||
case edge.SrcTableColumnIndex != nil:
|
||||
src = srcPortID(edge.Src, edge.Src.SQLTable.Columns[*edge.SrcTableColumnIndex].Name.Label)
|
||||
case edge.Src.SQLTable != nil:
|
||||
p := &ELKPort{
|
||||
ID: fmt.Sprintf("%s.%d", srcPortID(edge.Src, "__root__"), ei),
|
||||
LayoutOptions: &elkOpts{PortSide: srcSide},
|
||||
}
|
||||
src = p.ID
|
||||
elkNodes[edge.Src].Ports = append(elkNodes[edge.Src].Ports, p)
|
||||
k := struct {
|
||||
obj *d2graph.Object
|
||||
side PortSide
|
||||
}{edge.Src, srcSide}
|
||||
ports[k] = append(ports[k], p)
|
||||
default:
|
||||
src = edge.Src.AbsID()
|
||||
}
|
||||
|
||||
switch {
|
||||
case edge.DstTableColumnIndex != nil:
|
||||
dst = dstPortID(edge.Dst, edge.Dst.SQLTable.Columns[*edge.DstTableColumnIndex].Name.Label)
|
||||
case edge.Dst.SQLTable != nil:
|
||||
p := &ELKPort{
|
||||
ID: fmt.Sprintf("%s.%d", dstPortID(edge.Dst, "__root__"), ei),
|
||||
LayoutOptions: &elkOpts{PortSide: dstSide},
|
||||
}
|
||||
dst = p.ID
|
||||
elkNodes[edge.Dst].Ports = append(elkNodes[edge.Dst].Ports, p)
|
||||
k := struct {
|
||||
obj *d2graph.Object
|
||||
side PortSide
|
||||
}{edge.Dst, dstSide}
|
||||
ports[k] = append(ports[k], p)
|
||||
default:
|
||||
dst = edge.Dst.AbsID()
|
||||
}
|
||||
|
||||
e := &ELKEdge{
|
||||
ID: edge.AbsID(),
|
||||
Sources: []string{src},
|
||||
Targets: []string{dst},
|
||||
}
|
||||
if edge.Label.Value != "" {
|
||||
e.Labels = append(e.Labels, &ELKLabel{
|
||||
Text: edge.Label.Value,
|
||||
Width: float64(edge.LabelDimensions.Width),
|
||||
Height: float64(edge.LabelDimensions.Height),
|
||||
LayoutOptions: &elkOpts{
|
||||
InlineEdgeLabels: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
elkGraph.Edges = append(elkGraph.Edges, e)
|
||||
elkEdges[edge] = e
|
||||
}
|
||||
|
||||
for k, ports := range ports {
|
||||
width := elkNodes[k.obj].Width
|
||||
spacing := width / float64(len(ports)+1)
|
||||
for i, p := range ports {
|
||||
p.X = float64(i+1) * spacing
|
||||
}
|
||||
}
|
||||
return elkGraph, nil
|
||||
}
|
||||
|
|
@ -190,6 +190,20 @@ func boundingBox(g *d2graph.Graph) (tl, br *geo.Point) {
|
|||
}
|
||||
}
|
||||
|
||||
for _, edge := range g.Edges {
|
||||
if edge.Src.OuterNearContainer() != nil || edge.Dst.OuterNearContainer() != nil {
|
||||
continue
|
||||
}
|
||||
if edge.Route != nil {
|
||||
for _, point := range edge.Route {
|
||||
x1 = math.Min(x1, point.X)
|
||||
y1 = math.Min(y1, point.Y)
|
||||
x2 = math.Max(x2, point.X)
|
||||
y2 = math.Max(y2, point.Y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if math.IsInf(x1, 1) && math.IsInf(x2, -1) {
|
||||
x1 = 0
|
||||
x2 = 0
|
||||
|
|
|
|||
|
|
@ -466,7 +466,12 @@ func (sd *sequenceDiagram) placeNotes() {
|
|||
|
||||
for _, msg := range sd.messages {
|
||||
if sd.verticalIndices[msg.AbsID()] < verticalIndex {
|
||||
y += sd.yStep + float64(msg.LabelDimensions.Height)
|
||||
if msg.Src == msg.Dst {
|
||||
// For self-messages, account for the full vertical space they occupy
|
||||
y += sd.yStep + math.Max(float64(msg.LabelDimensions.Height), MIN_MESSAGE_DISTANCE)*1.5
|
||||
} else {
|
||||
y += sd.yStep + float64(msg.LabelDimensions.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, otherNote := range sd.notes {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,15 @@ func Compile(ctx context.Context, input string, compileOpts *CompileOptions, ren
|
|||
|
||||
d, err := compile(ctx, g, compileOpts, renderOpts)
|
||||
if d != nil {
|
||||
if config == nil {
|
||||
config = &d2target.Config{}
|
||||
}
|
||||
// These are fields that affect a diagram's appearance, so feed them back
|
||||
// into diagram.Config to ensure the hash computed for CSS styling purposes
|
||||
// is unique to its appearance
|
||||
config.ThemeID = renderOpts.ThemeID
|
||||
config.DarkThemeID = renderOpts.DarkThemeID
|
||||
config.Sketch = renderOpts.Sketch
|
||||
d.Config = config
|
||||
}
|
||||
return d, g, err
|
||||
|
|
|
|||
506
d2lsp/completion.go
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
// Completion implements lsp autocomplete features
|
||||
// Currently handles:
|
||||
// - Complete dot and inside maps for reserved keyword holders (style, labels, etc)
|
||||
// - Complete discrete values for keywords like shape
|
||||
// - Complete suggestions for formats for keywords like opacity
|
||||
package d2lsp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"oss.terrastruct.com/d2/d2ast"
|
||||
"oss.terrastruct.com/d2/d2parser"
|
||||
"oss.terrastruct.com/d2/d2target"
|
||||
)
|
||||
|
||||
type CompletionKind int
|
||||
|
||||
const (
|
||||
KeywordCompletion CompletionKind = iota
|
||||
StyleCompletion
|
||||
ShapeCompletion
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
Label string
|
||||
Kind CompletionKind
|
||||
Detail string
|
||||
InsertText string
|
||||
}
|
||||
|
||||
func GetCompletionItems(text string, line, column int) ([]CompletionItem, error) {
|
||||
ast, err := d2parser.Parse("", strings.NewReader(text), nil)
|
||||
if err != nil {
|
||||
ast, _ = d2parser.Parse("", strings.NewReader(getTextUntilPosition(text, line, column)), nil)
|
||||
}
|
||||
|
||||
keyword := getKeywordContext(text, ast, line, column)
|
||||
switch keyword {
|
||||
case "style", "style.":
|
||||
return getStyleCompletions(), nil
|
||||
case "shape", "shape:":
|
||||
return getShapeCompletions(), nil
|
||||
case "shadow", "3d", "multiple", "animated", "bold", "italic", "underline", "filled", "double-border",
|
||||
"shadow:", "3d:", "multiple:", "animated:", "bold:", "italic:", "underline:", "filled:", "double-border:",
|
||||
"style.shadow:", "style.3d:", "style.multiple:", "style.animated:", "style.bold:", "style.italic:", "style.underline:", "style.filled:", "style.double-border:":
|
||||
return getBooleanCompletions(), nil
|
||||
case "fill-pattern", "fill-pattern:", "style.fill-pattern:":
|
||||
return getFillPatternCompletions(), nil
|
||||
case "text-transform", "text-transform:", "style.text-transform:":
|
||||
return getTextTransformCompletions(), nil
|
||||
case "opacity", "stroke-width", "stroke-dash", "border-radius", "font-size",
|
||||
"stroke", "fill", "font-color":
|
||||
return getValueCompletions(keyword), nil
|
||||
case "opacity:", "stroke-width:", "stroke-dash:", "border-radius:", "font-size:",
|
||||
"stroke:", "fill:", "font-color:",
|
||||
"style.opacity:", "style.stroke-width:", "style.stroke-dash:", "style.border-radius:", "style.font-size:",
|
||||
"style.stroke:", "style.fill:", "style.font-color:":
|
||||
return getValueCompletions(strings.TrimSuffix(strings.TrimPrefix(keyword, "style."), ":")), nil
|
||||
case "width", "height", "top", "left":
|
||||
return getValueCompletions(keyword), nil
|
||||
case "width:", "height:", "top:", "left:":
|
||||
return getValueCompletions(keyword[:len(keyword)-1]), nil
|
||||
case "source-arrowhead", "target-arrowhead":
|
||||
return getArrowheadCompletions(), nil
|
||||
case "source-arrowhead.shape:", "target-arrowhead.shape:":
|
||||
return getArrowheadShapeCompletions(), nil
|
||||
case "label", "label.":
|
||||
return getLabelCompletions(), nil
|
||||
case "icon", "icon:":
|
||||
return getIconCompletions(), nil
|
||||
case "icon.":
|
||||
return getLabelCompletions(), nil
|
||||
case "near", "near:":
|
||||
return getNearCompletions(), nil
|
||||
case "tooltip:", "tooltip":
|
||||
return getTooltipCompletions(), nil
|
||||
case "direction:", "direction":
|
||||
return getDirectionCompletions(), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getTextUntilPosition(text string, line, column int) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
if line >= len(lines) {
|
||||
return text
|
||||
}
|
||||
|
||||
result := strings.Join(lines[:line], "\n")
|
||||
if len(result) > 0 {
|
||||
result += "\n"
|
||||
}
|
||||
if column > len(lines[line]) {
|
||||
result += lines[line]
|
||||
} else {
|
||||
result += lines[line][:column]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getKeywordContext(text string, m *d2ast.Map, line, column int) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
for _, n := range m.Nodes {
|
||||
if n.MapKey == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var firstPart, lastPart string
|
||||
var key *d2ast.KeyPath
|
||||
if len(n.MapKey.Edges) > 0 {
|
||||
key = n.MapKey.EdgeKey
|
||||
} else {
|
||||
key = n.MapKey.Key
|
||||
}
|
||||
if key != nil && len(key.Path) > 0 {
|
||||
firstKey := key.Path[0].Unbox()
|
||||
if !firstKey.IsUnquoted() {
|
||||
continue
|
||||
}
|
||||
firstPart = firstKey.ScalarString()
|
||||
|
||||
pathLen := len(key.Path)
|
||||
if pathLen > 1 {
|
||||
lastKey := key.Path[pathLen-1].Unbox()
|
||||
if lastKey.IsUnquoted() {
|
||||
lastPart = lastKey.ScalarString()
|
||||
_, isHolderLast := d2ast.ReservedKeywordHolders[lastPart]
|
||||
if !isHolderLast {
|
||||
_, isHolderLast = d2ast.CompositeReservedKeywords[lastPart]
|
||||
}
|
||||
keyRange := n.MapKey.Range
|
||||
lineText := lines[keyRange.End.Line]
|
||||
if isHolderLast && isAfterDot(lineText, column) {
|
||||
return lastPart + "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, isBoard := d2ast.BoardKeywords[firstPart]; isBoard {
|
||||
firstPart = ""
|
||||
}
|
||||
if firstPart == "classes" {
|
||||
firstPart = ""
|
||||
}
|
||||
|
||||
_, isHolder := d2ast.ReservedKeywordHolders[firstPart]
|
||||
if !isHolder {
|
||||
_, isHolder = d2ast.CompositeReservedKeywords[firstPart]
|
||||
}
|
||||
|
||||
// Check nested map
|
||||
if n.MapKey.Value.Map != nil && isPositionInMap(line, column, n.MapKey.Value.Map) {
|
||||
if nested := getKeywordContext(text, n.MapKey.Value.Map, line, column); nested != "" {
|
||||
if isHolder {
|
||||
// If we got a direct key completion from inside a holder's map,
|
||||
// prefix it with the holder's name
|
||||
if strings.HasSuffix(nested, ":") && !strings.Contains(nested, ".") {
|
||||
return firstPart + "." + strings.TrimSuffix(nested, ":") + ":"
|
||||
}
|
||||
}
|
||||
return nested
|
||||
}
|
||||
return firstPart
|
||||
}
|
||||
|
||||
keyRange := n.MapKey.Range
|
||||
if line != keyRange.End.Line {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1) Skip if cursor is well above/below this key
|
||||
if line < keyRange.Start.Line || line > keyRange.End.Line {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2) If on the start line, skip if before the key
|
||||
if line == keyRange.Start.Line && column < keyRange.Start.Column {
|
||||
continue
|
||||
}
|
||||
|
||||
// 3) If on the end line, allow up to keyRange.End.Column + 1
|
||||
if line == keyRange.End.Line && column > keyRange.End.Column+1 {
|
||||
continue
|
||||
}
|
||||
|
||||
lineText := lines[keyRange.End.Line]
|
||||
|
||||
if isAfterColon(lineText, column) {
|
||||
if key != nil && len(key.Path) > 1 {
|
||||
if isHolder && (firstPart == "source-arrowhead" || firstPart == "target-arrowhead") {
|
||||
return firstPart + "." + lastPart + ":"
|
||||
}
|
||||
|
||||
_, isHolder := d2ast.ReservedKeywordHolders[lastPart]
|
||||
if !isHolder {
|
||||
return lastPart
|
||||
}
|
||||
}
|
||||
return firstPart + ":"
|
||||
}
|
||||
|
||||
if isAfterDot(lineText, column) && isHolder {
|
||||
return firstPart
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAfterDot(text string, pos int) bool {
|
||||
return pos > 0 && pos <= len(text) && text[pos-1] == '.'
|
||||
}
|
||||
|
||||
func isAfterColon(text string, pos int) bool {
|
||||
if pos < 1 || pos > len(text) {
|
||||
return false
|
||||
}
|
||||
i := pos - 1
|
||||
for i >= 0 && unicode.IsSpace(rune(text[i])) {
|
||||
i--
|
||||
}
|
||||
return i >= 0 && text[i] == ':'
|
||||
}
|
||||
|
||||
func isPositionInMap(line, column int, m *d2ast.Map) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
mapRange := m.Range
|
||||
if line < mapRange.Start.Line || line > mapRange.End.Line {
|
||||
return false
|
||||
}
|
||||
|
||||
if line == mapRange.Start.Line && column < mapRange.Start.Column {
|
||||
return false
|
||||
}
|
||||
if line == mapRange.End.Line && column > mapRange.End.Column {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getShapeCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2target.Shapes))
|
||||
for _, shape := range d2target.Shapes {
|
||||
item := CompletionItem{
|
||||
Label: shape,
|
||||
Kind: ShapeCompletion,
|
||||
Detail: "shape",
|
||||
InsertText: shape,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getValueCompletions(property string) []CompletionItem {
|
||||
switch property {
|
||||
case "opacity":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 0.0 and 1.0)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 0.4",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "stroke-width":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 0 and 15)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 2",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "font-size":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 8 and 100)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 14",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "stroke-dash":
|
||||
return []CompletionItem{{
|
||||
Label: "(number between 0 and 10)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 5",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "border-radius":
|
||||
return []CompletionItem{{
|
||||
Label: "(number greater than or equal to 0)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 4",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "font-color", "stroke", "fill":
|
||||
return []CompletionItem{{
|
||||
Label: "(color name or hex code)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. blue, #ff0000",
|
||||
InsertText: "",
|
||||
}}
|
||||
case "width", "height", "top", "left":
|
||||
return []CompletionItem{{
|
||||
Label: "(pixels)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. 400",
|
||||
InsertText: "",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStyleCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.StyleKeywords))
|
||||
for keyword := range d2ast.StyleKeywords {
|
||||
item := CompletionItem{
|
||||
Label: keyword,
|
||||
Kind: StyleCompletion,
|
||||
Detail: "style property",
|
||||
InsertText: keyword + ": ",
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getBooleanCompletions() []CompletionItem {
|
||||
return []CompletionItem{
|
||||
{
|
||||
Label: "true",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "boolean",
|
||||
InsertText: "true",
|
||||
},
|
||||
{
|
||||
Label: "false",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "boolean",
|
||||
InsertText: "false",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getFillPatternCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.FillPatterns))
|
||||
for _, pattern := range d2ast.FillPatterns {
|
||||
item := CompletionItem{
|
||||
Label: pattern,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "fill pattern",
|
||||
InsertText: pattern,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getTextTransformCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.TextTransforms))
|
||||
for _, transform := range d2ast.TextTransforms {
|
||||
item := CompletionItem{
|
||||
Label: transform,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "text transform",
|
||||
InsertText: transform,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func isOnEmptyLine(text string, line int) bool {
|
||||
lines := strings.Split(text, "\n")
|
||||
if line >= len(lines) {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.TrimSpace(lines[line]) == ""
|
||||
}
|
||||
|
||||
func getLabelCompletions() []CompletionItem {
|
||||
return []CompletionItem{{
|
||||
Label: "near",
|
||||
Kind: StyleCompletion,
|
||||
Detail: "label position",
|
||||
InsertText: "near: ",
|
||||
}}
|
||||
}
|
||||
|
||||
func getNearCompletions() []CompletionItem {
|
||||
items := make([]CompletionItem, 0, len(d2ast.LabelPositionsArray)+1)
|
||||
|
||||
items = append(items, CompletionItem{
|
||||
Label: "(object ID)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "e.g. container.inner_shape",
|
||||
InsertText: "",
|
||||
})
|
||||
|
||||
for _, pos := range d2ast.LabelPositionsArray {
|
||||
item := CompletionItem{
|
||||
Label: pos,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "label position",
|
||||
InsertText: pos,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getTooltipCompletions() []CompletionItem {
|
||||
return []CompletionItem{
|
||||
{
|
||||
Label: "(markdown)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "markdown formatted text",
|
||||
InsertText: "|md\n # Tooltip\n Hello world\n|",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getIconCompletions() []CompletionItem {
|
||||
return []CompletionItem{
|
||||
{
|
||||
Label: "(URL, e.g. https://icons.terrastruct.com/xyz.svg)",
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "icon URL",
|
||||
InsertText: "https://icons.terrastruct.com/essentials%2F073-add.svg",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getDirectionCompletions() []CompletionItem {
|
||||
directions := []string{"up", "down", "right", "left"}
|
||||
items := make([]CompletionItem, len(directions))
|
||||
for i, dir := range directions {
|
||||
items[i] = CompletionItem{
|
||||
Label: dir,
|
||||
Kind: KeywordCompletion,
|
||||
Detail: "direction",
|
||||
InsertText: dir,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getArrowheadShapeCompletions() []CompletionItem {
|
||||
arrowheads := []string{
|
||||
"triangle",
|
||||
"arrow",
|
||||
"diamond",
|
||||
"circle",
|
||||
"cf-one", "cf-one-required",
|
||||
"cf-many", "cf-many-required",
|
||||
}
|
||||
|
||||
items := make([]CompletionItem, len(arrowheads))
|
||||
details := map[string]string{
|
||||
"triangle": "default",
|
||||
"arrow": "like triangle but pointier",
|
||||
"cf-one": "crows foot one",
|
||||
"cf-one-required": "crows foot one (required)",
|
||||
"cf-many": "crows foot many",
|
||||
"cf-many-required": "crows foot many (required)",
|
||||
}
|
||||
|
||||
for i, shape := range arrowheads {
|
||||
detail := details[shape]
|
||||
if detail == "" {
|
||||
detail = "arrowhead shape"
|
||||
}
|
||||
items[i] = CompletionItem{
|
||||
Label: shape,
|
||||
Kind: ShapeCompletion,
|
||||
Detail: detail,
|
||||
InsertText: shape,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func getArrowheadCompletions() []CompletionItem {
|
||||
completions := []string{
|
||||
"shape",
|
||||
"label",
|
||||
"style.filled",
|
||||
}
|
||||
|
||||
items := make([]CompletionItem, len(completions))
|
||||
|
||||
for i, shape := range completions {
|
||||
items[i] = CompletionItem{
|
||||
Label: shape,
|
||||
Kind: ShapeCompletion,
|
||||
InsertText: shape,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
454
d2lsp/completion_test.go
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
package d2lsp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetCompletionItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
line int
|
||||
column int
|
||||
want []CompletionItem
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "style dot suggestions",
|
||||
text: "a.style.",
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getStyleCompletions(),
|
||||
},
|
||||
{
|
||||
name: "style map suggestions",
|
||||
text: `a: {
|
||||
style.
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 8,
|
||||
want: getStyleCompletions(),
|
||||
},
|
||||
{
|
||||
name: "classes shapes",
|
||||
text: `classes: {
|
||||
goal: {
|
||||
shape:
|
||||
}
|
||||
}
|
||||
`,
|
||||
line: 2,
|
||||
column: 10,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "nested style map suggestions",
|
||||
text: `a: {
|
||||
style: {
|
||||
3d:
|
||||
}
|
||||
}
|
||||
`,
|
||||
line: 2,
|
||||
column: 7,
|
||||
want: getBooleanCompletions(),
|
||||
},
|
||||
{
|
||||
name: "3d style map suggestions",
|
||||
text: `a.style: {
|
||||
3d:
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 5,
|
||||
want: getBooleanCompletions(),
|
||||
},
|
||||
{
|
||||
name: "fill pattern style map suggestions",
|
||||
text: `a.style: {
|
||||
fill-pattern:
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 15,
|
||||
want: getFillPatternCompletions(),
|
||||
},
|
||||
{
|
||||
name: "opacity style map suggestions",
|
||||
text: `a.style: {
|
||||
opacity:
|
||||
}
|
||||
`,
|
||||
line: 1,
|
||||
column: 10,
|
||||
want: getValueCompletions("opacity"),
|
||||
},
|
||||
{
|
||||
name: "width dot",
|
||||
text: `a.width:`,
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getValueCompletions("width"),
|
||||
},
|
||||
{
|
||||
name: "layer shape",
|
||||
text: `a
|
||||
|
||||
layers: {
|
||||
hey: {
|
||||
go: {
|
||||
shape:
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
line: 5,
|
||||
column: 12,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "stroke width value",
|
||||
text: `a.style.stroke-width: 1`,
|
||||
line: 0,
|
||||
column: 23,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no style suggestions",
|
||||
text: `a.style:
|
||||
`,
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "style property suggestions",
|
||||
text: "a -> b: { style. }",
|
||||
line: 0,
|
||||
column: 16,
|
||||
want: getStyleCompletions(),
|
||||
},
|
||||
{
|
||||
name: "style.opacity value hint",
|
||||
text: "a -> b: { style.opacity: }",
|
||||
line: 0,
|
||||
column: 24,
|
||||
want: getValueCompletions("opacity"),
|
||||
},
|
||||
{
|
||||
name: "fill pattern completions",
|
||||
text: "a -> b: { style.fill-pattern: }",
|
||||
line: 0,
|
||||
column: 29,
|
||||
want: getFillPatternCompletions(),
|
||||
},
|
||||
{
|
||||
name: "text transform completions",
|
||||
text: "a -> b: { style.text-transform: }",
|
||||
line: 0,
|
||||
column: 31,
|
||||
want: getTextTransformCompletions(),
|
||||
},
|
||||
{
|
||||
name: "boolean property completions",
|
||||
text: "a -> b: { style.shadow: }",
|
||||
line: 0,
|
||||
column: 23,
|
||||
want: getBooleanCompletions(),
|
||||
},
|
||||
{
|
||||
name: "near position completions",
|
||||
text: "a -> b: { label.near: }",
|
||||
line: 0,
|
||||
column: 21,
|
||||
want: getNearCompletions(),
|
||||
},
|
||||
{
|
||||
name: "direction completions",
|
||||
text: "a -> b: { direction: }",
|
||||
line: 0,
|
||||
column: 20,
|
||||
want: getDirectionCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon url completions",
|
||||
text: "a -> b: { icon: }",
|
||||
line: 0,
|
||||
column: 15,
|
||||
want: getIconCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon dot url completions",
|
||||
text: "a.icon:",
|
||||
line: 0,
|
||||
column: 7,
|
||||
want: getIconCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon near completions",
|
||||
text: "a -> b: { icon.near: }",
|
||||
line: 0,
|
||||
column: 20,
|
||||
want: getNearCompletions(),
|
||||
},
|
||||
{
|
||||
name: "icon map",
|
||||
text: `a.icon: {
|
||||
# here
|
||||
}`,
|
||||
line: 1,
|
||||
column: 2,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "icon flat dot",
|
||||
text: `a.icon.`,
|
||||
line: 0,
|
||||
column: 7,
|
||||
want: getLabelCompletions(),
|
||||
},
|
||||
{
|
||||
name: "label flat dot",
|
||||
text: `a.label.`,
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getLabelCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead completions - dot syntax",
|
||||
text: "a -> b: { source-arrowhead. }",
|
||||
line: 0,
|
||||
column: 27,
|
||||
want: getArrowheadCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead completions - colon syntax",
|
||||
text: "a -> b: { source-arrowhead: }",
|
||||
line: 0,
|
||||
column: 27,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "arrowhead completions - map syntax",
|
||||
text: `a -> b: {
|
||||
source-arrowhead: {
|
||||
# here
|
||||
}
|
||||
}`,
|
||||
line: 2,
|
||||
column: 4,
|
||||
want: getArrowheadCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead shape completions - flat dot syntax",
|
||||
text: "(a -> b)[0].source-arrowhead.shape:",
|
||||
line: 0,
|
||||
column: 35,
|
||||
want: getArrowheadShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead shape completions - dot syntax",
|
||||
text: "a -> b: { source-arrowhead.shape: }",
|
||||
line: 0,
|
||||
column: 33,
|
||||
want: getArrowheadShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "arrowhead shape completions - map syntax",
|
||||
text: "a -> b: { source-arrowhead: { shape: } }",
|
||||
line: 0,
|
||||
column: 36,
|
||||
want: getArrowheadShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "width value hint",
|
||||
text: "a -> b: { width: }",
|
||||
line: 0,
|
||||
column: 16,
|
||||
want: getValueCompletions("width"),
|
||||
},
|
||||
{
|
||||
name: "height value hint",
|
||||
text: "a -> b: { height: }",
|
||||
line: 0,
|
||||
column: 17,
|
||||
want: getValueCompletions("height"),
|
||||
},
|
||||
{
|
||||
name: "tooltip markdown template",
|
||||
text: "a -> b: { tooltip: }",
|
||||
line: 0,
|
||||
column: 18,
|
||||
want: getTooltipCompletions(),
|
||||
},
|
||||
{
|
||||
name: "tooltip dot markdown template",
|
||||
text: "a.tooltip:",
|
||||
line: 0,
|
||||
column: 10,
|
||||
want: getTooltipCompletions(),
|
||||
},
|
||||
{
|
||||
name: "shape dot suggestions",
|
||||
text: "a.shape:",
|
||||
line: 0,
|
||||
column: 8,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "shape suggestions",
|
||||
text: "a -> b: { shape: }",
|
||||
line: 0,
|
||||
column: 16,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
{
|
||||
name: "shape 2 suggestions",
|
||||
text: `a: {
|
||||
shape:
|
||||
}`,
|
||||
line: 1,
|
||||
column: 8,
|
||||
want: getShapeCompletions(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GetCompletionItems(tt.text, tt.line, tt.column)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetCompletionItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("GetCompletionItems() got %d completions, want %d", len(got), len(tt.want))
|
||||
return
|
||||
}
|
||||
|
||||
// Create maps for easy comparison
|
||||
gotMap := make(map[string]CompletionItem)
|
||||
wantMap := make(map[string]CompletionItem)
|
||||
for _, item := range got {
|
||||
gotMap[item.Label] = item
|
||||
}
|
||||
for _, item := range tt.want {
|
||||
wantMap[item.Label] = item
|
||||
}
|
||||
|
||||
// Check that each completion exists and has correct properties
|
||||
for label, wantItem := range wantMap {
|
||||
gotItem, exists := gotMap[label]
|
||||
if !exists {
|
||||
t.Errorf("missing completion for %q", label)
|
||||
continue
|
||||
}
|
||||
if gotItem.Kind != wantItem.Kind {
|
||||
t.Errorf("completion %q Kind = %v, want %v", label, gotItem.Kind, wantItem.Kind)
|
||||
}
|
||||
if gotItem.Detail != wantItem.Detail {
|
||||
t.Errorf("completion %q Detail = %v, want %v", label, gotItem.Detail, wantItem.Detail)
|
||||
}
|
||||
if gotItem.InsertText != wantItem.InsertText {
|
||||
t.Errorf("completion %q InsertText = %v, want %v", label, gotItem.InsertText, wantItem.InsertText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare CompletionItem slices
|
||||
func equalCompletions(a, b []CompletionItem) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Label != b[i].Label ||
|
||||
a[i].Kind != b[i].Kind ||
|
||||
a[i].Detail != b[i].Detail ||
|
||||
a[i].InsertText != b[i].InsertText {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGetArrowheadShapeCompletions(t *testing.T) {
|
||||
got := getArrowheadShapeCompletions()
|
||||
|
||||
expectedLabels := []string{
|
||||
"triangle", "arrow", "diamond", "circle",
|
||||
"cf-one", "cf-one-required",
|
||||
"cf-many", "cf-many-required",
|
||||
}
|
||||
|
||||
if len(got) != len(expectedLabels) {
|
||||
t.Errorf("getArrowheadShapeCompletions() returned %d items, want %d", len(got), len(expectedLabels))
|
||||
return
|
||||
}
|
||||
|
||||
for i, label := range expectedLabels {
|
||||
if got[i].Label != label {
|
||||
t.Errorf("completion[%d].Label = %v, want %v", i, got[i].Label, label)
|
||||
}
|
||||
if got[i].Kind != ShapeCompletion {
|
||||
t.Errorf("completion[%d].Kind = %v, want ShapeCompletion", i, got[i].Kind)
|
||||
}
|
||||
if got[i].InsertText != label {
|
||||
t.Errorf("completion[%d].InsertText = %v, want %v", i, got[i].InsertText, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValueCompletions(t *testing.T) {
|
||||
tests := []struct {
|
||||
property string
|
||||
wantLabel string
|
||||
wantDetail string
|
||||
}{
|
||||
{
|
||||
property: "opacity",
|
||||
wantLabel: "(number between 0.0 and 1.0)",
|
||||
wantDetail: "e.g. 0.4",
|
||||
},
|
||||
{
|
||||
property: "stroke-width",
|
||||
wantLabel: "(number between 0 and 15)",
|
||||
wantDetail: "e.g. 2",
|
||||
},
|
||||
{
|
||||
property: "font-size",
|
||||
wantLabel: "(number between 8 and 100)",
|
||||
wantDetail: "e.g. 14",
|
||||
},
|
||||
{
|
||||
property: "width",
|
||||
wantLabel: "(pixels)",
|
||||
wantDetail: "e.g. 400",
|
||||
},
|
||||
{
|
||||
property: "stroke",
|
||||
wantLabel: "(color name or hex code)",
|
||||
wantDetail: "e.g. blue, #ff0000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.property, func(t *testing.T) {
|
||||
got := getValueCompletions(tt.property)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("getValueCompletions(%s) returned %d items, want 1", tt.property, len(got))
|
||||
}
|
||||
if got[0].Label != tt.wantLabel {
|
||||
t.Errorf("completion.Label = %v, want %v", got[0].Label, tt.wantLabel)
|
||||
}
|
||||
if got[0].Detail != tt.wantDetail {
|
||||
t.Errorf("completion.Detail = %v, want %v", got[0].Detail, tt.wantDetail)
|
||||
}
|
||||
if got[0].InsertText != "" {
|
||||
t.Errorf("completion.InsertText = %v, want empty string", got[0].InsertText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,9 @@ func Create(g *d2graph.Graph, boardPath []string, key string) (_ *d2graph.Graph,
|
|||
}
|
||||
// TODO beter name
|
||||
baseAST = boardG.BaseAST
|
||||
if baseAST == nil {
|
||||
return nil, "", fmt.Errorf("board %v cannot be modified through this file", boardPath)
|
||||
}
|
||||
}
|
||||
|
||||
newKey, edge, err := generateUniqueKey(boardG, key, nil, nil)
|
||||
|
|
@ -98,6 +101,9 @@ func Set(g *d2graph.Graph, boardPath []string, key string, tag, value *string) (
|
|||
}
|
||||
// TODO beter name
|
||||
baseAST = boardG.BaseAST
|
||||
if baseAST == nil {
|
||||
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
|
||||
}
|
||||
}
|
||||
|
||||
err = _set(boardG, baseAST, key, tag, value)
|
||||
|
|
@ -142,6 +148,9 @@ func ReconnectEdge(g *d2graph.Graph, boardPath []string, edgeKey string, srcKey,
|
|||
}
|
||||
// TODO beter name
|
||||
baseAST = boardG.BaseAST
|
||||
if baseAST == nil {
|
||||
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
|
||||
}
|
||||
}
|
||||
|
||||
obj := boardG.Root
|
||||
|
|
@ -946,6 +955,9 @@ func Delete(g *d2graph.Graph, boardPath []string, key string) (_ *d2graph.Graph,
|
|||
}
|
||||
// TODO beter name
|
||||
baseAST = boardG.BaseAST
|
||||
if baseAST == nil {
|
||||
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
|
||||
}
|
||||
}
|
||||
|
||||
g2, err := deleteReserved(g, boardPath, baseAST, mk)
|
||||
|
|
@ -1761,6 +1773,9 @@ func move(g *d2graph.Graph, boardPath []string, key, newKey string, includeDesce
|
|||
}
|
||||
// TODO beter name
|
||||
baseAST = boardG.BaseAST
|
||||
if baseAST == nil {
|
||||
return nil, fmt.Errorf("board %v cannot be modified through this file", boardPath)
|
||||
}
|
||||
}
|
||||
|
||||
newKey, _, err := generateUniqueKey(boardG, newKey, nil, nil)
|
||||
|
|
|
|||
|
|
@ -559,6 +559,37 @@ layers: {
|
|||
expKey: `d`,
|
||||
exp: `b
|
||||
|
||||
layers: {
|
||||
c: {
|
||||
d
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "add_layer/5",
|
||||
text: `classes: {
|
||||
a: {
|
||||
style.stroke: red
|
||||
}
|
||||
}
|
||||
b
|
||||
|
||||
layers: {
|
||||
c
|
||||
}
|
||||
`,
|
||||
key: `d`,
|
||||
|
||||
boardPath: []string{"c"},
|
||||
expKey: `d`,
|
||||
exp: `classes: {
|
||||
a: {
|
||||
style.stroke: red
|
||||
}
|
||||
}
|
||||
b
|
||||
|
||||
layers: {
|
||||
c: {
|
||||
d
|
||||
|
|
@ -2436,6 +2467,28 @@ layers: {
|
|||
(a -> b)[0].style.stroke: red
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "import/10",
|
||||
|
||||
text: `heyn
|
||||
|
||||
layers: {
|
||||
man: {...@meow}
|
||||
}
|
||||
`,
|
||||
fsTexts: map[string]string{
|
||||
"meow.d2": `layers: {
|
||||
1: {
|
||||
asdf
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
boardPath: []string{"man", "1"},
|
||||
key: `asdf.link`,
|
||||
value: go2.Pointer(`_._`),
|
||||
expErr: `failed to set "asdf.link" to "\"_._\"": board [man 1] cannot be modified through this file`,
|
||||
},
|
||||
{
|
||||
name: "label-near/1",
|
||||
|
||||
|
|
@ -2666,6 +2719,31 @@ scenarios: {
|
|||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "step-connection",
|
||||
|
||||
text: `steps: {
|
||||
1: {
|
||||
Modules -- Metricbeat: {
|
||||
style.stroke-width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`,
|
||||
key: `Metricbeat.style.stroke`,
|
||||
value: go2.Pointer(`red`),
|
||||
boardPath: []string{"1"},
|
||||
exp: `steps: {
|
||||
1: {
|
||||
Modules -- Metricbeat: {
|
||||
style.stroke-width: 1
|
||||
}
|
||||
Metricbeat.style.stroke: red
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
|
@ -7999,6 +8077,32 @@ y
|
|||
y
|
||||
|
||||
(* -> *)[*].style.opacity: 0.8
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "layer-delete-complex-object",
|
||||
|
||||
text: `k
|
||||
|
||||
layers: {
|
||||
x: {
|
||||
a: "b" {
|
||||
top: 184
|
||||
left: 180
|
||||
}
|
||||
j
|
||||
}
|
||||
}
|
||||
`,
|
||||
key: `a`,
|
||||
boardPath: []string{"x"},
|
||||
exp: `k
|
||||
|
||||
layers: {
|
||||
x: {
|
||||
j
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ func GetID(key string) string {
|
|||
|
||||
func GetWriteableRefs(obj *d2graph.Object, writeableAST *d2ast.Map) (out []d2graph.Reference) {
|
||||
for i, ref := range obj.References {
|
||||
if ref.ScopeAST == writeableAST && ref.Key.Range.Path == writeableAST.Range.Path {
|
||||
if ref.ScopeAST == writeableAST && ref.Key.Range.Path == writeableAST.Range.Path && len(ref.MapKey.Edges) == 0 {
|
||||
out = append(out, obj.References[i])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1480,12 +1480,6 @@ func (p *parser) parseBlockString() *d2ast.BlockString {
|
|||
}
|
||||
|
||||
if r != endHint {
|
||||
if (bs.Tag == "latex" || bs.Tag == "tex") && r == '\\' {
|
||||
// For LaTeX, where single backslash is common, we escape it so that users don't have to write double the backslashes
|
||||
sb.WriteRune('\\')
|
||||
sb.WriteRune('\\')
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO
|
|||
)
|
||||
fmt.Fprint(buf, fitToScreenWrapperOpening)
|
||||
|
||||
innerOpening := fmt.Sprintf(`<svg id="d2-svg" width="%d" height="%d" viewBox="%d %d %d %d">`,
|
||||
innerOpening := fmt.Sprintf(`<svg class="d2-svg" width="%d" height="%d" viewBox="%d %d %d %d">`,
|
||||
width, height, left, top, width, height)
|
||||
fmt.Fprint(buf, innerOpening)
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO
|
|||
svgsStr += string(svg) + " "
|
||||
}
|
||||
|
||||
diagramHash, err := rootDiagram.HashID()
|
||||
diagramHash, err := rootDiagram.HashID(renderOpts.Salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO
|
|||
}
|
||||
|
||||
if renderOpts.Sketch != nil && *renderOpts.Sketch {
|
||||
d2sketch.DefineFillPatterns(buf)
|
||||
d2sketch.DefineFillPatterns(buf, diagramHash)
|
||||
}
|
||||
|
||||
fmt.Fprint(buf, `<style type="text/css"><![CDATA[`)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import (
|
|||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
"oss.terrastruct.com/d2/lib/jsrunner"
|
||||
"oss.terrastruct.com/util-go/xdefer"
|
||||
)
|
||||
|
||||
|
|
@ -29,21 +29,25 @@ var svgRe = regexp.MustCompile(`<svg[^>]+width="([0-9\.]+)ex" height="([0-9\.]+)
|
|||
|
||||
func Render(s string) (_ string, err error) {
|
||||
defer xdefer.Errorf(&err, "latex failed to parse")
|
||||
vm := goja.New()
|
||||
s = doubleBackslashes(s)
|
||||
runner := jsrunner.NewJSRunner()
|
||||
|
||||
if _, err := vm.RunString(polyfillsJS); err != nil {
|
||||
if _, err := runner.RunString(polyfillsJS); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := vm.RunString(mathjaxJS); err != nil {
|
||||
if _, err := runner.RunString(mathjaxJS); err != nil {
|
||||
// Known issue that a harmless error occurs in JS: https://github.com/mathjax/MathJax/issues/3289
|
||||
if runner.Engine() == jsrunner.Goja {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := runner.RunString(setupJS); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := vm.RunString(setupJS); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
val, err := vm.RunString(fmt.Sprintf(`adaptor.innerHTML(html.convert(`+"`"+"%s`"+`, {
|
||||
val, err := runner.RunString(fmt.Sprintf(`adaptor.innerHTML(html.convert(`+"`"+"%s`"+`, {
|
||||
em: %d,
|
||||
ex: %d,
|
||||
}))`, s, pxPerEx*2, pxPerEx))
|
||||
|
|
@ -80,3 +84,15 @@ func Measure(s string) (width, height int, err error) {
|
|||
|
||||
return int(math.Ceil(wf * float64(pxPerEx))), int(math.Ceil(hf * float64(pxPerEx))), nil
|
||||
}
|
||||
|
||||
func doubleBackslashes(s string) string {
|
||||
var result strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' {
|
||||
result.WriteString("\\\\")
|
||||
} else {
|
||||
result.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
func TestRender(t *testing.T) {
|
||||
txts := []string{
|
||||
`a + b = c`,
|
||||
`\\frac{1}{2}`,
|
||||
`\frac{1}{2}`,
|
||||
`a + b
|
||||
= c
|
||||
`,
|
||||
|
|
@ -24,10 +24,3 @@ func TestRender(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderError(t *testing.T) {
|
||||
_, err := Render(`\frac{1}{2}`)
|
||||
if err == nil {
|
||||
t.Fatal("expected to error on invalid latex syntax")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,10 @@ const adaptor = MathJax._.adaptors.liteAdaptor.liteAdaptor();
|
|||
MathJax._.handlers.html_ts.RegisterHTMLHandler(adaptor)
|
||||
const html = MathJax._.mathjax.mathjax.document('', {
|
||||
InputJax: new MathJax._.input.tex_ts.TeX({ packages: ['base', 'mathtools', 'ams', 'amscd', 'braket', 'cancel', 'cases', 'color', 'gensymb', 'mhchem', 'physics'] }),
|
||||
OutputJax: new MathJax._.output.svg_ts.SVG(),
|
||||
OutputJax: new MathJax._.output.svg_ts.SVG({fontCache: 'none'}),
|
||||
});
|
||||
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.adaptor = adaptor;
|
||||
globalThis.html = html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,7 @@ const root = {
|
|||
};
|
||||
const rc = rough.svg(root, { seed: 1 });
|
||||
let node;
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.rc = rc;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ import (
|
|||
|
||||
_ "embed"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
"oss.terrastruct.com/d2/d2target"
|
||||
"oss.terrastruct.com/d2/d2themes"
|
||||
"oss.terrastruct.com/d2/lib/color"
|
||||
"oss.terrastruct.com/d2/lib/geo"
|
||||
"oss.terrastruct.com/d2/lib/jsrunner"
|
||||
"oss.terrastruct.com/d2/lib/label"
|
||||
"oss.terrastruct.com/d2/lib/svg"
|
||||
"oss.terrastruct.com/util-go/go2"
|
||||
|
|
@ -29,8 +28,6 @@ var setupJS string
|
|||
//go:embed streaks.txt
|
||||
var streaks string
|
||||
|
||||
type Runner goja.Runtime
|
||||
|
||||
var baseRoughProps = `fillWeight: 2.0,
|
||||
hachureGap: 16,
|
||||
fillStyle: "solid",
|
||||
|
|
@ -44,46 +41,39 @@ const (
|
|||
FG_COLOR = color.N1
|
||||
)
|
||||
|
||||
func (r *Runner) run(js string) (goja.Value, error) {
|
||||
vm := (*goja.Runtime)(r)
|
||||
return vm.RunString(js)
|
||||
}
|
||||
|
||||
func InitSketchVM() (*Runner, error) {
|
||||
vm := goja.New()
|
||||
if _, err := vm.RunString(roughJS); err != nil {
|
||||
return nil, err
|
||||
func LoadJS(runner jsrunner.JSRunner) error {
|
||||
if _, err := runner.RunString(roughJS); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vm.RunString(setupJS); err != nil {
|
||||
return nil, err
|
||||
if _, err := runner.RunString(setupJS); err != nil {
|
||||
return err
|
||||
}
|
||||
r := Runner(*vm)
|
||||
return &r, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefineFillPatterns adds reusable patterns that are overlayed on shapes with
|
||||
// fill. This gives it a subtle streaky effect that subtly looks hand-drawn but
|
||||
// not distractingly so.
|
||||
func DefineFillPatterns(buf *bytes.Buffer) {
|
||||
func DefineFillPatterns(buf *bytes.Buffer, diagramHash string) {
|
||||
source := buf.String()
|
||||
fmt.Fprint(buf, "<defs>")
|
||||
|
||||
defineFillPattern(buf, source, "bright", "rgba(0, 0, 0, 0.1)")
|
||||
defineFillPattern(buf, source, "normal", "rgba(0, 0, 0, 0.16)")
|
||||
defineFillPattern(buf, source, "dark", "rgba(0, 0, 0, 0.32)")
|
||||
defineFillPattern(buf, source, "darker", "rgba(255, 255, 255, 0.24)")
|
||||
defineFillPattern(buf, source, diagramHash, "bright", "rgba(0, 0, 0, 0.1)")
|
||||
defineFillPattern(buf, source, diagramHash, "normal", "rgba(0, 0, 0, 0.16)")
|
||||
defineFillPattern(buf, source, diagramHash, "dark", "rgba(0, 0, 0, 0.32)")
|
||||
defineFillPattern(buf, source, diagramHash, "darker", "rgba(255, 255, 255, 0.24)")
|
||||
|
||||
fmt.Fprint(buf, "</defs>")
|
||||
}
|
||||
|
||||
func defineFillPattern(buf *bytes.Buffer, source string, luminanceCategory, fill string) {
|
||||
trigger := fmt.Sprintf(`url(#streaks-%s)`, luminanceCategory)
|
||||
func defineFillPattern(buf *bytes.Buffer, source, diagramHash string, luminanceCategory, fill string) {
|
||||
trigger := fmt.Sprintf(`url(#streaks-%s-%s)`, luminanceCategory, diagramHash)
|
||||
if strings.Contains(source, trigger) {
|
||||
fmt.Fprintf(buf, streaks, luminanceCategory, fill)
|
||||
fmt.Fprintf(buf, streaks, luminanceCategory, diagramHash, fill)
|
||||
}
|
||||
}
|
||||
|
||||
func Rect(r *Runner, shape d2target.Shape) (string, error) {
|
||||
func Rect(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
|
||||
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
|
||||
fill: "#000",
|
||||
stroke: "#000",
|
||||
|
|
@ -119,7 +109,7 @@ func Rect(r *Runner, shape d2target.Shape) (string, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
|
||||
func DoubleRect(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
|
||||
jsBigRect := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
|
||||
fill: "#000",
|
||||
stroke: "#000",
|
||||
|
|
@ -179,7 +169,7 @@ func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
func Oval(r *Runner, shape d2target.Shape) (string, error) {
|
||||
func Oval(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
|
||||
js := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
|
||||
fill: "#000",
|
||||
stroke: "#000",
|
||||
|
|
@ -218,7 +208,7 @@ func Oval(r *Runner, shape d2target.Shape) (string, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
|
||||
func DoubleOval(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
|
||||
jsBigCircle := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
|
||||
fill: "#000",
|
||||
stroke: "#000",
|
||||
|
|
@ -281,7 +271,7 @@ func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
|
|||
}
|
||||
|
||||
// TODO need to personalize this per shape like we do in Terrastruct app
|
||||
func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
|
||||
func Paths(r jsrunner.JSRunner, shape d2target.Shape, paths []string) (string, error) {
|
||||
output := ""
|
||||
for _, path := range paths {
|
||||
js := fmt.Sprintf(`node = rc.path("%s", {
|
||||
|
|
@ -320,7 +310,7 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) {
|
||||
func Connection(r jsrunner.JSRunner, connection d2target.Connection, path, attrs string) (string, error) {
|
||||
animatedClass := ""
|
||||
if connection.Animated {
|
||||
animatedClass = " animated-connection"
|
||||
|
|
@ -388,7 +378,7 @@ func Connection(r *Runner, connection d2target.Connection, path, attrs string) (
|
|||
}
|
||||
|
||||
// TODO cleanup
|
||||
func Table(r *Runner, shape d2target.Shape) (string, error) {
|
||||
func Table(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
|
||||
output := ""
|
||||
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
|
||||
fill: "#000",
|
||||
|
|
@ -530,7 +520,7 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
func Class(r *Runner, shape d2target.Shape) (string, error) {
|
||||
func Class(r jsrunner.JSRunner, shape d2target.Shape) (string, error) {
|
||||
output := ""
|
||||
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
|
||||
fill: "#000",
|
||||
|
|
@ -681,8 +671,8 @@ func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText str
|
|||
return output
|
||||
}
|
||||
|
||||
func computeRoughPathData(r *Runner, js string) ([]string, error) {
|
||||
if _, err := r.run(js); err != nil {
|
||||
func computeRoughPathData(r jsrunner.JSRunner, js string) ([]string, error) {
|
||||
if _, err := r.RunString(js); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roughPaths, err := extractRoughPaths(r)
|
||||
|
|
@ -692,8 +682,8 @@ func computeRoughPathData(r *Runner, js string) ([]string, error) {
|
|||
return extractPathData(roughPaths)
|
||||
}
|
||||
|
||||
func computeRoughPaths(r *Runner, js string) ([]roughPath, error) {
|
||||
if _, err := r.run(js); err != nil {
|
||||
func computeRoughPaths(r jsrunner.JSRunner, js string) ([]roughPath, error) {
|
||||
if _, err := r.RunString(js); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return extractRoughPaths(r)
|
||||
|
|
@ -722,8 +712,8 @@ func (rp roughPath) StyleCSS() string {
|
|||
return style
|
||||
}
|
||||
|
||||
func extractRoughPaths(r *Runner) ([]roughPath, error) {
|
||||
val, err := r.run("JSON.stringify(node.children, null, ' ')")
|
||||
func extractRoughPaths(r jsrunner.JSRunner) ([]roughPath, error) {
|
||||
val, err := r.RunString("JSON.stringify(node.children, null, ' ')")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -756,7 +746,7 @@ func extractPathData(roughPaths []roughPath) ([]string, error) {
|
|||
return paths, nil
|
||||
}
|
||||
|
||||
func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeWidth int) (arrowJS, extraJS string) {
|
||||
func ArrowheadJS(r jsrunner.JSRunner, arrowhead d2target.Arrowhead, stroke string, strokeWidth int) (arrowJS, extraJS string) {
|
||||
// Note: selected each seed that looks the good for consistent renders
|
||||
switch arrowhead {
|
||||
case d2target.ArrowArrowhead:
|
||||
|
|
@ -850,11 +840,27 @@ func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeW
|
|||
stroke,
|
||||
BG_COLOR,
|
||||
)
|
||||
case d2target.BoxArrowhead:
|
||||
arrowJS = fmt.Sprintf(
|
||||
`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 1})`,
|
||||
`[[0, -10], [0, 10], [-20, 10], [-20, -10]]`,
|
||||
strokeWidth,
|
||||
stroke,
|
||||
BG_COLOR,
|
||||
)
|
||||
case d2target.FilledBoxArrowhead:
|
||||
arrowJS = fmt.Sprintf(
|
||||
`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 1})`,
|
||||
`[[0, -10], [0, 10], [-20, 10], [-20, -10]]`,
|
||||
strokeWidth,
|
||||
stroke,
|
||||
stroke,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.Point) (string, error) {
|
||||
func Arrowheads(r jsrunner.JSRunner, connection d2target.Connection, srcAdj, dstAdj *geo.Point) (string, error) {
|
||||
arrowPaths := []string{}
|
||||
|
||||
if connection.SrcArrow != d2target.NoArrowhead {
|
||||
|
|
|
|||
|
|
@ -462,6 +462,20 @@ a.9 <-> b.9: cf-one-required {
|
|||
source-arrowhead.shape: cf-one-required
|
||||
target-arrowhead.shape: cf-one-required
|
||||
}
|
||||
a.10 <-> b.10: box {
|
||||
source-arrowhead.shape: box
|
||||
target-arrowhead.shape: box
|
||||
}
|
||||
a.11 <-> b.11: box-filled {
|
||||
source-arrowhead: {
|
||||
shape: box
|
||||
style.filled: true
|
||||
}
|
||||
target-arrowhead: {
|
||||
shape: box
|
||||
style.filled: true
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
164
d2renderers/d2sketch/testdata/basic/sketch.exp.svg
vendored
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
164
d2renderers/d2sketch/testdata/class/sketch.exp.svg
vendored
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 157 KiB |
174
d2renderers/d2sketch/testdata/dots-3d/sketch.exp.svg
vendored
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
574
d2renderers/d2sketch/testdata/opacity/sketch.exp.svg
vendored
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |