commit
5838760d19
6 changed files with 484 additions and 0 deletions
30
d2js/README.md
Normal file
30
d2js/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# D2 as a Javascript library
|
||||||
|
|
||||||
|
D2 is runnable as a Javascript library, on both the client and server side. This means you
|
||||||
|
can run D2 entirely on the browser.
|
||||||
|
|
||||||
|
This is achieved by a JS wrapper around a WASM file.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### NPM
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @terrastruct/d2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yarn
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn add @terrastruct/d2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o main.wasm ./d2js
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
todo
|
||||||
71
d2js/d2wasm/api.go
Normal file
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
292
d2js/d2wasm/functions.go
Normal file
292
d2js/d2wasm/functions.go
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
//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 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 {
|
||||||
|
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
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"`
|
||||||
|
}
|
||||||
31
d2js/js.go
Normal file
31
d2js/js.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
//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("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 {}
|
||||||
|
}
|
||||||
|
|
@ -1867,6 +1867,8 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
|
||||||
}
|
}
|
||||||
darkThemeID = opts.DarkThemeID
|
darkThemeID = opts.DarkThemeID
|
||||||
scale = opts.Scale
|
scale = opts.Scale
|
||||||
|
} else {
|
||||||
|
opts = &RenderOpts{}
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue