readysite / docs / examples.md
10.5 KB
examples.md

Examples & Recipes

Practical patterns for building with ReadySite.

Minimal Application

package main

import (
    "embed"
    "github.com/readysite/readysite/pkg/application"
)

//go:embed views
var views embed.FS

func main() {
    application.Serve(views,
        application.WithController(Home()),
    )
}

func Home() (string, *HomeController) {
    return "home", &HomeController{}
}

type HomeController struct {
    application.BaseController
}

func (c *HomeController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    http.Handle("GET /", app.Serve("index.html", nil))
}

func (c HomeController) Handle(r *http.Request) application.Controller {
    c.Request = r
    return &c
}

func (c *HomeController) Message() string {
    return "Hello, World!"
}
<!-- views/layouts/base.html -->
<!DOCTYPE html>
<html><body>{{block "content" .}}{{end}}</body></html>

<!-- views/index.html -->
{{template "base.html" .}}
{{define "content"}}
<h1>{{home.Message}}</h1>
{{end}}

Blog with Collections

Models

// models/db.go
package models

import (
    "github.com/readysite/readysite/pkg/database"
    "github.com/readysite/readysite/pkg/database/engines"
)

var (
    DB    = engines.NewAuto()
    Posts = database.Manage(DB, new(Post),
        database.WithIndex[Post]("Published"),
    )
)

// models/post.go
type Post struct {
    database.Model
    Title     string
    Slug      string
    Content   string
    Published bool
}

Controller

func Blog() (string, *BlogController) {
    return "blog", &BlogController{}
}

type BlogController struct {
    application.BaseController
}

func (c *BlogController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    http.Handle("GET /blog", app.Serve("blog/index.html", nil))
    http.Handle("GET /blog/{slug}", app.Serve("blog/post.html", nil))
    http.Handle("POST /blog", app.Method(c, "Create", RequireAuth))
}

func (c BlogController) Handle(r *http.Request) application.Controller {
    c.Request = r
    return &c
}

// Template: {{blog.Posts}}
func (c *BlogController) Posts() []*models.Post {
    posts, _ := models.Posts.Search("WHERE Published = ? ORDER BY CreatedAt DESC", true)
    return posts
}

// Template: {{blog.Post}}
func (c *BlogController) Post() *models.Post {
    slug := c.PathValue("slug")
    post, _ := models.Posts.First("WHERE Slug = ? AND Published = ?", slug, true)
    return post
}

func (c *BlogController) Create(w http.ResponseWriter, r *http.Request) {
    post := &models.Post{
        Title:     r.FormValue("title"),
        Slug:      r.FormValue("slug"),
        Content:   r.FormValue("content"),
        Published: r.FormValue("published") == "on",
    }
    if _, err := models.Posts.Insert(post); err != nil {
        c.RenderError(w, r, err)
        return
    }
    c.Redirect(w, r, "/blog/"+post.Slug)
}

Templates

<!-- views/blog/index.html -->
{{template "base.html" .}}
{{define "content"}}
<h1>Blog</h1>
{{range $post := blog.Posts}}
    <article>
        <h2><a href="/blog/{{$post.Slug}}">{{$post.Title}}</a></h2>
        <time>{{$post.CreatedAt.Format "Jan 2, 2006"}}</time>
    </article>
{{else}}
    <p>No posts yet.</p>
{{end}}
{{end}}

<!-- views/blog/post.html -->
{{template "base.html" .}}
{{define "content"}}
{{with blog.Post}}
    <article>
        <h1>{{.Title}}</h1>
        <div>{{.Content}}</div>
    </article>
{{end}}
{{end}}

Authentication with Bouncers

Route Guard

func RequireAuth(app *application.App, w http.ResponseWriter, r *http.Request) bool {
    user := GetCurrentUser(r)
    if user == nil {
        http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusSeeOther)
        return false
    }
    return true
}

func RequireAdmin(app *application.App, w http.ResponseWriter, r *http.Request) bool {
    user := GetCurrentUser(r)
    if user == nil || user.Role != "admin" {
        http.Error(w, "Forbidden", http.StatusForbidden)
        return false
    }
    return true
}

Protected Routes

func (c *AdminController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    // Public route
    http.Handle("GET /signin", app.Serve("auth/signin.html", nil))

    // Requires authentication
    http.Handle("GET /dashboard", app.Serve("dashboard.html", RequireAuth))

    // Requires admin role
    http.Handle("GET /admin/users", app.Serve("admin/users.html", RequireAdmin))
    http.Handle("DELETE /admin/users/{id}", app.Method(c, "DeleteUser", RequireAdmin))
}

REST API Endpoint

func API() (string, *APIController) {
    return "api", &APIController{}
}

type APIController struct {
    application.BaseController
}

func (c *APIController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    http.Handle("GET /api/posts", app.Method(c, "List", nil))
    http.Handle("GET /api/posts/{id}", app.Method(c, "Get", nil))
    http.Handle("POST /api/posts", app.Method(c, "Create", RequireAuth))
}

