diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index cdc65f746..931433ee9 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -4,6 +4,18 @@ #### 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` + #### Bugfixes ⛑️ - Compiler: diff --git a/d2js/d2wasm/functions.go b/d2js/d2wasm/functions.go index 9cb8e5b26..192ac068e 100644 --- a/d2js/d2wasm/functions.go +++ b/d2js/d2wasm/functions.go @@ -19,8 +19,11 @@ import ( "oss.terrastruct.com/d2/d2lsp" "oss.terrastruct.com/d2/d2oracle" "oss.terrastruct.com/d2/d2parser" + "oss.terrastruct.com/d2/d2renderers/d2animate" "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/d2renderers/d2svg" + "oss.terrastruct.com/d2/d2renderers/d2svg/appendix" + "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/lib/log" "oss.terrastruct.com/d2/lib/memfs" "oss.terrastruct.com/d2/lib/textmeasure" @@ -170,47 +173,61 @@ func Compile(args []js.Value) (interface{}, error) { return nil, &WASMError{Message: "missing 'index' file in input fs", Code: 400} } - fs, err := memfs.New(input.FS) + compileOpts := &d2lib.CompileOptions{ + UTF16Pos: true, + } + + compileOpts.LayoutResolver = func(engine string) (d2graph.LayoutGraph, error) { + switch engine { + case "dagre": + return d2dagrelayout.DefaultLayout, nil + case "elk": + return d2elklayout.DefaultLayout, nil + default: + return nil, &WASMError{Message: fmt.Sprintf("layout option '%s' not recognized", engine), Code: 400} + } + } + + var err error + compileOpts.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() + compileOpts.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 + compileOpts.Layout = input.Opts.Layout } renderOpts := &d2svg.RenderOpts{} - var fontFamily *d2fonts.FontFamily - if input.Opts != nil && input.Opts.Sketch != nil && *input.Opts.Sketch { - fontFamily = go2.Pointer(d2fonts.HandDrawn) + if input.Opts != nil && input.Opts.Sketch != nil { renderOpts.Sketch = input.Opts.Sketch + if *input.Opts.Sketch { + compileOpts.FontFamily = go2.Pointer(d2fonts.HandDrawn) + } + } + if input.Opts != nil && input.Opts.Pad != nil { + renderOpts.Pad = input.Opts.Pad + } + if input.Opts != nil && input.Opts.Center != nil { + renderOpts.Center = input.Opts.Center } 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 input.Opts != nil && input.Opts.DarkThemeID != nil { + renderOpts.DarkThemeID = input.Opts.DarkThemeID + } + if input.Opts != nil && input.Opts.Scale != nil { + renderOpts.Scale = input.Opts.Scale + } + + ctx := log.WithDefault(context.Background()) + diagram, g, err := d2lib.Compile(ctx, input.FS["index"], compileOpts, renderOpts) if err != nil { if pe, ok := err.(*d2parser.ParseError); ok { errs, _ := json.Marshal(pe.Errors) @@ -225,6 +242,19 @@ func Compile(args []js.Value) (interface{}, error) { FS: input.FS, Diagram: *diagram, Graph: *g, + RenderOptions: RenderOptions{ + ThemeID: renderOpts.ThemeID, + DarkThemeID: renderOpts.DarkThemeID, + Sketch: renderOpts.Sketch, + Pad: renderOpts.Pad, + Center: renderOpts.Center, + Scale: renderOpts.Scale, + ForceAppendix: input.Opts.ForceAppendix, + Target: input.Opts.Target, + AnimateInterval: input.Opts.AnimateInterval, + Salt: input.Opts.Salt, + NoXMLTag: input.Opts.NoXMLTag, + }, }, nil } @@ -241,21 +271,159 @@ func Render(args []js.Value) (interface{}, error) { return nil, &WASMError{Message: "missing 'diagram' field in input JSON", Code: 400} } + animateInterval := 0 + if input.Opts != nil && input.Opts.AnimateInterval != nil && *input.Opts.AnimateInterval > 0 { + animateInterval = int(*input.Opts.AnimateInterval) + } + + var boardPath []string + noChildren := true + + if input.Opts.Target != nil { + switch *input.Opts.Target { + case "*": + noChildren = false + case "": + default: + target := *input.Opts.Target + if strings.HasSuffix(target, ".*") { + target = target[:len(target)-2] + noChildren = false + } + key, err := d2parser.ParseKey(target) + if err != nil { + return nil, &WASMError{Message: fmt.Sprintf("target '%s' not recognized", target), Code: 400} + } + boardPath = key.StringIDA() + } + if !noChildren && animateInterval <= 0 { + return nil, &WASMError{Message: fmt.Sprintf("target '%s' only supported for animated SVGs", *input.Opts.Target), Code: 500} + } + } + + diagram := input.Diagram.GetBoard(boardPath) + if diagram == nil { + return nil, &WASMError{Message: fmt.Sprintf("render target '%s' not found", strings.Join(boardPath, ".")), Code: 400} + } + if noChildren { + diagram.Layers = nil + diagram.Scenarios = nil + diagram.Steps = nil + } + renderOpts := &d2svg.RenderOpts{} + + if input.Opts != nil && input.Opts.Salt != nil { + renderOpts.Salt = input.Opts.Salt + } + + if animateInterval > 0 { + masterID, err := diagram.HashID(renderOpts.Salt) + if err != nil { + return nil, &WASMError{Message: fmt.Sprintf("cannot process animate interval: %s", err.Error()), Code: 500} + } + renderOpts.MasterID = masterID + } + + ruler, err := textmeasure.NewRuler() + if err != nil { + return nil, &WASMError{Message: fmt.Sprintf("text ruler cannot be initialized: %s", err.Error()), Code: 500} + } + if input.Opts != nil && input.Opts.Sketch != nil { renderOpts.Sketch = input.Opts.Sketch } + if input.Opts != nil && input.Opts.Pad != nil { + renderOpts.Pad = input.Opts.Pad + } + if input.Opts != nil && input.Opts.Center != nil { + renderOpts.Center = input.Opts.Center + } if input.Opts != nil && input.Opts.ThemeID != nil { renderOpts.ThemeID = input.Opts.ThemeID } - out, err := d2svg.Render(input.Diagram, renderOpts) + if input.Opts != nil && input.Opts.DarkThemeID != nil { + renderOpts.DarkThemeID = input.Opts.DarkThemeID + } + if input.Opts != nil && input.Opts.Scale != nil { + renderOpts.Scale = input.Opts.Scale + } + if input.Opts != nil && input.Opts.NoXMLTag != nil { + renderOpts.NoXMLTag = input.Opts.NoXMLTag + } + + forceAppendix := input.Opts != nil && input.Opts.ForceAppendix != nil && *input.Opts.ForceAppendix + + var boards [][]byte + if noChildren { + var board []byte + board, err = renderSingleBoard(renderOpts, forceAppendix, ruler, diagram) + boards = [][]byte{board} + } else { + boards, err = renderBoards(renderOpts, forceAppendix, ruler, diagram) + } if err != nil { return nil, &WASMError{Message: fmt.Sprintf("render failed: %s", err.Error()), Code: 500} } + var out []byte + if len(boards) > 0 { + out = boards[0] + if animateInterval > 0 { + out, err = d2animate.Wrap(diagram, boards, *renderOpts, animateInterval) + if err != nil { + return nil, &WASMError{Message: fmt.Sprintf("animation failed: %s", err.Error()), Code: 500} + } + } + } return out, nil } +func renderSingleBoard(opts *d2svg.RenderOpts, forceAppendix bool, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) { + out, err := d2svg.Render(diagram, opts) + if err != nil { + return nil, &WASMError{Message: fmt.Sprintf("render failed: %s", err.Error()), Code: 500} + } + if forceAppendix { + out = appendix.Append(diagram, opts, ruler, out) + } + return out, nil +} + +func renderBoards(opts *d2svg.RenderOpts, forceAppendix bool, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) { + var boards [][]byte + for _, dl := range diagram.Layers { + childrenBoards, err := renderBoards(opts, forceAppendix, ruler, dl) + if err != nil { + return nil, err + } + boards = append(boards, childrenBoards...) + } + for _, dl := range diagram.Scenarios { + childrenBoards, err := renderBoards(opts, forceAppendix, ruler, dl) + if err != nil { + return nil, err + } + boards = append(boards, childrenBoards...) + } + for _, dl := range diagram.Steps { + childrenBoards, err := renderBoards(opts, forceAppendix, ruler, dl) + if err != nil { + return nil, err + } + boards = append(boards, childrenBoards...) + } + + if !diagram.IsFolderOnly { + out, err := renderSingleBoard(opts, forceAppendix, ruler, diagram) + if err != nil { + return boards, err + } + boards = append([][]byte{out}, boards...) + } + return boards, nil +} + func GetBoardAtPosition(args []js.Value) (interface{}, error) { if len(args) < 3 { return nil, &WASMError{Message: "missing required arguments", Code: 400} diff --git a/d2js/d2wasm/types.go b/d2js/d2wasm/types.go index a13b82bae..70b3e1154 100644 --- a/d2js/d2wasm/types.go +++ b/d2js/d2wasm/types.go @@ -33,19 +33,33 @@ type BoardPositionResponse struct { type CompileRequest struct { FS map[string]string `json:"fs"` - Opts *RenderOptions `json:"options"` + Opts *CompileOptions `json:"options"` } type RenderOptions struct { - Layout *string `json:"layout"` - Sketch *bool `json:"sketch"` - ThemeID *int64 `json:"themeID"` + Pad *int64 `json:"pad"` + Sketch *bool `json:"sketch"` + Center *bool `json:"center"` + ThemeID *int64 `json:"themeID"` + DarkThemeID *int64 `json:"darkThemeID"` + Scale *float64 `json:"scale"` + ForceAppendix *bool `json:"forceAppendix"` + Target *string `json:"target"` + AnimateInterval *int64 `json:"animateInterval"` + Salt *string `json:"salt"` + NoXMLTag *bool `json:"noXMLTag"` +} + +type CompileOptions struct { + RenderOptions + Layout *string `json:"layout"` } type CompileResponse struct { - FS map[string]string `json:"fs"` - Diagram d2target.Diagram `json:"diagram"` - Graph d2graph.Graph `json:"graph"` + FS map[string]string `json:"fs"` + Diagram d2target.Diagram `json:"diagram"` + Graph d2graph.Graph `json:"graph"` + RenderOptions RenderOptions `json:"renderOptions"` } type CompletionResponse struct { diff --git a/d2js/js/README.md b/d2js/js/README.md index 5918a8f0a..411586036 100644 --- a/d2js/js/README.md +++ b/d2js/js/README.md @@ -42,24 +42,63 @@ import { D2 } from '@terrastruct/d2'; const d2 = new D2(); const result = await d2.compile('x -> y'); -const svg = await d2.render(result.diagram); +const svg = await d2.render(result.diagram, result.options); +``` + +Configuring render options (see [CompileOptions](#compileoptions) for all available options): + +```javascript +import { D2 } from '@terrastruct/d2'; + +const d2 = new D2(); + +const result = await d2.compile('x -> y', { + 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` + Compiles D2 markup into an intermediate representation. -Options: -- `layout`: Layout engine to use ('dagre' | 'elk') [default: 'dagre'] -- `sketch`: Enable sketch mode [default: false] - ### `render(diagram: Diagram, options?: RenderOptions): Promise` + Renders a compiled diagram to SVG. +### `CompileOptions` + +All [RenderOptions](#renderoptions) properties in addition to: + +- `layout`: Layout engine to use ('dagre' | 'elk') [default: 'dagre'] + +### `RenderOptions` + +- `sketch`: Enable sketch mode [default: false] +- `themeID`: Theme ID to use [default: 0] +- `darkThemeID`: Theme ID to use when client is in dark mode +- `center`: Center the SVG in the containing viewbox [default: false] +- `pad`: Pixels padded around the rendered diagram [default: 100] +- `scale`: Scale the output. E.g., 0.5 to halve the default size. The default will render SVG's that will fit to screen. Setting to 1 turns off SVG fitting to screen. +- `forceAppendix`: Adds an appendix for tooltips and links [default: false] +- `target`: Target board/s to render. If target ends with '*', it will be rendered with all of its scenarios, steps, and layers. Otherwise, only the target board will be rendered. E.g. `target: 'layers.x.*'` to render layer 'x' with all of its children. Pass '*' to render all scenarios, steps, and layers. By default, only the root board is rendered. Multi-board outputs are currently only supported for animated SVGs and so `animateInterval` must be set to a value greater than 0 when targeting multiple boards. +- `animateInterval`: If given, multiple boards are packaged as 1 SVG which transitions through each board at the interval (in milliseconds). +- `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. + +### `CompileResult` + +- `diagram`: `Diagram`: Compiled D2 diagram +- `options`: `RenderOptions`: Render options merged with configuration set in diagram +- `fs` +- `graph` + ## Development D2.js uses Bun, so install this first. diff --git a/d2js/js/examples/customizable.html b/d2js/js/examples/customizable.html index 13b7a2138..54df63a49 100644 --- a/d2js/js/examples/customizable.html +++ b/d2js/js/examples/customizable.html @@ -10,12 +10,14 @@ margin: 0; font-family: system-ui, -apple-system, sans-serif; } + .controls { display: flex; flex-direction: column; gap: 12px; width: 400px; } + textarea { width: 100%; height: 300px; @@ -24,6 +26,7 @@ border-radius: 4px; font-family: monospace; } + .options-group { display: flex; flex-direction: column; @@ -32,23 +35,36 @@ border: 1px solid #eee; border-radius: 4px; } - .layout-toggle, - .sketch-toggle { + + .option:has(.option-toggle-box:not(:checked)) .option-select { + opacity: 0.5; + pointer-events: none; + } + + .option { display: flex; gap: 16px; align-items: center; } - .radio-group { - display: flex; - gap: 12px; - } - .radio-label, - .checkbox-label { + + .input-label, + .checkbox-label, + .select-label { display: flex; gap: 4px; align-items: center; + } + + .checkbox-label, + .select-label { cursor: pointer; } + + .text-input, + .number-input { + width: 3rem; + } + button { padding: 8px 16px; background: #0066cc; @@ -57,9 +73,11 @@ border-radius: 4px; cursor: pointer; } + button:hover { background: #0052a3; } + #output { flex: 1; overflow: auto; @@ -67,34 +85,239 @@ border-radius: 4px; padding: 16px; } + #output svg { - max-width: 100%; + min-width: 100%; max-height: 90vh; } +
-
- Layout: -
-