diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index 931433ee9..d1332deb7 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -4,17 +4,19 @@ #### Improvements 🧹 -- d2js: Support `d2-config`. Support additional options: [#2343](https://github.com/terrastruct/d2/pull/2343) - - `themeID` - - `darkThemeID` - - `center` - - `pad` - - `scale` - - `forceAppendix` - - `target` - - `animateInterval` - - `salt` - - `noXMLTag` +- d2js: + - Support `d2-config`. Support additional options: [#2343](https://github.com/terrastruct/d2/pull/2343) + - `themeID` + - `darkThemeID` + - `center` + - `pad` + - `scale` + - `forceAppendix` + - `target` + - `animateInterval` + - `salt` + - `noXMLTag` + - Support relative imports. Improve elk error handling: (PR Pending) #### Bugfixes ⛑️ diff --git a/d2js/d2wasm/functions.go b/d2js/d2wasm/functions.go index 192ac068e..2d5080775 100644 --- a/d2js/d2wasm/functions.go +++ b/d2js/d2wasm/functions.go @@ -32,6 +32,8 @@ import ( "oss.terrastruct.com/util-go/go2" ) +const DEFAULT_INPUT_PATH = "index" + func GetParentID(args []js.Value) (interface{}, error) { if len(args) < 1 { return nil, &WASMError{Message: "missing id argument", Code: 400} @@ -123,8 +125,14 @@ func GetELKGraph(args []js.Value) (interface{}, error) { 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} + inputPath := DEFAULT_INPUT_PATH + + if input.InputPath != nil { + inputPath = *input.InputPath + } + + if _, ok := input.FS[inputPath]; !ok { + return nil, &WASMError{Message: fmt.Sprintf("missing '%s' file in input fs", inputPath), Code: 400} } fs, err := memfs.New(input.FS) @@ -132,7 +140,7 @@ func GetELKGraph(args []js.Value) (interface{}, error) { 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{ + g, _, err := d2compiler.Compile(inputPath, strings.NewReader(input.FS[inputPath]), &d2compiler.CompileOptions{ UTF16Pos: true, FS: fs, }) @@ -169,14 +177,22 @@ func Compile(args []js.Value) (interface{}, error) { 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} - } - compileOpts := &d2lib.CompileOptions{ UTF16Pos: true, } + inputPath := DEFAULT_INPUT_PATH + + if input.InputPath != nil { + inputPath = *input.InputPath + } + + if _, ok := input.FS[inputPath]; !ok { + return nil, &WASMError{Message: fmt.Sprintf("missing '%s' file in input fs", inputPath), Code: 400} + } + + compileOpts.InputPath = inputPath + compileOpts.LayoutResolver = func(engine string) (d2graph.LayoutGraph, error) { switch engine { case "dagre": @@ -227,7 +243,7 @@ func Compile(args []js.Value) (interface{}, error) { } ctx := log.WithDefault(context.Background()) - diagram, g, err := d2lib.Compile(ctx, input.FS["index"], compileOpts, renderOpts) + diagram, g, err := d2lib.Compile(ctx, input.FS[inputPath], compileOpts, renderOpts) if err != nil { if pe, ok := err.(*d2parser.ParseError); ok { errs, _ := json.Marshal(pe.Errors) @@ -236,12 +252,13 @@ func Compile(args []js.Value) (interface{}, error) { return nil, &WASMError{Message: err.Error(), Code: 500} } - input.FS["index"] = d2format.Format(g.AST) + input.FS[inputPath] = d2format.Format(g.AST) return CompileResponse{ - FS: input.FS, - Diagram: *diagram, - Graph: *g, + FS: input.FS, + InputPath: inputPath, + Diagram: *diagram, + Graph: *g, RenderOptions: RenderOptions{ ThemeID: renderOpts.ThemeID, DarkThemeID: renderOpts.DarkThemeID, diff --git a/d2js/d2wasm/types.go b/d2js/d2wasm/types.go index 70b3e1154..5e7299bd4 100644 --- a/d2js/d2wasm/types.go +++ b/d2js/d2wasm/types.go @@ -32,8 +32,9 @@ type BoardPositionResponse struct { } type CompileRequest struct { - FS map[string]string `json:"fs"` - Opts *CompileOptions `json:"options"` + FS map[string]string `json:"fs"` + InputPath *string `json:"inputPath"` + Opts *CompileOptions `json:"options"` } type RenderOptions struct { @@ -57,6 +58,7 @@ type CompileOptions struct { type CompileResponse struct { FS map[string]string `json:"fs"` + InputPath string `json:"inputPath"` Diagram d2target.Diagram `json:"diagram"` Graph d2graph.Graph `json:"graph"` RenderOptions RenderOptions `json:"renderOptions"` diff --git a/d2js/js/README.md b/d2js/js/README.md index 411586036..16b574eb7 100644 --- a/d2js/js/README.md +++ b/d2js/js/README.md @@ -33,6 +33,8 @@ bun add @terrastruct/d2 D2.js uses webworkers to call a WASM file. +### Basic Usage + ```javascript // Same for Node or browser import { D2 } from '@terrastruct/d2'; @@ -42,7 +44,7 @@ import { D2 } from '@terrastruct/d2'; const d2 = new D2(); const result = await d2.compile('x -> y'); -const svg = await d2.render(result.diagram, result.options); +const svg = await d2.render(result.diagram, result.renderOptions); ``` Configuring render options (see [CompileOptions](#compileoptions) for all available options): @@ -58,15 +60,39 @@ const result = await d2.compile('x -> y', { const svg = await d2.render(result.diagram, result.renderOptions); ``` +### Supporting Imports + +In order to support [imports](https://d2lang.com/tour/imports), a 'filesystem' needs to be passed to the D2 compiler. This is a mapping of D2 file paths to their content. + +```javascript +import { D2 } from '@terrastruct/d2'; + +const d2 = new D2(); + +const fs = { + "project.d2": "a: @import", + "import.d2": "x: {shape: circle}", +} + +const result = await d2.compile({ + fs, + inputPath: "project.d2", + options: { + sketch: true + } +}); +const svg = await d2.render(result.diagram, result.renderOptions); +``` + ## API Reference ### `new D2()` Creates a new D2 instance. -### `compile(input: string, options?: CompileOptions): Promise` +### `compile(input: string | CompileRequest, options?: CompileOptions): Promise` -Compiles D2 markup into an intermediate representation. +Compiles D2 markup into an intermediate representation. It compile options are provided in both `input` and `options`, the latter will take precedence. ### `render(diagram: Diagram, options?: RenderOptions): Promise` @@ -92,6 +118,12 @@ All [RenderOptions](#renderoptions) properties in addition to: - `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. - `noXMLTag`: Omit XML tag `()` from output SVG files. Useful when generating SVGs for direct HTML embedding. +### `CompileRequest` + +- `fs`: A mapping of D2 filepaths to their content +- `inputPath`: The path of the D2 file containing the root D2 board [default: index] +- `options`: The [CompileOptions](#compileoptions) to pass to the compiler + ### `CompileResult` - `diagram`: `Diagram`: Compiled D2 diagram diff --git a/d2js/js/src/worker.browser.js b/d2js/js/src/worker.browser.js index 4b5f4b4a5..4989a76df 100644 --- a/d2js/js/src/worker.browser.js +++ b/d2js/js/src/worker.browser.js @@ -34,7 +34,9 @@ export function setupMessageHandler(isNode, port, initWasm) { // anyway to support `layout-engine: elk` in d2-config vars if (data.options.layout === "elk" || data.options.layout == null) { const elkGraph = await d2.getELKGraph(JSON.stringify(data)); - const elkGraph2 = JSON.parse(elkGraph).data; + const response = JSON.parse(elkGraph); + if (response.error) throw new Error(response.error.message); + const elkGraph2 = response.data; const layout = await elk.layout(elkGraph2); globalThis.elkResult = layout; } diff --git a/d2js/js/src/worker.node.js b/d2js/js/src/worker.node.js index 6901d957f..0e5cda307 100644 --- a/d2js/js/src/worker.node.js +++ b/d2js/js/src/worker.node.js @@ -29,7 +29,9 @@ export function setupMessageHandler(isNode, port, initWasm) { try { if (data.options.layout === "elk" || data.options.layout == null) { const elkGraph = await d2.getELKGraph(JSON.stringify(data)); - const elkGraph2 = JSON.parse(elkGraph).data; + const response = JSON.parse(elkGraph); + if (response.error) throw new Error(response.error.message); + const elkGraph2 = response.data; const layout = await elk.layout(elkGraph2); globalThis.elkResult = layout; } diff --git a/d2js/js/test/unit/basic.test.js b/d2js/js/test/unit/basic.test.js index 1ff5e9a64..7841e8100 100644 --- a/d2js/js/test/unit/basic.test.js +++ b/d2js/js/test/unit/basic.test.js @@ -16,6 +16,29 @@ describe("D2 Unit Tests", () => { await d2.worker.terminate(); }, 20000); + test("import works", async () => { + const d2 = new D2(); + const fs = { + index: "a: @import", + "import.d2": "x: {shape: circle}", + }; + const result = await d2.compile({ fs }); + expect(result.diagram).toBeDefined(); + await d2.worker.terminate(); + }, 20000); + + test("relative import works", async () => { + const d2 = new D2(); + const fs = { + "folder/index.d2": "a: @../import", + "import.d2": "x: {shape: circle}", + }; + const inputPath = "folder/index.d2"; + const result = await d2.compile({ fs, inputPath }); + expect(result.diagram).toBeDefined(); + await d2.worker.terminate(); + }, 20000); + test("render works", async () => { const d2 = new D2(); const result = await d2.compile("x -> y"); @@ -180,4 +203,21 @@ layers: { } await d2.worker.terminate(); }, 20000); + + test("handles invalid imports correctly", async () => { + const d2 = new D2(); + const fs = { + "folder/index.d2": "a: @../invalid", + "import.d2": "x: {shape: circle}", + }; + const inputPath = "folder/index.d2"; + try { + await d2.compile({ fs, inputPath }); + throw new Error("Should have thrown compile error"); + } catch (err) { + expect(err).toBeDefined(); + expect(err.message).not.toContain("Should have thrown compile error"); + } + await d2.worker.terminate(); + }, 20000); });