541 lines
13 KiB
Go
541 lines
13 KiB
Go
package storybook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"golang.org/x/mod/sumdb/dirhash"
|
|
|
|
_ "embed"
|
|
"log/slog"
|
|
|
|
"github.com/a-h/templ"
|
|
"github.com/rs/cors"
|
|
)
|
|
|
|
type Storybook struct {
|
|
// Path to the storybook-server directory, defaults to ./storybook-server.
|
|
Path string
|
|
// RoutePrefix is the prefix of HTTP routes, e.g. /prod/
|
|
RoutePrefix string
|
|
// Config of the Stories.
|
|
Config map[string]*Conf
|
|
// Handlers for each of the components.
|
|
Handlers map[string]http.Handler
|
|
// Handler used to serve Storybook, defaults to filesystem at ./storybook-server/storybook-static.
|
|
StaticHandler http.Handler
|
|
Header string
|
|
Server http.Server
|
|
Log *slog.Logger
|
|
AdditionalPrefixJS string
|
|
}
|
|
|
|
type StorybookConfig func(*Storybook)
|
|
|
|
func WithServerAddr(addr string) StorybookConfig {
|
|
return func(sb *Storybook) {
|
|
sb.Server.Addr = addr
|
|
}
|
|
}
|
|
|
|
func WithHeader(header string) StorybookConfig {
|
|
return func(s *Storybook) {
|
|
s.Header = header
|
|
}
|
|
}
|
|
|
|
func WithPath(path string) StorybookConfig {
|
|
return func(sb *Storybook) {
|
|
sb.Path = path
|
|
}
|
|
}
|
|
|
|
// WithAdditionalPreviewJS / WithAdditionalPreviewJS allows to add content to the generated .storybook/preview.js file.
|
|
// For example this can be used to include custom CSS.
|
|
func WithAdditionalPreviewJS(content string) StorybookConfig {
|
|
return func(sb *Storybook) {
|
|
sb.AdditionalPrefixJS = content
|
|
}
|
|
}
|
|
|
|
func New(conf ...StorybookConfig) *Storybook {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
|
sh := &Storybook{
|
|
Path: "./storybook-server",
|
|
Config: map[string]*Conf{},
|
|
Handlers: map[string]http.Handler{},
|
|
Log: logger,
|
|
}
|
|
sh.Server = http.Server{
|
|
Handler: sh,
|
|
Addr: ":60606",
|
|
}
|
|
for _, sc := range conf {
|
|
sc(sh)
|
|
}
|
|
|
|
// Depends on the correct Path, so must be set after additional config
|
|
sh.StaticHandler = http.FileServer(http.Dir(path.Join(sh.Path, "storybook-static")))
|
|
|
|
return sh
|
|
}
|
|
|
|
func (sh *Storybook) AddComponent(name string, componentConstructor any, args ...Arg) *Conf {
|
|
//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 c
|
|
}
|
|
|
|
func (sh *Storybook) Build(ctx context.Context) (err error) {
|
|
// 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.")
|
|
configHasChanged, err := sh.configureStorybook()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
// Execute a static build of storybook if the config has changed.
|
|
if configHasChanged {
|
|
sh.Log.Info("Config not present, or has changed, rebuilding storybook.")
|
|
err = sh.buildStorybook()
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else {
|
|
sh.Log.Info("Storybook is up-to-date, skipping build step.")
|
|
}
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (sh *Storybook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
sbh := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, path.Join(sh.RoutePrefix, "/storybook_preview/")) {
|
|
sh.previewHandler(w, r)
|
|
return
|
|
}
|
|
sh.StaticHandler.ServeHTTP(w, r)
|
|
})
|
|
cors.Default().Handler(sbh).ServeHTTP(w, r)
|
|
}
|
|
|
|
func (sh *Storybook) ListenAndServeWithContext(ctx context.Context) (err error) {
|
|
err = sh.Build(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
sh.Log.Info("Starting Go server", slog.String("address", sh.Server.Addr))
|
|
err = sh.Server.ListenAndServe()
|
|
}()
|
|
<-ctx.Done()
|
|
// Close the Go server.
|
|
sh.Server.Close()
|
|
return err
|
|
}
|
|
|
|
func (sh *Storybook) previewHandler(w http.ResponseWriter, r *http.Request) {
|
|
prefix := path.Join(sh.RoutePrefix, "/storybook_preview/")
|
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
|
sh.Log.Warn("URL does not match preview prefix", slog.String("url", r.URL.String()))
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
name, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, prefix))
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to unescape URL: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if name == "" {
|
|
sh.Log.Warn("URL does not contain component name", slog.String("url", r.URL.String()))
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
name = strings.TrimPrefix(name, "/")
|
|
|
|
h, found := sh.Handlers[name]
|
|
if !found {
|
|
sh.Log.Info("Component name not found", slog.String("name", name), slog.String("url", r.URL.String()), slog.Any("available", keysOfMap(sh.Handlers)))
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h.ServeHTTP(w, r)
|
|
}
|
|
|
|
func keysOfMap[K comparable, V any](handler map[K]V) (keys []K) {
|
|
keys = make([]K, len(handler))
|
|
var i int
|
|
for k := range handler {
|
|
keys[i] = k
|
|
i++
|
|
}
|
|
return keys
|
|
}
|
|
|
|
//go:embed _package.json
|
|
var packageJSON string
|
|
|
|
func (sh *Storybook) installStorybook() (err error) {
|
|
_, err = os.Stat(sh.Path)
|
|
if err == nil {
|
|
sh.Log.Info("Storybook already installed, Skipping installation.")
|
|
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)
|
|
}
|
|
err = os.WriteFile(filepath.Join(sh.Path, "package.json"), []byte(packageJSON), 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("templ-storybook: error writing package.json: %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", "--no-dev"}
|
|
return cmd.Run()
|
|
}
|
|
|
|
func (sh *Storybook) configureStorybook() (configHasChanged bool, err error) {
|
|
// Delete template/existing files in the stories directory.
|
|
storiesDir := filepath.Join(sh.Path, "stories")
|
|
before, err := dirhash.HashDir(storiesDir, "/", dirhash.DefaultHash)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return configHasChanged, err
|
|
}
|
|
if err = os.RemoveAll(storiesDir); err != nil {
|
|
return configHasChanged, err
|
|
}
|
|
if err := os.Mkdir(storiesDir, os.ModePerm); err != nil {
|
|
return configHasChanged, err
|
|
}
|
|
// Create new *.stories.json files.
|
|
for _, c := range sh.Config {
|
|
name := filepath.Join(sh.Path, fmt.Sprintf("stories/%s.stories.json", c.Title))
|
|
f, err := os.Create(name)
|
|
if err != nil {
|
|
return configHasChanged, fmt.Errorf("failed to create config file to %q: %w", name, err)
|
|
}
|
|
err = json.NewEncoder(f).Encode(c)
|
|
if err != nil {
|
|
return configHasChanged, fmt.Errorf("failed to write JSON config to %q: %w", name, err)
|
|
}
|
|
}
|
|
after, err := dirhash.HashDir(storiesDir, "/", dirhash.DefaultHash)
|
|
if err != nil {
|
|
return configHasChanged, fmt.Errorf("failed to hash directory %q: %w", storiesDir, err)
|
|
}
|
|
configHasChanged = before != after
|
|
// Configure storybook Preview URL.
|
|
err = os.WriteFile(filepath.Join(sh.Path, ".storybook/preview.js"), []byte(fmt.Sprintf("%s\n%s", sh.AdditionalPrefixJS, previewJS)), os.ModePerm)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Configure preview-head.html
|
|
err = os.WriteFile(filepath.Join(sh.Path, ".storybook/preview-head.html"), []byte(sh.Header), os.ModePerm)
|
|
return
|
|
}
|
|
|
|
var previewJS = `
|
|
// Customise fetch so that it uses a relative URL.
|
|
const fetchStoryHtml = async (url, path, params, context) => {
|
|
const qs = new URLSearchParams(params);
|
|
const response = await fetch("/storybook_preview/" + path + "?" + qs.toString());
|
|
return response.text();
|
|
};
|
|
|
|
export const parameters = {
|
|
server: {
|
|
url: "http://localhost/storybook_preview", // Ignored by fetchStoryHtml.
|
|
fetchStoryHtml,
|
|
},
|
|
};
|
|
`
|
|
|
|
func (sh *Storybook) buildStorybook() (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", "build-storybook"}
|
|
return cmd.Run()
|
|
}
|
|
|
|
func NewHandler(name string, f any, args ...Arg) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
argv := make([]any, 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 any, values []any) (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]any{
|
|
"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]any{
|
|
"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: m,
|
|
})
|
|
}
|
|
|
|
// Controls for the configuration.
|
|
// See https://storybook.js.org/docs/react/essentials/controls
|
|
type Arg struct {
|
|
Name string
|
|
Value any
|
|
Control any
|
|
Get func(q url.Values) any
|
|
}
|
|
|
|
func ObjectArg(name string, value any, valuePtr any) Arg {
|
|
return Arg{
|
|
Name: name,
|
|
Value: value,
|
|
Control: "object",
|
|
Get: func(q url.Values) any {
|
|
err := json.Unmarshal([]byte(q.Get(name)), valuePtr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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) any {
|
|
return q.Get(name)
|
|
},
|
|
}
|
|
}
|
|
|
|
func BooleanArg(name string, value bool) Arg {
|
|
return Arg{
|
|
Name: name,
|
|
Value: value,
|
|
Control: "boolean",
|
|
Get: func(q url.Values) any {
|
|
return q.Get(name) == "true"
|
|
},
|
|
}
|
|
}
|
|
|
|
type IntArgConf struct{ Min, Max, Step *int }
|
|
|
|
func IntArg(name string, value int, conf IntArgConf) Arg {
|
|
control := map[string]any{
|
|
"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) any {
|
|
i64, err := strconv.ParseInt(q.Get(name), 10, 64)
|
|
if err != nil || i64 < math.MinInt || i64 > math.MaxInt {
|
|
return 0
|
|
}
|
|
return int(i64)
|
|
},
|
|
}
|
|
return arg
|
|
}
|
|
|
|
func FloatArg(name string, value float64, min, max, step float64) Arg {
|
|
return Arg{
|
|
Name: name,
|
|
Value: value,
|
|
Control: map[string]any{
|
|
"type": "number",
|
|
"min": min,
|
|
"max": max,
|
|
"step": step,
|
|
},
|
|
Get: func(q url.Values) any {
|
|
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]any `json:"server"`
|
|
}
|
|
|
|
func NewSortedMap() *SortedMap {
|
|
return &SortedMap{
|
|
m: new(sync.Mutex),
|
|
internal: map[string]any{},
|
|
keys: []string{},
|
|
}
|
|
}
|
|
|
|
type SortedMap struct {
|
|
m *sync.Mutex
|
|
internal map[string]any
|
|
keys []string
|
|
}
|
|
|
|
func (sm *SortedMap) Add(key string, value any) {
|
|
sm.m.Lock()
|
|
defer sm.m.Unlock()
|
|
sm.keys = append(sm.keys, key)
|
|
sm.internal[key] = value
|
|
}
|
|
|
|
func (sm *SortedMap) MarshalJSON() (output []byte, err error) {
|
|
sm.m.Lock()
|
|
defer sm.m.Unlock()
|
|
b := new(bytes.Buffer)
|
|
b.WriteRune('{')
|
|
enc := json.NewEncoder(b)
|
|
for i, k := range sm.keys {
|
|
err = enc.Encode(k)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, err = b.WriteRune(':')
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = enc.Encode(sm.internal[k])
|
|
if err != nil {
|
|
return
|
|
}
|
|
if i < len(sm.keys)-1 {
|
|
_, err = b.WriteRune(',')
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
b.WriteRune('}')
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
type Story struct {
|
|
Name string `json:"name"`
|
|
Args *SortedMap `json:"args"`
|
|
}
|