Skip to content

Commit 5838760

Browse files
authored
Merge pull request #436 from alixander/wasm-build
wasm init
2 parents d807b8c + 3dee7bb commit 5838760

File tree

6 files changed

+484
-0
lines changed

6 files changed

+484
-0
lines changed

d2js/README.md

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# D2 as a Javascript library
2+
3+
D2 is runnable as a Javascript library, on both the client and server side. This means you
4+
can run D2 entirely on the browser.
5+
6+
This is achieved by a JS wrapper around a WASM file.
7+
8+
## Install
9+
10+
### NPM
11+
12+
```sh
13+
npm install @terrastruct/d2
14+
```
15+
16+
### Yarn
17+
18+
```sh
19+
yarn add @terrastruct/d2
20+
```
21+
22+
## Build
23+
24+
```sh
25+
GOOS=js GOARCH=wasm go build -ldflags='-s -w' -trimpath -o main.wasm ./d2js
26+
```
27+
28+
## API
29+
30+
todo

d2js/d2wasm/api.go

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//go:build js && wasm
2+
3+
package d2wasm
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"runtime/debug"
9+
"syscall/js"
10+
)
11+
12+
type D2API struct {
13+
exports map[string]js.Func
14+
}
15+
16+
func NewD2API() *D2API {
17+
return &D2API{
18+
exports: make(map[string]js.Func),
19+
}
20+
}
21+
22+
func (api *D2API) Register(name string, fn func(args []js.Value) (interface{}, error)) {
23+
api.exports[name] = wrapWASMCall(fn)
24+
}
25+
26+
func (api *D2API) ExportTo(target js.Value) {
27+
d2Namespace := make(map[string]interface{})
28+
for name, fn := range api.exports {
29+
d2Namespace[name] = fn
30+
}
31+
target.Set("d2", js.ValueOf(d2Namespace))
32+
}
33+
34+
func wrapWASMCall(fn func(args []js.Value) (interface{}, error)) js.Func {
35+
return js.FuncOf(func(this js.Value, args []js.Value) (result any) {
36+
defer func() {
37+
if r := recover(); r != nil {
38+
resp := WASMResponse{
39+
Error: &WASMError{
40+
Message: fmt.Sprintf("panic recovered: %v\n%s", r, debug.Stack()),
41+
Code: 500,
42+
},
43+
}
44+
jsonResp, _ := json.Marshal(resp)
45+
result = string(jsonResp)
46+
}
47+
}()
48+
49+
data, err := fn(args)
50+
if err != nil {
51+
wasmErr, ok := err.(*WASMError)
52+
if !ok {
53+
wasmErr = &WASMError{
54+
Message: err.Error(),
55+
Code: 500,
56+
}
57+
}
58+
resp := WASMResponse{
59+
Error: wasmErr,
60+
}
61+
jsonResp, _ := json.Marshal(resp)
62+
return string(jsonResp)
63+
}
64+
65+
resp := WASMResponse{
66+
Data: data,
67+
}
68+
jsonResp, _ := json.Marshal(resp)
69+
return string(jsonResp)
70+
})
71+
}

d2js/d2wasm/functions.go

