diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index 67303ce99..b7032e194 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -8,6 +8,7 @@ Layout capability also takes a subtle but important step forward: you can now cu - Configure timeout value with D2_TIMEOUT env var [#1392](https://github.com/terrastruct/d2/pull/1392) - Scale renders and disable fit to screen with `--scale` flag [#1413](https://github.com/terrastruct/d2/pull/1413) - `null` keyword can be used to un-declare. See [docs](https://d2lang.com/tour/overrides#null) [#1446](https://github.com/terrastruct/d2/pull/1446) +- Develop multi-board diagrams in watch mode (links to layers/scenarios/steps work in `--watch`) [#1503](https://github.com/terrastruct/d2/pull/1503) #### Improvements 🧹 diff --git a/d2cli/main.go b/d2cli/main.go index 635a5e440..546a5a977 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -331,7 +331,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { ctx, cancel := timelib.WithTimeout(ctx, time.Minute*2) defer cancel() - _, written, err := compile(ctx, ms, plugins, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page) + _, written, err := compile(ctx, ms, plugins, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, "", *bundleFlag, *forceAppendixFlag, pw.Page) if err != nil { if written { return fmt.Errorf("failed to fully compile (partial render written): %w", err) @@ -366,7 +366,7 @@ func LayoutResolver(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plu } } -func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) { +func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath, boardPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) { start := time.Now() input, err := ms.ReadPath(inputPath) if err != nil { @@ -500,7 +500,12 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, la } } - boards, err := render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) + board := diagram.GetBoard(boardPath) + if board == nil { + return nil, false, fmt.Errorf("Diagram with path %s not found", boardPath) + } + + boards, err := render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, board) if err != nil { return nil, false, err } diff --git a/d2cli/static/watch.js b/d2cli/static/watch.js index a95403971..4d91259eb 100644 --- a/d2cli/static/watch.js +++ b/d2cli/static/watch.js @@ -8,9 +8,7 @@ function init(reconnectDelay) { const d2SVG = window.document.querySelector("#d2-svg-container"); const devMode = document.body.dataset.d2DevMode === "true"; - const ws = new WebSocket( - `ws://${window.location.host}${window.location.pathname}watch` - ); + const ws = new WebSocket(`ws://${window.location.host}/watch`); let isInit = true; let ratio; ws.onopen = () => { @@ -28,7 +26,7 @@ function init(reconnectDelay) { // we can't just set `d2SVG.innerHTML = msg.svg` need to parse this as xml not html const parsedXML = new DOMParser().parseFromString(msg.svg, "text/xml"); d2SVG.replaceChildren(parsedXML.documentElement); - changeFavicon("./static/favicon.ico"); + changeFavicon("/static/favicon.ico"); const svgEl = d2SVG.querySelector("#d2-svg"); // just use inner SVG in watch mode svgEl.parentElement.replaceWith(svgEl); @@ -60,7 +58,7 @@ function init(reconnectDelay) { if (msg.err) { d2ErrDiv.innerText = msg.err; d2ErrDiv.style.display = "block"; - changeFavicon("./static/favicon-err.ico"); + changeFavicon("/static/favicon-err.ico"); d2ErrDiv.scrollIntoView(); } }; diff --git a/d2cli/watch.go b/d2cli/watch.go index 0fefabd61..866a480b3 100644 --- a/d2cli/watch.go +++ b/d2cli/watch.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync" "time" @@ -49,6 +50,7 @@ type watcherOpts struct { port string inputPath string outputPath string + boardPath string pwd string bundle bool forceAppendix bool @@ -362,7 +364,7 @@ func (w *watcher) compileLoop(ctx context.Context) error { w.pw = newPW } - svg, _, err := compile(ctx, w.ms, w.plugins, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page) + svg, _, err := compile(ctx, w.ms, w.plugins, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, w.boardPath, w.bundle, w.forceAppendix, w.pw.Page) errs := "" if err != nil { if len(svg) > 0 { @@ -430,15 +432,25 @@ func (w *watcher) handleRoot(hw http.ResponseWriter, r *http.Request) { %s - - - + + +
`, filepath.Base(w.outputPath), w.devMode) + + // if path is "/x.svg", we just want "x" + boardPath := strings.TrimPrefix(r.URL.Path, "/") + if idx := strings.LastIndexByte(boardPath, '.'); idx != -1 { + boardPath = boardPath[:idx] + } + if boardPath != w.boardPath { + w.boardPath = boardPath + w.requestCompile() + } } func (w *watcher) handleWatch(hw http.ResponseWriter, r *http.Request) error { diff --git a/d2target/d2target.go b/d2target/d2target.go index 368e4c1a7..ea8e67b0b 100644 --- a/d2target/d2target.go +++ b/d2target/d2target.go @@ -6,6 +6,7 @@ import ( "hash/fnv" "math" "net/url" + "os" "strings" "oss.terrastruct.com/util-go/go2" @@ -72,6 +73,72 @@ type Diagram struct { Steps []*Diagram `json:"steps,omitempty"` } +// boardPath comes in the form of "x/layers/z/scenarios/a" +// or in the form of "layers/z/scenarios/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 { + head := boardPath[0] + + if head == "index" { + return d + } + + switch head { + case "layers": + if len(boardPath) < 2 { + return nil + } + for _, b := range d.Layers { + if b.Name == boardPath[1] { + return b.getBoard(boardPath[2:]) + } + } + case "scenarios": + if len(boardPath) < 2 { + return nil + } + for _, b := range d.Scenarios { + if b.Name == boardPath[1] { + return b.getBoard(boardPath[2:]) + } + } + case "steps": + if len(boardPath) < 2 { + return nil + } + for _, b := range d.Steps { + if b.Name == boardPath[1] { + return b.getBoard(boardPath[2:]) + } + } + } + + for _, b := range d.Layers { + if b.Name == head { + return b.getBoard(boardPath[2:]) + } + } + for _, b := range d.Scenarios { + if b.Name == head { + return b.getBoard(boardPath[2:]) + } + } + for _, b := range d.Steps { + if b.Name == head { + return b.getBoard(boardPath[2:]) + } + } + return nil +} + func (diagram Diagram) Bytes() ([]byte, error) { b1, err := json.Marshal(diagram.Shapes) if err != nil {