learnlytics-go/templ/storybook/storybook.go
2025-03-20 12:35:13 +01:00

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"`
}