+292
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
//go:build js && wasm
2+
3+
package d2wasm
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"strings"
10+
"syscall/js"
11+
12+
"oss.terrastruct.com/d2/d2ast"
13+
"oss.terrastruct.com/d2/d2compiler"
14+
"oss.terrastruct.com/d2/d2format"
15+
"oss.terrastruct.com/d2/d2graph"
16+
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
17+
"oss.terrastruct.com/d2/d2layouts/d2elklayout"
18+
"oss.terrastruct.com/d2/d2lib"
19+
"oss.terrastruct.com/d2/d2lsp"
20+
"oss.terrastruct.com/d2/d2oracle"
21+
"oss.terrastruct.com/d2/d2parser"
22+
"oss.terrastruct.com/d2/d2renderers/d2fonts"
23+
"oss.terrastruct.com/d2/d2renderers/d2svg"
24+
"oss.terrastruct.com/d2/lib/log"
25+
"oss.terrastruct.com/d2/lib/memfs"
26+
"oss.terrastruct.com/d2/lib/textmeasure"
27+
"oss.terrastruct.com/d2/lib/urlenc"
28+
"oss.terrastruct.com/d2/lib/version"
29+
"oss.terrastruct.com/util-go/go2"
30+
)
31+
32+
func GetParentID(args []js.Value) (interface{}, error) {
33+
if len(args) < 1 {
34+
return nil, &WASMError{Message: "missing id argument", Code: 400}
35+
}
36+
37+
id := args[0].String()
38+
mk, err := d2parser.ParseMapKey(id)
39+
if err != nil {
40+
return nil, &WASMError{Message: err.Error(), Code: 400}
41+
}
42+
43+
if len(mk.Edges) > 0 {
44+
return "", nil
45+
}
46+
47+
if mk.Key != nil {
48+
if len(mk.Key.Path) == 1 {
49+
return "root", nil
50+
}
51+
mk.Key.Path = mk.Key.Path[:len(mk.Key.Path)-1]
52+
return strings.Join(mk.Key.StringIDA(), "."), nil
53+
}
54+
55+
return "", nil
56+
}
57+
58+
func GetObjOrder(args []js.Value) (interface{}, error) {
59+
if len(args) < 1 {
60+
return nil, &WASMError{Message: "missing dsl argument", Code: 400}
61+
}
62+
63+
dsl := args[0].String()
64+
g, _, err := d2compiler.Compile("", strings.NewReader(dsl), &d2compiler.CompileOptions{
65+
UTF16Pos: true,
66+
})
67+
if err != nil {
68+
return nil, &WASMError{Message: err.Error(), Code: 400}
69+
}
70+
71+
objOrder, err := d2oracle.GetObjOrder(g, nil)
72+
if err != nil {
73+
return nil, &WASMError{Message: err.Error(), Code: 500}
74+
}
75+
76+
return map[string]interface{}{
77+
"order": objOrder,
78+
}, nil
79+
}
80+
81+
func GetRefRanges(args []js.Value) (interface{}, error) {
82+
if len(args) < 4 {
83+
return nil, &WASMError{Message: "missing required arguments", Code: 400}
84+
}
85+
86+
var fs map[string]string
87+
if err := json.Unmarshal([]byte(args[0].String()), &fs); err != nil {
88+
return nil, &WASMError{Message: "invalid fs argument", Code: 400}
89+
}
90+
91+
file := args[1].String()
92+
key := args[2].String()
93+
94+
var boardPath []string
95+
if err := json.Unmarshal([]byte(args[3].String()), &boardPath); err != nil {
96+
return nil, &WASMError{Message: "invalid boardPath argument", Code: 400}
97+
}
98+
99+
ranges, importRanges, err := d2lsp.GetRefRanges(file, fs, boardPath, key)
100+
if err != nil {
101+
return nil, &WASMError{Message: err.Error(), Code: 500}
102+
}
103+
104+
return RefRangesResponse{
105+
Ranges: ranges,
106+
ImportRanges: importRanges,
107+
}, nil
108+
}
109+
110+
func Compile(args []js.Value) (interface{}, error) {
111+
if len(args) < 1 {
112+
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
113+
}
114+
var input CompileRequest
115+
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
116+
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
117+
}
118+
119+
if input.FS == nil {
120+
return nil, &WASMError{Message: "missing 'fs' field in input JSON", Code: 400}
121+
}
122+
123+
if _, ok := input.FS["index"]; !ok {
124+
return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400}
125+
}
126+
127+
fs, err := memfs.New(input.FS)
128+
if err != nil {
129+
return nil, &WASMError{Message: fmt.Sprintf("invalid fs input: %s", err.Error()), Code: 400}
130+
}
131+
132+
ruler, err := textmeasure.NewRuler()
133+
if err != nil {
134+
return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500}
135+
}
136+
ctx := log.WithDefault(context.Background())
137+
layoutFunc := d2dagrelayout.DefaultLayout
138+
if input.Opts != nil && input.Opts.Layout != nil {
139+
switch *input.Opts.Layout {
140+
case "dagre":
141+
layoutFunc = d2dagrelayout.DefaultLayout
142+
case "elk":
143+
layoutFunc = d2elklayout.DefaultLayout
144+
default:
145+
return nil, &WASMError{Message: fmt.Sprintf("layout option '%s' not recognized", *input.Opts.Layout), Code: 400}
146+
}
147+
}
148+
layoutResolver := func(engine string) (d2graph.LayoutGraph, error) {
149+
return layoutFunc, nil
150+
}
151+
152+
renderOpts := &d2svg.RenderOpts{}
153+
var fontFamily *d2fonts.FontFamily
154+
if input.Opts != nil && input.Opts.Sketch != nil {
155+
fontFamily = go2.Pointer(d2fonts.HandDrawn)
156+
renderOpts.Sketch = input.Opts.Sketch
157+
}
158+
if input.Opts != nil && input.Opts.ThemeID != nil {
159+
renderOpts.ThemeID = input.Opts.ThemeID
160+
}
161+
diagram, g, err := d2lib.Compile(ctx, input.FS["index"], &d2lib.CompileOptions{
162+
UTF16Pos: true,
163+
FS: fs,
164+
Ruler: ruler,
165+
LayoutResolver: layoutResolver,
166+
FontFamily: fontFamily,
167+
}, renderOpts)
168+
if err != nil {
169+
if pe, ok := err.(*d2parser.ParseError); ok {
170+
return nil, &WASMError{Message: pe.Error(), Code: 400}
171+
}
172+
return nil, &WASMError{Message: err.Error(), Code: 500}
173+
}
174+
175+
input.FS["index"] = d2format.Format(g.AST)
176+
177+
return CompileResponse{
178+
FS: input.FS,
179+
Diagram: *diagram,
180+
Graph: *g,
181+
}, nil
182+
}
183+
184+
func Render(args []js.Value) (interface{}, error) {
185+
if len(args) < 1 {
186+
return nil, &WASMError{Message: "missing JSON argument", Code: 400}
187+
}
188+
var input RenderRequest
189+
if err := json.Unmarshal([]byte(args[0].String()), &input); err != nil {
190+
return nil, &WASMError{Message: "invalid JSON input", Code: 400}
191+
}
192+
193+
if input.Diagram == nil {
194+
return nil, &WASMError{Message: "missing 'diagram' field in input JSON", Code: 400}
195+
}
196+
197+
renderOpts := &d2svg.RenderOpts{}
198+
if input.Opts != nil && input.Opts.Sketch != nil {
199+
renderOpts.Sketch = input.Opts.Sketch
200+
}
201+
if input.Opts != nil && input.Opts.ThemeID != nil {
202+
renderOpts.ThemeID = input.Opts.ThemeID
203+
}
204+
out, err := d2svg.Render(input.Diagram, renderOpts)
205+
if err != nil {
206+
return nil, &WASMError{Message: fmt.Sprintf("render failed: %s", err.Error()), Code: 500}
207+
}
208+
209+
return out, nil
210+
}
211+
212+
func GetBoardAtPosition(args []js.Value) (interface{}, error) {
213+
if len(args) < 3 {
214+
return nil, &WASMError{Message: "missing required arguments", Code: 400}
215+
}
216+
217+
dsl := args[0].String()
218+
line := args[1].Int()
219+
column := args[2].Int()
220+
221+
boardPath, err := d2lsp.GetBoardAtPosition(dsl, d2ast.Position{
222+
Line: line,
223+
Column: column,
224+
})
225+
if err != nil {
226+
return nil, &WASMError{Message: err.Error(), Code: 500}
227+
}
228+
229+
return BoardPositionResponse{BoardPath: boardPath}, nil
230+
}
231+
232+
func Encode(args []js.Value) (interface{}, error) {
233+
if len(args) < 1 {
234+
return nil, &WASMError{Message: "missing script argument", Code: 400}
235+
}
236+
237+
script := args[0].String()
238+
encoded, err := urlenc.Encode(script)
239+
// should never happen
240+
if err != nil {
241+
return nil, &WASMError{Message: err.Error(), Code: 500}
242+
}
243+
244+
return map[string]string{"result": encoded}, nil
245+
}
246+
247+
func Decode(args []js.Value) (interface{}, error) {
248+
if len(args) < 1 {
249+
return nil, &WASMError{Message: "missing script argument", Code: 400}
250+
}
251+
252+
script := args[0].String()
253+
script, err := urlenc.Decode(script)
254+
if err != nil {
255+
return nil, &WASMError{Message: err.Error(), Code: 500}
256+
}
257+
return map[string]string{"result": script}, nil
258+
}
259+
260+
func GetVersion(args []js.Value) (interface{}, error) {
261+
return version.Version, nil
262+
}
263+
264+
func GetCompletions(args []js.Value) (interface{}, error) {
265+
if len(args) < 3 {
266+
return nil, &WASMError{Message: "missing required arguments", Code: 400}
267+
}
268+
269+
text := args[0].String()
270+
line := args[1].Int()
271+
column := args[2].Int()
272+
273+
completions, err := d2lsp.GetCompletionItems(text, line, column)
274+
if err != nil {
275+
return nil, &WASMError{Message: err.Error(), Code: 500}
276+
}
277+
278+
// Convert to map for JSON serialization
279+
items := make([]map[string]interface{}, len(completions))
280+
for i, completion := range completions {
281+
items[i] = map[string]interface{}{
282+
"label": completion.Label,
283+
"kind": int(completion.Kind),
284+
"detail": completion.Detail,
285+
"insertText": completion.InsertText,
286+
}
287+
}
288+
289+
return CompletionResponse{
290+
Items: items,
291+
}, nil
292+
}

0 commit comments

Comments
 (0)