diff --git a/d2js/README.md b/d2js/README.md new file mode 100644 index 000000000..6728d72af --- /dev/null +++ b/d2js/README.md @@ -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 diff --git a/d2js/d2wasm/api.go b/d2js/d2wasm/api.go new file mode 100644 index 000000000..e87386cd6 --- /dev/null +++ b/d2js/d2wasm/api.go @@ -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) + }) +} diff --git a/d2js/d2wasm/functions.go b/d2js/d2wasm/functions.go new file mode 100644 index 000000000..d82965a69 --- /dev/null +++ b/d2js/d2wasm/functions.go @@ -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 +} diff --git a/d2js/d2wasm/types.go b/d2js/d2wasm/types.go new file mode 100644 index 000000000..a13b82bae --- /dev/null +++ b/d2js/d2wasm/types.go @@ -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"` +} diff --git a/d2js/js.go b/d2js/js.go new file mode 100644 index 000000000..514fd5179 --- /dev/null +++ b/d2js/js.go @@ -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 {} +} diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index cb14ff041..12ea2e7c0 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -1867,6 +1867,8 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { } darkThemeID = opts.DarkThemeID scale = opts.Scale + } else { + opts = &RenderOpts{} } buf := &bytes.Buffer{}