From 1bf8784f00884a964d9aebb4848efcba950634b1 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Tue, 19 Oct 2021 19:47:11 +0100 Subject: [PATCH] feat: storybook support and example --- go.mod | 2 + go.sum | 22 ++ storybook/.gitignore | 1 + storybook/example/main.go | 20 ++ storybook/example/run.sh | 1 + storybook/example/templates.templ | 16 ++ storybook/example/templates_templ.go | 95 +++++++ storybook/storybook.go | 409 +++++++++++++++++++++++++++ 8 files changed, 566 insertions(+) create mode 100644 storybook/.gitignore create mode 100644 storybook/example/main.go create mode 100644 storybook/example/run.sh create mode 100644 storybook/example/templates.templ create mode 100644 storybook/example/templates_templ.go create mode 100644 storybook/storybook.go diff --git a/go.mod b/go.mod index 6ab321c7e..c5478ef00 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.16 require ( github.com/PuerkitoBio/goquery v1.7.1 github.com/a-h/lexical v0.0.47 + github.com/a-h/pathvars v0.0.0-20200320143331-78b263b728e2 github.com/google/go-cmp v0.5.6 github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 github.com/natefinch/atomic v0.0.0-20210627155326-85a124586cef + github.com/rs/cors v1.8.0 github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d github.com/sourcegraph/jsonrpc2 v0.1.0 go.uber.org/atomic v1.8.0 // indirect diff --git a/go.sum b/go.sum index dc7bc4a8a..aa57421b1 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,21 @@ github.com/PuerkitoBio/goquery v1.7.1 h1:oE+T06D+1T7LNrn91B4aERsRIeCLJ/oPSa6xB9F github.com/PuerkitoBio/goquery v1.7.1/go.mod h1:XY0pP4kfraEmmV1O7Uf6XyjoslwsneBbgeDjLYuN8xY= github.com/a-h/lexical v0.0.47 h1:8VshFdJrb1wnDiA1bFqoxPhXsxpMqPFAsLcwLNQ8rq4= github.com/a-h/lexical v0.0.47/go.mod h1:d73jw5cgKXuYypRozNBuxRNFrTWQ3y5hVMG7rUjh1Qw= +github.com/a-h/pathvars v0.0.0-20200320143331-78b263b728e2 h1:OObPWjEG3FZsjTNQPATyh8IpAvUfB6WpIO4mSzRSbRc= +github.com/a-h/pathvars v0.0.0-20200320143331-78b263b728e2/go.mod h1:QulNSBbUWcOdt7MTypwParV0/gDtr8qRBmQAiDdOnS4= github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -16,20 +24,30 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/natefinch/atomic v0.0.0-20210627155326-85a124586cef h1:4MIQ+csbyiVcxSajo0jymyhSowdeh5lCcgAPyDz96k8= github.com/natefinch/atomic v0.0.0-20210627155326-85a124586cef/go.mod h1:1rLVY/DWf3U6vSZgH16S7pymfrhK2lcUlXjgGglw/lY= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so= +github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d h1:afLbh+ltiygTOB37ymZVwKlJwWZn+86syPTbrrOAydY= github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d/go.mod h1:SULmZY7YNBsvNiQbrb/BEDdEJ84TGnfyUQxaHt8t8rY= github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk= github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.8.0 h1:CUhrE4N1rqSE6FM9ecihEjRkLQu8cDfgDyoOs83mEY4= go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -41,6 +59,7 @@ go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -49,6 +68,9 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/storybook/.gitignore b/storybook/.gitignore new file mode 100644 index 000000000..19e8b1e74 --- /dev/null +++ b/storybook/.gitignore @@ -0,0 +1 @@ +storybook-server diff --git a/storybook/example/main.go b/storybook/example/main.go new file mode 100644 index 000000000..9142f21f0 --- /dev/null +++ b/storybook/example/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/a-h/templ/storybook" +) + +func main() { + s := storybook.New() + s.AddComponent("headerTemplate", headerTemplate, + storybook.TextArg("name", "Page Name")) + s.AddComponent("footerTemplate", footerTemplate) + if err := s.ListenAndServeWithContext(context.Background()); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } +} diff --git a/storybook/example/run.sh b/storybook/example/run.sh new file mode 100644 index 000000000..b13dd19e1 --- /dev/null +++ b/storybook/example/run.sh @@ -0,0 +1 @@ +go run *.go diff --git a/storybook/example/templates.templ b/storybook/example/templates.templ new file mode 100644 index 000000000..953362017 --- /dev/null +++ b/storybook/example/templates.templ @@ -0,0 +1,16 @@ +{% package main %} + +{% import "fmt" %} +{% import "time" %} + +{% templ headerTemplate(name string) %} +
+

{%= name %}

+
+{% endtempl %} + +{% templ footerTemplate() %} + +{% endtempl %} diff --git a/storybook/example/templates_templ.go b/storybook/example/templates_templ.go new file mode 100644 index 000000000..8d341c5b8 --- /dev/null +++ b/storybook/example/templates_templ.go @@ -0,0 +1,95 @@ +// Code generated by templ DO NOT EDIT. + +package main + +import "github.com/a-h/templ" +import "context" +import "io" +import "fmt" +import "time" + +func headerTemplate(name string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + ctx, _ = templ.RenderedCSSClassesFromContext(ctx) + ctx, _ = templ.RenderedScriptsFromContext(ctx) + err = templ.RenderScripts(ctx, w, ) + if err != nil { + return err + } + _, err = io.WriteString(w, "") + if err != nil { + return err + } + _, err = io.WriteString(w, "

") + if err != nil { + return err + } + _, err = io.WriteString(w, templ.EscapeString(name)) + if err != nil { + return err + } + _, err = io.WriteString(w, "

") + if err != nil { + return err + } + _, err = io.WriteString(w, "") + if err != nil { + return err + } + return err + }) +} + +func footerTemplate() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + ctx, _ = templ.RenderedCSSClassesFromContext(ctx) + ctx, _ = templ.RenderedScriptsFromContext(ctx) + err = templ.RenderScripts(ctx, w, ) + if err != nil { + return err + } + _, err = io.WriteString(w, "") + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + var_1 := `© ` + _, err = io.WriteString(w, var_1) + if err != nil { + return err + } + _, err = io.WriteString(w, templ.EscapeString(fmt.Sprintf("%d", time.Now().Year()))) + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + _, err = io.WriteString(w, "") + if err != nil { + return err + } + return err + }) +} + diff --git a/storybook/storybook.go b/storybook/storybook.go new file mode 100644 index 000000000..eaef7fb61 --- /dev/null +++ b/storybook/storybook.go @@ -0,0 +1,409 @@ +package storybook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "reflect" + "strconv" + "sync" + + "github.com/a-h/pathvars" + "github.com/a-h/templ" + "github.com/rs/cors" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type Storybook struct { + Path string + Config map[string]*Conf + Handlers map[string]http.Handler + Server http.Server + ServerURL string + Log *zap.Logger +} + +func New() *Storybook { + cfg := zap.NewProductionConfig() + cfg.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder + logger, err := cfg.Build() + if err != nil { + panic("templ-storybook: zap configuration failed: " + err.Error()) + } + sh := &Storybook{ + Path: "./storybook-server", + Config: map[string]*Conf{}, + Handlers: map[string]http.Handler{}, + ServerURL: "http://localhost:60606/storybook_preview", + Log: logger, + } + sh.Server = http.Server{ + Handler: sh, + Addr: "localhost:60606", + } + return sh +} + +func (sh *Storybook) AddComponent(name string, componentConstructor interface{}, args ...Arg) { + //TODO: Check that the component constructor is a function that returns a templ.Component. + c := NewConf(name, args...) + sh.Config[name] = c + h := NewHandler(name, componentConstructor, args...) + sh.Handlers[name] = h + return +} + +var storybookPreviewMatcher = pathvars.NewExtractor("/storybook_preview/{name}") + +func (sh *Storybook) ServeHTTP(w http.ResponseWriter, r *http.Request) { + values, ok := storybookPreviewMatcher.Extract(r.URL) + if !ok { + sh.Log.Info("URL not matched", zap.String("url", r.URL.String())) + http.NotFound(w, r) + return + } + name, ok := values["name"] + if !ok { + sh.Log.Info("URL does not contain component name", zap.String("url", r.URL.String())) + http.NotFound(w, r) + return + } + h, found := sh.Handlers[name] + if !found { + sh.Log.Info("Component name not found", zap.String("name", name), zap.String("url", r.URL.String())) + http.NotFound(w, r) + return + } + cors.Default().Handler(h).ServeHTTP(w, r) +} + +func (sh *Storybook) ListenAndServeWithContext(ctx context.Context) (err error) { + defer sh.Log.Sync() + // Download Storybook to the directory required. + sh.Log.Info("Installing storybook") + err = sh.installStorybook() + if err != nil { + return + } + if ctx.Err() != nil { + return + } + + // Copy the config to Storybook. + sh.Log.Info("Configuring storybook") + err = sh.configureStorybook() + if err != nil { + return + } + if ctx.Err() != nil { + return + } + + // Run storybook. + go func() { + sh.Log.Info("Starting Storybook on http://localhost:6006/") + sh.runStorybook(ctx) + }() + + // Start the Go server. + sh.Server.Handler = sh + go func() { + sh.Log.Info("Starting Go server", zap.String("address", sh.Server.Addr), zap.String("serverUrl", sh.ServerURL)) + err = sh.Server.ListenAndServe() + }() + <-ctx.Done() + // Close the Go server. + sh.Server.Close() + return err +} + +func (sh *Storybook) installStorybook() (err error) { + _, err = os.Stat(sh.Path) + if err == nil { + sh.Log.Info("Skipping installation - Storybook is already installed.") + return + } + if os.IsNotExist(err) { + err = os.Mkdir(sh.Path, os.ModePerm) + if err != nil { + return fmt.Errorf("templ-storybook: error creating @storybook/server directory: %w", err) + } + } + var cmd exec.Cmd + cmd.Dir = sh.Path + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Path, err = exec.LookPath("npx") + if err != nil { + return fmt.Errorf("templ-storybook: cannot install storybook, cannot find npx on the path, check that Node.js is installed: %w", err) + } + cmd.Args = []string{"npx", "sb", "init", "-t", "server"} + return cmd.Run() +} + +func (sh *Storybook) configureStorybook() error { + // Delete template/existing files in the stories directory. + storiesDir := path.Join(sh.Path, "stories") + if err := os.RemoveAll(storiesDir); err != nil { + return err + } + if err := os.Mkdir(storiesDir, os.ModePerm); err != nil { + return err + } + // Create new *.stories.json files. + for _, c := range sh.Config { + name := path.Join(sh.Path, fmt.Sprintf("stories/%s.stories.json", c.Title)) + f, err := os.Create(name) + if err != nil { + return fmt.Errorf("failed to create config file to %q: %w", name, err) + } + err = json.NewEncoder(f).Encode(c) + if err != nil { + return fmt.Errorf("failed to write JSON config to %q: %w", name, err) + } + } + // Configure storybook Preview URL. + previewJS := fmt.Sprintf(`export const parameters = { server: { url: %s } };`, jsonEncode(sh.ServerURL)) + return os.WriteFile(path.Join(sh.Path, ".storybook/preview.js"), []byte(previewJS), os.ModePerm) +} + +func jsonEncode(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func (sh *Storybook) runStorybook(ctx context.Context) (err error) { + var cmd exec.Cmd + cmd.Dir = sh.Path + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Path, err = exec.LookPath("npm") + if err != nil { + return fmt.Errorf("templ-storybook: cannot run storybook, cannot find npm on the path, check that Node.js is installed: %w", err) + } + cmd.Args = []string{"npm", "run", "storybook"} + return cmd.Run() +} + +func NewHandler(name string, f interface{}, args ...Arg) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + argv := make([]interface{}, len(args)) + q := r.URL.Query() + for i, arg := range args { + argv[i] = arg.Get(q) + } + component, err := executeTemplate(name, f, argv) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + templ.Handler(component).ServeHTTP(w, r) + }) +} + +func executeTemplate(name string, fn interface{}, values []interface{}) (output templ.Component, err error) { + v := reflect.ValueOf(fn) + t := v.Type() + argv := make([]reflect.Value, t.NumIn()) + if len(argv) != len(values) { + err = fmt.Errorf("templ-storybook: component %s expects %d argument, but %d were provided", fn, len(argv), len(values)) + return + } + for i := 0; i < len(argv); i++ { + argv[i] = reflect.ValueOf(values[i]) + } + result := v.Call(argv) + if len(result) != 1 { + err = fmt.Errorf("templ-storybook: function %s must return a templ.Component", name) + return + } + output, ok := result[0].Interface().(templ.Component) + if !ok { + err = fmt.Errorf("templ-storybook: result of function %s is not a templ.Component", name) + return + } + return output, nil +} + +func NewConf(title string, args ...Arg) *Conf { + c := &Conf{ + Title: title, + Parameters: StoryParameters{ + Server: map[string]interface{}{ + "id": title, + }, + }, + Args: NewSortedMap(), + ArgTypes: NewSortedMap(), + Stories: []Story{}, + } + for _, arg := range args { + c.Args.Add(arg.Name, arg.Value) + c.ArgTypes.Add(arg.Name, map[string]interface{}{ + "control": arg.Control, + }) + } + c.AddStory("Default") + return c +} + +func (c *Conf) AddStory(name string, args ...Arg) { + m := NewSortedMap() + for _, arg := range args { + m.Add(arg.Name, arg.Value) + } + c.Stories = append(c.Stories, Story{ + Name: name, + Args: NewSortedMap(), + }) +} + +// Controls for the configuration. +// See https://storybook.js.org/docs/react/essentials/controls +type Arg struct { + Name string + Value interface{} + Control interface{} + Get func(q url.Values) interface{} +} + +func ObjectArg(name string, value interface{}, valuePtr interface{}) Arg { + return Arg{ + Name: name, + Value: value, + Control: "object", + Get: func(q url.Values) interface{} { + json.Unmarshal([]byte(q.Get(name)), valuePtr) + return reflect.Indirect(reflect.ValueOf(valuePtr)).Interface() + }, + } +} + +func TextArg(name, value string) Arg { + return Arg{ + Name: name, + Value: value, + Control: "text", + Get: func(q url.Values) interface{} { + return q.Get(name) + }, + } +} + +func BooleanArg(name string, value bool) Arg { + return Arg{ + Name: name, + Value: value, + Control: "boolean", + Get: func(q url.Values) interface{} { + return q.Get(name) == "true" + }, + } +} + +type IntArgConf struct{ Min, Max, Step *int } + +func IntArg(name string, value int, conf IntArgConf) Arg { + control := map[string]interface{}{ + "type": "number", + } + if conf.Min != nil { + control["min"] = conf.Min + } + if conf.Max != nil { + control["max"] = conf.Max + } + if conf.Step != nil { + control["step"] = conf.Step + } + arg := Arg{ + Name: name, + Value: value, + Control: control, + Get: func(q url.Values) interface{} { + i, _ := strconv.ParseInt(q.Get(name), 10, 64) + return int(i) + }, + } + return arg +} + +func FloatArg(name string, value float64, min, max, step float64) Arg { + return Arg{ + Name: name, + Value: value, + Control: map[string]interface{}{ + "type": "number", + "min": min, + "max": max, + "step": step, + }, + Get: func(q url.Values) interface{} { + i, _ := strconv.ParseFloat(q.Get(name), 64) + return i + }, + } +} + +type Conf struct { + Title string `json:"title"` + Parameters StoryParameters `json:"parameters"` + Args *SortedMap `json:"args"` + ArgTypes *SortedMap `json:"argTypes"` + Stories []Story `json:"stories"` +} + +type StoryParameters struct { + Server map[string]interface{} `json:"server"` +} + +func NewSortedMap() *SortedMap { + return &SortedMap{ + m: new(sync.Mutex), + internal: map[string]interface{}{}, + keys: []string{}, + } +} + +type SortedMap struct { + m *sync.Mutex + internal map[string]interface{} + keys []string +} + +func (sm *SortedMap) Add(key string, value interface{}) { + sm.m.Lock() + defer sm.m.Unlock() + sm.keys = append(sm.keys, key) + sm.internal[key] = value +} + +func (sm *SortedMap) MarshalJSON() ([]byte, error) { + sm.m.Lock() + defer sm.m.Unlock() + b := new(bytes.Buffer) + b.WriteRune('{') + enc := json.NewEncoder(b) + for i, k := range sm.keys { + enc.Encode(k) + b.WriteRune(':') + enc.Encode(sm.internal[k]) + if i < len(sm.keys)-1 { + b.WriteRune(',') + } + } + b.WriteRune('}') + return b.Bytes(), nil +} + +type Story struct { + Name string `json:"name"` + Args *SortedMap +}