diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 4a012fd0b..46f2e9d09 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -3,6 +3,7 @@
- ELK now routes `sql_table` edges to the exact columns (ty @landmaj) [#1681](https://github.com/terrastruct/d2/pull/1681)
- Adds new unfilled triangle arrowhead. [#1711](https://github.com/terrastruct/d2/pull/1711)
- Grid containers can now have custom label positions. [#1715](https://github.com/terrastruct/d2/pull/1715)
+- A single board from a multi-board diagram can now be rendered with `--target` flag. [#1725](https://github.com/terrastruct/d2/pull/1725)
#### Improvements 🧹
diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1
index d1ebc5939..d1663d7ea 100644
--- a/ci/release/template/man/d2.1
+++ b/ci/release/template/man/d2.1
@@ -111,6 +111,11 @@ Bundle all assets and layers into the output svg
.It Fl -force-appendix Ar false
An appendix for tooltips and links is added to PNG exports since they are not interactive. Setting this to true adds an appendix to SVG exports as well
.Ns .
+.It Fl -target
+Target board to render. Pass an empty string to target root board. 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=''
+to render root board only or --target='layers.x.*' to render layer 'x' with all of its children
+.Ns .
.It Fl d , -debug
Print debug logs
.Ns .
diff --git a/d2cli/main.go b/d2cli/main.go
index f879bf6d8..3cafcf6e5 100644
--- a/d2cli/main.go
+++ b/d2cli/main.go
@@ -117,6 +117,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
return err
}
+ targetFlag := ms.Opts.String("", "target", "", "*", "target board to render. Pass an empty string to target root board. 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='' to render root board only or --target='layers.x.*' to render layer 'x' with all of its children.")
fontRegularFlag := ms.Opts.String("D2_FONT_REGULAR", "font-regular", "", "", "path to .ttf file to use for the regular font. If none provided, Source Sans Pro Regular is used.")
fontItalicFlag := ms.Opts.String("D2_FONT_ITALIC", "font-italic", "", "", "path to .ttf file to use for the italic font. If none provided, Source Sans Pro Regular-Italic is used.")
@@ -312,6 +313,9 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if inputPath == "-" {
return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin")
}
+ if *targetFlag != "*" {
+ return xmain.UsageErrorf("-w[atch] cannot be combined with --target")
+ }
w, err := newWatcher(ctx, ms, watcherOpts{
plugins: plugins,
layout: layoutFlag,
@@ -332,10 +336,30 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
return w.run()
}
+ var boardPath []string
+ var noChildren bool
+ switch *targetFlag {
+ case "*":
+ case "":
+ noChildren = true
+ default:
+ target := *targetFlag
+ if strings.HasSuffix(target, ".*") {
+ target = target[:len(target)-2]
+ } else {
+ noChildren = true
+ }
+ key, err := d2parser.ParseKey(target)
+ if err != nil {
+ return xmain.UsageErrorf("invalid target: %s", *targetFlag)
+ }
+ boardPath = key.IDA()
+ }
+
ctx, cancel := timelib.WithTimeout(ctx, time.Minute*2)
defer cancel()
- _, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, "", *bundleFlag, *forceAppendixFlag, pw.Page)
+ _, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page)
if err != nil {
if written {
return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), err)
@@ -410,7 +434,7 @@ func RouterResolver(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plu
}
}
-func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath, boardPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
+func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
start := time.Now()
input, err := ms.ReadPath(inputPath)
if err != nil {
@@ -463,6 +487,16 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
}
cancel()
+ diagram = diagram.GetBoard(boardPath)
+ if diagram == nil {
+ return nil, false, fmt.Errorf(`render target "%s" not found`, strings.Join(boardPath, "."))
+ }
+ if noChildren {
+ diagram.Layers = nil
+ diagram.Scenarios = nil
+ diagram.Steps = nil
+ }
+
plugin, _ := d2plugin.FindPlugin(ctx, plugins, *opts.Layout)
if animateInterval > 0 {
@@ -566,12 +600,13 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs
}
}
- board := diagram.GetBoard(boardPath)
- if board == nil {
- return nil, false, fmt.Errorf(`Diagram with path "%s" not found. Did you mean to specify a board like "layers.%s"?`, boardPath, boardPath)
+ var boards [][]byte
+ var err error
+ if noChildren {
+ boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
+ } else {
+ boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
}
-
- boards, err := render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, board)
if err != nil {
return nil, false, err
}
@@ -788,6 +823,19 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
return boards, nil
}
+func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
+ start := time.Now()
+ out, err := _render(ctx, ms, plugin, opts, outputPath, bundle, forceAppendix, page, ruler, diagram)
+ if err != nil {
+ return [][]byte{}, err
+ }
+ dur := compileDur + time.Since(start)
+ if opts.MasterID == "" {
+ ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur)
+ }
+ return [][]byte{out}, nil
+}
+
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
toPNG := getExportExtension(outputPath) == PNG
var scale *float64
diff --git a/d2cli/watch.go b/d2cli/watch.go
index b721e7726..92d9ee97f 100644
--- a/d2cli/watch.go
+++ b/d2cli/watch.go
@@ -426,7 +426,11 @@ func (w *watcher) compileLoop(ctx context.Context) error {
fs := trackedFS{}
w.boardpathMu.Lock()
- svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, w.boardPath, w.bundle, w.forceAppendix, w.pw.Page)
+ var boardPath []string
+ if w.boardPath != "" {
+ boardPath = strings.Split(w.boardPath, string(os.PathSeparator))
+ }
+ svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page)
w.boardpathMu.Unlock()
errs := ""
if err != nil {
diff --git a/d2target/d2target.go b/d2target/d2target.go
index 1efcfd08f..b369af4e7 100644
--- a/d2target/d2target.go
+++ b/d2target/d2target.go
@@ -6,7 +6,6 @@ import (
"hash/fnv"
"math"
"net/url"
- "os"
"strings"
"oss.terrastruct.com/util-go/go2"
@@ -73,19 +72,7 @@ type Diagram struct {
Steps []*Diagram `json:"steps,omitempty"`
}
-// boardPath comes in the form of "x/layers/z/scenarios/a"
-// or "layers/z/scenarios/a"
-// or "x/z/a"
-func (d *Diagram) GetBoard(boardPath string) *Diagram {
- path := strings.Split(boardPath, string(os.PathSeparator))
- if len(path) == 0 || len(boardPath) == 0 {
- return d
- }
-
- return d.getBoard(path)
-}
-
-func (d *Diagram) getBoard(boardPath []string) *Diagram {
+func (d *Diagram) GetBoard(boardPath []string) *Diagram {
if len(boardPath) == 0 {
return d
}
@@ -103,7 +90,7 @@ func (d *Diagram) getBoard(boardPath []string) *Diagram {
}
for _, b := range d.Layers {
if b.Name == boardPath[1] {
- return b.getBoard(boardPath[2:])
+ return b.GetBoard(boardPath[2:])
}
}
case "scenarios":
@@ -112,7 +99,7 @@ func (d *Diagram) getBoard(boardPath []string) *Diagram {
}
for _, b := range d.Scenarios {
if b.Name == boardPath[1] {
- return b.getBoard(boardPath[2:])
+ return b.GetBoard(boardPath[2:])
}
}
case "steps":
@@ -121,24 +108,24 @@ func (d *Diagram) getBoard(boardPath []string) *Diagram {
}
for _, b := range d.Steps {
if b.Name == boardPath[1] {
- return b.getBoard(boardPath[2:])
+ return b.GetBoard(boardPath[2:])
}
}
}
for _, b := range d.Layers {
if b.Name == head {
- return b.getBoard(boardPath[1:])
+ return b.GetBoard(boardPath[1:])
}
}
for _, b := range d.Scenarios {
if b.Name == head {
- return b.getBoard(boardPath[1:])
+ return b.GetBoard(boardPath[1:])
}
}
for _, b := range d.Steps {
if b.Name == head {
- return b.getBoard(boardPath[1:])
+ return b.GetBoard(boardPath[1:])
}
}
return nil
diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go
index f851c9833..d58e81dd7 100644
--- a/e2etests-cli/main_test.go
+++ b/e2etests-cli/main_test.go
@@ -239,6 +239,66 @@ You provided: .png`)
testdataIgnoreDiff(t, ".png", png)
},
},
+ {
+ name: "target-root",
+ run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
+ writeFile(t, dir, "target-root.d2", `title: {
+ label: Main Plan
+}
+scenarios: {
+ b: {
+ title.label: Backup Plan
+ }
+}`)
+ err := runTestMain(t, ctx, dir, env, "--target", "", "target-root.d2", "target-root.svg")
+ assert.Success(t, err)
+ svg := readFile(t, dir, "target-root.svg")
+ assert.Testdata(t, ".svg", svg)
+ },
+ },
+ {
+ name: "target-b",
+ run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
+ writeFile(t, dir, "target-b.d2", `title: {
+ label: Main Plan
+}
+scenarios: {
+ b: {
+ title.label: Backup Plan
+ }
+}`)
+ err := runTestMain(t, ctx, dir, env, "--target", "b", "target-b.d2", "target-b.svg")
+ assert.Success(t, err)
+ svg := readFile(t, dir, "target-b.svg")
+ assert.Testdata(t, ".svg", svg)
+ },
+ },
+ {
+ name: "target-nested-with-special-chars",
+ run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
+ writeFile(t, dir, "target-nested-with-special-chars.d2", `layers: {
+ a: {
+ layers: {
+ "x / y . z": {
+ mad
+ }
+ }
+ }
+}`)
+ err := runTestMain(t, ctx, dir, env, "--target", `layers.a.layers."x / y . z"`, "target-nested-with-special-chars.d2", "target-nested-with-special-chars.svg")
+ assert.Success(t, err)
+ svg := readFile(t, dir, "target-nested-with-special-chars.svg")
+ assert.Testdata(t, ".svg", svg)
+ },
+ },
+ {
+ name: "target-invalid",
+ run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
+ writeFile(t, dir, "target-invalid.d2", `x -> y`)
+ err := runTestMain(t, ctx, dir, env, "--target", "b", "target-invalid.d2", "target-invalid.svg")
+ assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: failed to compile target-invalid.d2: render target "b" not found`)
+ },
+ },
{
name: "multiboard/life",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
diff --git a/e2etests-cli/testdata/TestCLI_E2E/target-b.exp.svg b/e2etests-cli/testdata/TestCLI_E2E/target-b.exp.svg
new file mode 100644
index 000000000..83d8d49c8
--- /dev/null
+++ b/e2etests-cli/testdata/TestCLI_E2E/target-b.exp.svg
@@ -0,0 +1,95 @@
+
diff --git a/e2etests-cli/testdata/TestCLI_E2E/target-nested-with-special-chars.exp.svg b/e2etests-cli/testdata/TestCLI_E2E/target-nested-with-special-chars.exp.svg
new file mode 100644
index 000000000..9a02be7ec
--- /dev/null
+++ b/e2etests-cli/testdata/TestCLI_E2E/target-nested-with-special-chars.exp.svg
@@ -0,0 +1,95 @@
+mad
+
+
+
diff --git a/e2etests-cli/testdata/TestCLI_E2E/target-root.exp.svg b/e2etests-cli/testdata/TestCLI_E2E/target-root.exp.svg
new file mode 100644
index 000000000..b0721bed1
--- /dev/null
+++ b/e2etests-cli/testdata/TestCLI_E2E/target-root.exp.svg
@@ -0,0 +1,95 @@
+Main Plan
+
+
+