package main import ( "encoding/json" "flag" "fmt" "html/template" "io" "log" "net/http" "os" "path/filepath" "strconv" "sync" ) type Card struct { ID string `json:"id"` Title string `json:"title"` } type Column struct { ID int `json:"id"` Label string `json:"label"` Cards []Card `json:"cards"` } type Item struct { ID string `json:"id"` Label string `json:"label"` } type DemoModel struct { Columns []Column `json:"columns"` List []Item `json:"list"` } var ( modelMu sync.Mutex model DemoModel pageTemplate = template.Must(template.New("page").Parse(` Rocket kit spike

Rocket kit spike

Drag cards, use the Kanban keyboard defaults, or reorder the list.

{{template "demo" .}} {{define "demo"}}

Kanban

{{range .Columns}}

{{.Label}}

{{range .Cards}}
{{end}}
{{end}}

Sortable list

{{range .List}}
{{.Label}}
{{end}}
{{end}} `)) ) func main() { addr := flag.String("addr", ":3035", "HTTP listen address") bundle := flag.String("bundle", "../../dist/rocket-kit.js", "vendored Rocket bundle") datastar := flag.String("datastar", "../../public/js/datastar-rocket.js", "Datastar + Rocket bundle") fixture := flag.String("fixture", "../hono-datastar/fixture.json", "shared demo fixture") css := flag.String("css", "../hono-datastar/demo.css", "shared demo stylesheet") flag.Parse() loaded, err := loadModel(*fixture) if err != nil { log.Fatal(err) } model = loaded mux := http.NewServeMux() mux.HandleFunc("GET /", page) mux.HandleFunc("POST /move", move) mux.HandleFunc("POST /list-move", listMove) mux.Handle("GET /rocket-kit.js", staticFile(*bundle, "text/javascript; charset=utf-8")) mux.Handle("GET /js/datastar-rocket.js", staticFile(*datastar, "text/javascript; charset=utf-8")) mux.Handle("GET /demo.css", staticFile(*css, "text/css; charset=utf-8")) log.Printf("Go Rocket kit demo: http://localhost%s", *addr) log.Fatal(http.ListenAndServe(*addr, mux)) } func loadModel(path string) (DemoModel, error) { contents, err := os.ReadFile(path) if err != nil { return DemoModel{}, fmt.Errorf("read fixture %q: %w", path, err) } var result DemoModel if err := json.Unmarshal(contents, &result); err != nil { return DemoModel{}, fmt.Errorf("parse fixture %q: %w", path, err) } return result, nil } func page(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } modelMu.Lock() defer modelMu.Unlock() if err := pageTemplate.Execute(w, model); err != nil { log.Printf("render page: %v", err) } } func move(w http.ResponseWriter, r *http.Request) { payload, err := readSignals(r.Body) if err != nil { http.Error(w, "invalid signals", http.StatusUnprocessableEntity) return } cardID, _ := payload["cardId"].(string) col := number(payload["col"]) before, _ := payload["before"].(string) if cardID == "" || col < 0 { http.Error(w, "invalid move", http.StatusUnprocessableEntity) return } modelMu.Lock() applyMove(&model, cardID, col, before) markup, renderErr := renderDemo(model) modelMu.Unlock() if renderErr != nil { http.Error(w, "render failed", http.StatusInternalServerError) return } writePatch(w, "#rocket-demo", markup) } func listMove(w http.ResponseWriter, r *http.Request) { payload, err := readSignals(r.Body) if err != nil { http.Error(w, "invalid signals", http.StatusUnprocessableEntity) return } itemID, _ := payload["itemId"].(string) before, _ := payload["before"].(string) if itemID == "" { http.Error(w, "invalid list move", http.StatusUnprocessableEntity) return } modelMu.Lock() applyListMove(&model.List, itemID, before) markup, renderErr := renderDemo(model) modelMu.Unlock() if renderErr != nil { http.Error(w, "render failed", http.StatusInternalServerError) return } writePatch(w, "#rocket-demo", markup) } func readSignals(body io.Reader) (map[string]any, error) { var payload map[string]any if err := json.NewDecoder(body).Decode(&payload); err != nil { return nil, err } if nested, ok := payload["signals"].(map[string]any); ok { return nested, nil } return payload, nil } func number(value any) int { switch value := value.(type) { case float64: return int(value) case json.Number: parsed, _ := strconv.Atoi(string(value)) return parsed default: return -1 } } func applyMove(model *DemoModel, cardID string, col int, before string) { targetIndex := -1 for index, column := range model.Columns { if column.ID == col { targetIndex = index break } } if targetIndex < 0 { return } if before == cardID { return } if before != "" { foundBefore := false for _, candidate := range model.Columns[targetIndex].Cards { if candidate.ID == before { foundBefore = true break } } if !foundBefore { return } } var card Card found := false for columnIndex := range model.Columns { cards := model.Columns[columnIndex].Cards[:0] for _, candidate := range model.Columns[columnIndex].Cards { if candidate.ID == cardID { card = candidate found = true continue } cards = append(cards, candidate) } model.Columns[columnIndex].Cards = cards } if !found { return } target := &model.Columns[targetIndex].Cards index := len(*target) for candidateIndex, candidate := range *target { if candidate.ID == before { index = candidateIndex break } } *target = append(*target, Card{}) copy((*target)[index+1:], (*target)[index:]) (*target)[index] = card } func applyListMove(items *[]Item, itemID string, before string) { var item Item found := false remaining := (*items)[:0] for _, candidate := range *items { if candidate.ID == itemID { item = candidate found = true continue } remaining = append(remaining, candidate) } if !found { return } index := len(remaining) for candidateIndex, candidate := range remaining { if candidate.ID == before { index = candidateIndex break } } remaining = append(remaining, Item{}) copy(remaining[index+1:], remaining[index:]) remaining[index] = item *items = remaining } func renderDemo(value DemoModel) (string, error) { var output []byte writer := sliceWriter{output: &output} if err := pageTemplate.ExecuteTemplate(writer, "demo", value); err != nil { return "", err } return string(output), nil } type sliceWriter struct { output *[]byte } func (w sliceWriter) Write(value []byte) (int, error) { *w.output = append(*w.output, value...) return len(value), nil } func writePatch(w http.ResponseWriter, selector string, markup string) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") fmt.Fprintln(w, "event: datastar-patch-elements") fmt.Fprintf(w, "data: selector %s\n", selector) fmt.Fprintln(w, "data: mode outer") for _, line := range splitLines(markup) { fmt.Fprintf(w, "data: elements %s\n", line) } fmt.Fprintln(w) } func splitLines(value string) []string { var lines []string for len(value) > 0 { index := 0 for index < len(value) && value[index] != '\n' { index++ } lines = append(lines, value[:index]) if index == len(value) { break } value = value[index+1:] } return lines } func staticFile(path string, contentType string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", contentType) http.ServeFile(w, r, filepath.Clean(path)) }) }