func (c APIController) Handle(r *http.Request) application.Controller {
    c.Request = r
    return &c
}

func (c *APIController) List(w http.ResponseWriter, r *http.Request) {
    posts, err := models.Posts.All()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(posts)
}

func (c *APIController) Get(w http.ResponseWriter, r *http.Request) {
    post, err := models.Posts.Get(r.PathValue("id"))
    if err != nil {
        http.Error(w, "Not found", http.StatusNotFound)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(post)
}

func (c *APIController) Create(w http.ResponseWriter, r *http.Request) {
    var post models.Post
    if err := json.NewDecoder(r.Body).Decode(&post); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if _, err := models.Posts.Insert(&post); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(post)
}

Real-Time Updates with SSE

Server

func (c *FeedController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    http.Handle("GET /feed", app.Serve("feed.html", nil))
    http.Handle("GET /api/feed/stream", app.Method(c, "Stream", nil))
    http.Handle("POST /api/feed/post", app.Method(c, "Post", RequireAuth))
}

var broadcast = make(chan *models.Post, 100)

func (c *FeedController) Stream(w http.ResponseWriter, r *http.Request) {
    stream := c.Stream(w)
    for {
        select {
        case <-r.Context().Done():
            return
        case post := <-broadcast:
            stream.Render("new-post", "partials/post-card.html", post)
        }
    }
}

func (c *FeedController) Post(w http.ResponseWriter, r *http.Request) {
    post := &models.Post{
        Title:   r.FormValue("title"),
        Content: r.FormValue("content"),
    }
    models.Posts.Insert(post)
    broadcast <- post
    c.Refresh(w, r)
}

Client

<!-- views/feed.html -->
{{template "base.html" .}}
{{define "content"}}
<div hx-ext="sse" sse-connect="/api/feed/stream">
    <div id="feed" sse-swap="new-post" hx-swap="afterbegin">
        <!-- New posts appear here -->
    </div>
</div>

<form hx-post="/api/feed/post" hx-swap="none">
    <input name="title" placeholder="Title" required>
    <textarea name="content" placeholder="What's on your mind?"></textarea>
    <button type="submit">Post</button>
</form>
{{end}}

<!-- views/partials/post-card.html -->
<div class="card">
    <h3>{{.Title}}</h3>
    <p>{{.Content}}</p>
</div>

React Island

For rich interactivity, use React islands alongside HTMX:

Setup

// main.go
application.Serve(views,
    frontend.WithBundler(&esbuild.Config{
        Entry:   "frontend/index.js",
        Include: []string{"frontend"},
    }),
    application.WithController(Home()),
)

Component

// frontend/components/Counter.jsx
import React, { useState } from 'react'

export function Counter({ initial = 0 }) {
    const [count, setCount] = useState(initial)
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(c => c + 1)}>+</button>
            <button onClick={() => setCount(c => c - 1)}>-</button>
        </div>
    )
}

Registration

// frontend/index.js
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import { Counter } from './components/Counter'

// Render function - mounts a component to an element
export function render(el, Component, props) {
    const root = ReactDOM.createRoot(el)
    root.render(React.createElement(Component, props))
    el._root = root
}

// Unmount function - unmounts a component from an element
export function unmount(el) {
    if (el._root) {
        el._root.unmount()
        delete el._root
    }
}

// Export components
export { Counter }

Usage in Template

{{render "Counter" (dict "initial" 5)}}

Use HTMX for navigation and forms. Use React islands for complex interactive components like editors, charts, and dashboards.

Pagination

func (c *BlogController) Posts() []*models.Post {
    page, _ := strconv.Atoi(c.QueryParam("page", "1"))
    limit := 10
    offset := (page - 1) * limit

    posts, _ := models.Posts.Search(
        "WHERE Published = ? ORDER BY CreatedAt DESC LIMIT ? OFFSET ?",
        true, limit, offset,
    )
    return posts
}

func (c *BlogController) PageCount() int {
    count, _ := models.Posts.Count("WHERE Published = ?", true)
    return (count + 9) / 10  // ceil division
}
{{range $i := seq 1 blog.PageCount}}
    <a href="/blog?page={{$i}}" class="btn btn-sm">{{$i}}</a>
{{end}}

Email Notifications

// main.go
//go:embed all:emails
var emails embed.FS

func main() {
    var emailer application.Emailer
    if key := os.Getenv("RESEND_API_KEY"); key != "" {
        emailer = emailers.NewResend(emails, key, "App <noreply@example.com>", nil)
    } else {
        emailer = emailers.NewLogger(emails, nil)  // Logs to stdout in dev
    }

    application.Serve(views,
        application.WithEmailer(emailer),
    )
}
// In a controller
c.App.Emailer.Send(user.Email, "Welcome!", "welcome.html", map[string]any{
    "user": user,
    "year": time.Now().Year(),
})
<!-- emails/welcome.html -->
<h1>Welcome, {{.user.Name}}!</h1>
<p>Thanks for signing up.</p>
← Back