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

d2js: Support Relative Imports #2382

Merged
merged 4 commits into from
Feb 27, 2025
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
1 change: 1 addition & 0 deletions ci/release/changelogs/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- `animateInterval`
- `salt`
- `noXMLTag`
- Support relative imports. Improve elk error handling: [#2382](https://github.com/terrastruct/d2/pull/2382)
- Support fonts (`fontRegular`, `fontItalic`, `fontBold`, `fontSemiBold`): [#2384](https://github.com/terrastruct/d2/pull/2384)

#### Bugfixes ⛑️
Expand Down
41 changes: 29 additions & 12 deletions d2js/d2wasm/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import (
"oss.terrastruct.com/d2/lib/version"
)

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}
Expand Down Expand Up @@ -122,16 +124,22 @@ 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)
if err != nil {
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,
})
Expand Down Expand Up @@ -168,14 +176,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":
Expand Down Expand Up @@ -247,7 +263,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)
Expand All @@ -256,12 +272,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,
Expand Down
6 changes: 4 additions & 2 deletions d2js/d2wasm/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -61,6 +62,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"`
Expand Down
38 changes: 35 additions & 3 deletions d2js/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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):
Expand All @@ -58,15 +60,39 @@ const result = await d2.compile('x -> y', {
const svg = await d2.render(result.diagram, result.renderOptions);
```

### Imports

In order to support [imports](https://d2lang.com/tour/imports), a mapping of D2 file paths to their content can be passed to the compiler.

```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<CompileResult>`
### `compile(input: string | CompileRequest, options?: CompileOptions): Promise<CompileResult>`

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<string>`

Expand Down Expand Up @@ -96,6 +122,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 `(<?xml ...?>)` from output SVG files. Useful when generating SVGs for direct HTML embedding.

### `CompileRequest`

- `fs`: A mapping of D2 file paths to their content
- `inputPath`: The path of the entry D2 file [default: index]
- `options`: The [CompileOptions](#compileoptions) to pass to the compiler

### `CompileResult`

- `diagram`: `Diagram`: Compiled D2 diagram
Expand Down
4 changes: 3 additions & 1 deletion d2js/js/src/worker.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion d2js/js/src/worker.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
40 changes: 40 additions & 0 deletions d2js/js/test/unit/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});