diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1 index 0aadfd3b5..90831ef73 100644 --- a/ci/release/template/man/d2.1 +++ b/ci/release/template/man/d2.1 @@ -8,6 +8,7 @@ .Nm d2 .Op Fl -watch Ar false .Op Fl -theme Em 0 +.Op Fl -salt Ar string .Ar file.d2 .Op Ar file.svg | file.png .Nm d2 @@ -128,6 +129,9 @@ The maximum number of seconds that D2 runs for before timing out and exiting. Wh .It Fl -check Ar false Check that the specified files are formatted correctly .Ns . +.It Fl -salt Ar string +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 id's do not cause invalid HTML. The salt value is a string that will be appended to IDs in the output. +.Ns . .It Fl h , -help Print usage information and exit .Ns . diff --git a/d2cli/main.go b/d2cli/main.go index d2b56aedb..643a44e1e 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -134,6 +134,8 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { return err } + saltFlag := ms.Opts.String("", "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.") + plugins, err := d2plugin.ListPlugins(ctx) if err != nil { return err @@ -324,6 +326,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { DarkThemeID: darkThemeFlag, Scale: scale, NoXMLTag: noXMLTagFlag, + Salt: saltFlag, } if *watchFlag { @@ -517,7 +520,7 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs plugin, _ := d2plugin.FindPlugin(ctx, plugins, *opts.Layout) if animateInterval > 0 { - masterID, err := diagram.HashID() + masterID, err := diagram.HashID(renderOpts.Salt) if err != nil { return nil, false, err } @@ -865,7 +868,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts } else if toPNG { scale = go2.Pointer(1.) } - svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{ + renderOpts := &d2svg.RenderOpts{ Pad: opts.Pad, Sketch: opts.Sketch, Center: opts.Center, @@ -875,8 +878,10 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts ThemeOverrides: opts.ThemeOverrides, DarkThemeOverrides: opts.DarkThemeOverrides, NoXMLTag: opts.NoXMLTag, + Salt: opts.Salt, Scale: scale, - }) + } + svg, err := d2svg.Render(diagram, renderOpts) if err != nil { return nil, err } @@ -897,12 +902,12 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts bundleErr = multierr.Combine(bundleErr, bundleErr2) } if forceAppendix && !toPNG { - svg = appendix.Append(diagram, ruler, svg) + svg = appendix.Append(diagram, renderOpts, ruler, svg) } out := svg if toPNG { - svg := appendix.Append(diagram, ruler, svg) + svg := appendix.Append(diagram, renderOpts, ruler, svg) if !bundle { var bundleErr2 error @@ -960,7 +965,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt scale = go2.Pointer(1.) } - svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{ + renderOpts := &d2svg.RenderOpts{ Pad: opts.Pad, Sketch: opts.Sketch, Center: opts.Center, @@ -969,7 +974,8 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt DarkThemeID: opts.DarkThemeID, ThemeOverrides: opts.ThemeOverrides, DarkThemeOverrides: opts.DarkThemeOverrides, - }) + } + svg, err = d2svg.Render(diagram, renderOpts) if err != nil { return nil, err } @@ -987,7 +993,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt if bundleErr != nil { return svg, bundleErr } - svg = appendix.Append(diagram, ruler, svg) + svg = appendix.Append(diagram, renderOpts, ruler, svg) pngImg, err := ConvertSVG(ms, page, svg) if err != nil { @@ -1066,7 +1072,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present var err error - svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{ + renderOpts := &d2svg.RenderOpts{ Pad: opts.Pad, Sketch: opts.Sketch, Center: opts.Center, @@ -1075,7 +1081,8 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present DarkThemeID: opts.DarkThemeID, ThemeOverrides: opts.ThemeOverrides, DarkThemeOverrides: opts.DarkThemeOverrides, - }) + } + svg, err = d2svg.Render(diagram, renderOpts) if err != nil { return nil, err } @@ -1094,7 +1101,7 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present return nil, bundleErr } - svg = appendix.Append(diagram, ruler, svg) + svg = appendix.Append(diagram, renderOpts, ruler, svg) pngImg, err := ConvertSVG(ms, page, svg) if err != nil { @@ -1312,7 +1319,7 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug } else { scale = go2.Pointer(1.) } - svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{ + renderOpts := &d2svg.RenderOpts{ Pad: opts.Pad, Sketch: opts.Sketch, Center: opts.Center, @@ -1321,7 +1328,8 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug DarkThemeID: opts.DarkThemeID, ThemeOverrides: opts.ThemeOverrides, DarkThemeOverrides: opts.DarkThemeOverrides, - }) + } + svg, err = d2svg.Render(diagram, renderOpts) if err != nil { return nil, nil, err } @@ -1340,7 +1348,7 @@ func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plug return nil, nil, bundleErr } - svg = appendix.Append(diagram, ruler, svg) + svg = appendix.Append(diagram, renderOpts, ruler, svg) pngImg, err := ConvertSVG(ms, page, svg) if err != nil { diff --git a/d2exporter/export_test.go b/d2exporter/export_test.go index 1f1f1353a..e0f72587b 100644 --- a/d2exporter/export_test.go +++ b/d2exporter/export_test.go @@ -303,10 +303,10 @@ a -> b db, err := compile(ctx, bString) assert.JSON(t, nil, err) - hashA, err := da.HashID() + hashA, err := da.HashID(nil) assert.JSON(t, nil, err) - hashB, err := db.HashID() + hashB, err := db.HashID(nil) assert.JSON(t, nil, err) assert.NotEqual(t, hashA, hashB) diff --git a/d2renderers/d2animate/d2animate.go b/d2renderers/d2animate/d2animate.go index fc430ece3..22f922484 100644 --- a/d2renderers/d2animate/d2animate.go +++ b/d2renderers/d2animate/d2animate.go @@ -77,7 +77,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO svgsStr += string(svg) + " " } - diagramHash, err := rootDiagram.HashID() + diagramHash, err := rootDiagram.HashID(renderOpts.Salt) if err != nil { return nil, err } diff --git a/d2renderers/d2svg/appendix/appendix.go b/d2renderers/d2svg/appendix/appendix.go index 97b025785..28b416284 100644 --- a/d2renderers/d2svg/appendix/appendix.go +++ b/d2renderers/d2svg/appendix/appendix.go @@ -63,7 +63,7 @@ func FindViewboxSlice(svg []byte) []string { return strings.Split(viewboxRaw, " ") } -func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []byte { +func Append(diagram *d2target.Diagram, renderOpts *d2svg.RenderOpts, ruler *textmeasure.Ruler, in []byte) []byte { svg := string(in) appendix, w, h := generateAppendix(diagram, ruler, svg) @@ -177,7 +177,11 @@ func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []by return renderOrder[i].shape.Level < renderOrder[j].shape.Level }) - diagramHash, err := diagram.HashID() + var salt *string + if renderOpts != nil { + salt = renderOpts.Salt + } + diagramHash, err := diagram.HashID(salt) if err != nil { return nil } diff --git a/d2renderers/d2svg/appendix/appendix_test.go b/d2renderers/d2svg/appendix/appendix_test.go index 7752e2d2f..9b0bbd1ba 100644 --- a/d2renderers/d2svg/appendix/appendix_test.go +++ b/d2renderers/d2svg/appendix/appendix_test.go @@ -3,7 +3,6 @@ package appendix_test import ( "context" "encoding/xml" - "io/ioutil" "log/slog" "os" "path/filepath" @@ -172,11 +171,11 @@ func run(t *testing.T, tc testCase) { svgBytes, err := d2svg.Render(diagram, renderOpts) assert.Success(t, err) - svgBytes = appendix.Append(diagram, ruler, svgBytes) + svgBytes = appendix.Append(diagram, nil, ruler, svgBytes) err = os.MkdirAll(dataPath, 0755) assert.Success(t, err) - err = ioutil.WriteFile(pathGotSVG, svgBytes, 0600) + err = os.WriteFile(pathGotSVG, svgBytes, 0600) assert.Success(t, err) defer os.Remove(pathGotSVG) diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index c3469a61c..ccce03afb 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -88,6 +88,7 @@ type RenderOpts struct { // Currently, that's when multi-boards are collapsed MasterID string NoXMLTag *bool + Salt *string } func dimensions(diagram *d2target.Diagram, pad int) (left, top, width, height int) { @@ -1908,7 +1909,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { } // Apply hash on IDs for targeting, to be specific for this diagram - diagramHash, err := diagram.HashID() + diagramHash, err := diagram.HashID(opts.Salt) if err != nil { return nil, err } diff --git a/d2target/d2target.go b/d2target/d2target.go index 11da29688..0f8aca91f 100644 --- a/d2target/d2target.go +++ b/d2target/d2target.go @@ -223,13 +223,16 @@ func (diagram Diagram) HasShape(condition func(Shape) bool) bool { return false } -func (diagram Diagram) HashID() (string, error) { +func (diagram Diagram) HashID(salt *string) (string, error) { bytes, err := diagram.Bytes() if err != nil { return "", err } h := fnv.New32a() h.Write(bytes) + if salt != nil { + h.Write([]byte(*salt)) + } // CSS names can't start with numbers, so prepend a little something return fmt.Sprintf("d2-%d", h.Sum32()), nil } diff --git a/docs/examples/lib/1-d2lib/d2lib_test.go b/docs/examples/lib/1-d2lib/d2lib_test.go index 30a1b923e..391ae5a1a 100644 --- a/docs/examples/lib/1-d2lib/d2lib_test.go +++ b/docs/examples/lib/1-d2lib/d2lib_test.go @@ -38,7 +38,7 @@ func TestConfigHash(t *testing.T) { } ctx := log.WithDefault(context.Background()) diagram, _, _ := d2lib.Compile(ctx, "x -> y", compileOpts, renderOpts) - hash1, err = diagram.HashID() + hash1, err = diagram.HashID(nil) assert.Success(t, err) } @@ -57,7 +57,52 @@ func TestConfigHash(t *testing.T) { } ctx := log.WithDefault(context.Background()) diagram, _, _ := d2lib.Compile(ctx, "x -> y", compileOpts, renderOpts) - hash2, err = diagram.HashID() + hash2, err = diagram.HashID(nil) + assert.Success(t, err) + } + + assert.NotEqual(t, hash1, hash2) +} + +func TestHashSalt(t *testing.T) { + var hash1, hash2 string + var err error + + { + ruler, _ := textmeasure.NewRuler() + layoutResolver := func(engine string) (d2graph.LayoutGraph, error) { + return d2dagrelayout.DefaultLayout, nil + } + renderOpts := &d2svg.RenderOpts{ + Pad: go2.Pointer(int64(5)), + ThemeID: &d2themescatalog.GrapeSoda.ID, + } + compileOpts := &d2lib.CompileOptions{ + LayoutResolver: layoutResolver, + Ruler: ruler, + } + ctx := log.WithDefault(context.Background()) + diagram, _, _ := d2lib.Compile(ctx, "x -> y", compileOpts, renderOpts) + hash1, err = diagram.HashID(nil) + assert.Success(t, err) + } + + { + ruler, _ := textmeasure.NewRuler() + layoutResolver := func(engine string) (d2graph.LayoutGraph, error) { + return d2dagrelayout.DefaultLayout, nil + } + renderOpts := &d2svg.RenderOpts{ + Pad: go2.Pointer(int64(5)), + ThemeID: &d2themescatalog.GrapeSoda.ID, + } + compileOpts := &d2lib.CompileOptions{ + LayoutResolver: layoutResolver, + Ruler: ruler, + } + ctx := log.WithDefault(context.Background()) + diagram, _, _ := d2lib.Compile(ctx, "x -> y", compileOpts, renderOpts) + hash2, err = diagram.HashID(go2.Pointer("asdf")) assert.Success(t, err) } diff --git a/e2etests/e2e_test.go b/e2etests/e2e_test.go index 6eda5d980..bfe1f230b 100644 --- a/e2etests/e2e_test.go +++ b/e2etests/e2e_test.go @@ -250,7 +250,7 @@ func run(t *testing.T, tc testCase) { pathGotSVG := filepath.Join(dataPath, "sketch.got.svg") if len(diagram.Layers) > 0 || len(diagram.Scenarios) > 0 || len(diagram.Steps) > 0 { - masterID, err := diagram.HashID() + masterID, err := diagram.HashID(nil) assert.Success(t, err) renderOpts.MasterID = masterID }