Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wasm init #436

Merged
merged 21 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions d2js/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 71 additions & 0 deletions d2js/d2wasm/api.go
Original file line number Diff line number Diff line change
@@ -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 changes: 292 additions & 0 deletions d2js/d2wasm/functions.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading