From b0cbd6621939f3eea025c38bf2dea376994b6f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Wielu=C5=84ski?= Date: Tue, 14 Nov 2023 21:42:02 +0100 Subject: [PATCH] cli: add --target flag --- ci/release/changelogs/next.md | 1 + ci/release/template/man/d2.1 | 5 + d2cli/main.go | 62 ++++++++++-- d2cli/watch.go | 6 +- d2target/d2target.go | 27 ++---- e2etests-cli/main_test.go | 60 ++++++++++++ .../testdata/TestCLI_E2E/target-b.exp.svg | 95 +++++++++++++++++++ .../target-nested-with-special-chars.exp.svg | 95 +++++++++++++++++++ .../testdata/TestCLI_E2E/target-root.exp.svg | 95 +++++++++++++++++++ 9 files changed, 418 insertions(+), 28 deletions(-) create mode 100644 e2etests-cli/testdata/TestCLI_E2E/target-b.exp.svg create mode 100644 e2etests-cli/testdata/TestCLI_E2E/target-nested-with-special-chars.exp.svg create mode 100644 e2etests-cli/testdata/TestCLI_E2E/target-root.exp.svg diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index 59c8f3b63..643bf2c66 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 793c04839..542b95737 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -115,6 +115,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.") @@ -310,6 +311,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, @@ -330,10 +334,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) @@ -368,7 +392,7 @@ func LayoutResolver(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 { @@ -400,6 +424,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 { @@ -503,12 +537,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 } @@ -725,6 +760,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 3c468b81d..7f0a7da7c 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 @@ +Backup Plan + + + 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 + + +