forge

package module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 3, 2026 License: AGPL-3.0 Imports: 34 Imported by: 1

README

Forge

The Go web framework designed for how you actually think.
Built for developers. Optimized for AI. Zero compromises on readability.

Go Reference v1.4.0 — stable. All exported symbols are stable. No breaking changes without a major version bump. See CHANGELOG.md.

app := forge.New(forge.Config{
    BaseURL: "https://mysite.com",
    Secret:  []byte(os.Getenv("SECRET")),
})

app.Use(
    forge.RequestLogger(),
    forge.Recoverer(),
    forge.SecurityHeaders(),
)

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.Auth(
        forge.Read(forge.Guest),
        forge.Write(forge.Author),
        forge.Delete(forge.Editor),
    ),
    forge.Cache(5*time.Minute),
    forge.Social(forge.OpenGraph, forge.TwitterCard),
    forge.AIIndex(forge.LLMsTxt, forge.AIDoc),
    forge.Templates("templates/posts"),
)

app.Run(":8080")

One block. A complete, production-ready content module with authentication, role-based access, SEO, social sharing, AI indexing, caching, and HTML templates.

Why Forge?

Most frameworks make you learn the framework.
Forge makes you express your intent — and handles the rest.

The guarantee: Forge makes it impossible to do the common things wrong. Draft content never leaks. Cookies are never set without consent. SEO is never missing. AI crawlers always get clean data.

Forge Echo Gin Chi
Zero dependencies ~
Content lifecycle built-in
Draft-safe by default
SEO + structured data
AI indexing (llms.txt + AIDoc)
Cookie compliance built-in
Social sharing built-in
Role hierarchy built-in
AI-native endpoints (llms.txt, AIDoc)

Contents


Installation

go get github.com/forge-cms/forge

Requires Go 1.22+. No other dependencies.


Getting started

Five minutes from go get to a running content API.

go get github.com/forge-cms/forge

1. Define a content type

type Post struct {
    forge.Node
    Title string `forge:"required" json:"title"`
    Body  string `forge:"required,min=50" json:"body"`
}

func (p *Post) Head() forge.Head {
    return forge.Head{
        Title:       p.Title,
        Description: forge.Excerpt(p.Body, 160),
        Canonical:   forge.URL("/posts/", p.Slug),
    }
}

2. Wire it up

app := forge.New(forge.Config{
    BaseURL: "https://mysite.com",
    Secret:  []byte(os.Getenv("SECRET")),
})

app.Content(&Post{},
    forge.At("/posts"),
    forge.Auth(
        forge.Read(forge.Guest),
        forge.Write(forge.Author),
        forge.Delete(forge.Editor),
    ),
)

app.Run(":8080")

3. You have:

  • GET /posts — list published posts (JSON or HTML)
  • GET /posts/{slug} — single post
  • POST /posts — create (Author+)
  • PUT /posts/{slug} — update (Author+)
  • DELETE /posts/{slug} — delete (Author+)
  • GET /posts/sitemap.xml — auto-generated, always fresh
  • Draft posts never visible to unauthenticated requests

No boilerplate. No route registration. No sitemap library.


Core concepts

Forge has six concepts. Learn them once, apply them everywhere.

Node      →  the base every content type embeds
Module    →  one content type, fully wired
Signal    →  a hook that fires when something changes
Head      →  all metadata for a page (SEO + social + AI)
Cookie    →  a declared, typed, compliance-aware browser cookie
Role      →  a position in the access hierarchy

Everything else is just Go.


Content types

Embed forge.Node. Implement Validate() and Head(). That's the contract.

type BlogPost struct {
    forge.Node                                          // ID, Slug, Status, timestamps

    Title  string      `forge:"required"      json:"title"`
    Body   string      `forge:"required,min=50" json:"body"`
    Author string      `forge:"required"      json:"author"`
    Tags   []string    `                      json:"tags,omitempty"`
    Cover  forge.Image `                      json:"cover,omitempty"`
}

// Validate runs after struct-tag validation.
// Use it for rules that tags cannot express.
func (p *BlogPost) Validate() error {
    if p.Status == forge.Published && len(p.Tags) == 0 {
        return forge.Err("tags", "required when publishing")
    }
    return nil
}

// Head returns all metadata for this content's page.
// Forge uses this for SEO, social sharing, and AI indexing.
func (p *BlogPost) Head() forge.Head {
    return forge.Head{
        Title:       p.Title,
        Description: forge.Excerpt(p.Body, 160),
        Author:      p.Author,
        Tags:        p.Tags,
        Image:       p.Cover,
        Type:        forge.Article,
        Canonical:   forge.URL("/posts/", p.Slug),
        Breadcrumbs: forge.Crumbs(
            forge.Crumb("Home",  "/"),
            forge.Crumb("Posts", "/posts"),
            forge.Crumb(p.Title, "/posts/"+p.Slug),
        ),
    }
}

// Markdown enables AI-friendly content negotiation.
// Accept: text/markdown → returns this. Accept: text/plain → stripped version.
func (p *BlogPost) Markdown() string { return p.Body }
forge.Node — what you always get
type Node struct {
    ID          string        // UUID — internal primary key
    Slug        string        // URL-safe identifier, auto-generated from title
    Status      forge.Status  // Draft | Published | Scheduled | Archived
    PublishedAt time.Time     // zero if not Published
    ScheduledAt *time.Time    // non-nil if Scheduled
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

Slug is auto-generated from the first forge:"required" string field unless you set it explicitly. Renaming a slug is safe — the UUID keeps all internal relations intact.

forge.Image — SEO-aware image type
type Image struct {
    URL    string // absolute or relative
    Alt    string // required for accessibility and SEO
    Width  int    // required for Open Graph
    Height int    // required for Open Graph
}

Lifecycle

Every content type has a lifecycle. Always. It cannot be opted out of — this is what guarantees draft content never leaks to the public, sitemaps, feeds, or AI crawlers.

forge.Draft      // visible to Author+ (own) and Editor+
forge.Published  // publicly visible
forge.Scheduled  // publishes automatically at ScheduledAt
forge.Archived   // hidden from public, preserved in storage
What Forge enforces automatically
Draft Scheduled Archived Published
Public GET 404 404 404
Sitemap
RSS feed
AIDoc / llms.txt
<meta robots> noindex noindex noindex index
Author (own content)
Editor+
Scheduled publishing

Available — the adaptive ticker and automatic Scheduled → Published transition are implemented as of Milestone 8.

Forge runs an internal ticker. No external cron needed.

// Schedule via the API
PUT /posts/my-draft
{
  "status":       "scheduled",
  "scheduled_at": "2025-09-01T09:00:00Z"
}

At scheduled_at, Forge automatically transitions to Published, sets PublishedAt, fires AfterPublish signals, regenerates the sitemap, and adds the item to the RSS feed.


Roles & auth

Built-in role hierarchy
Admin   →  full access including app configuration
Editor  →  create, update, delete any content — sees all drafts
Author  →  create, update own content — sees own drafts
Guest   →  read Published content only (unauthenticated)

Higher roles inherit all permissions below them. forge.Write(forge.Author) means Author, Editor, and Admin.

Custom roles
// Create custom roles inline with the hierarchy builder
moderator := forge.NewRole("moderator", forge.RoleBelow(forge.Editor), forge.RoleAbove(forge.Author))
subscriber := forge.NewRole("subscriber", forge.RoleBelow(forge.Author), forge.RoleAbove(forge.Guest))

// Use anywhere a Role is accepted
app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.Auth(forge.Read(subscriber), forge.Write(moderator)),
)
Auth configuration
// Accept bearer tokens (APIs, mobile clients)
app.Use(forge.Authenticate(forge.BearerHMAC(secret)))

// Accept cookie sessions (browser apps)
app.Use(forge.Authenticate(forge.CookieSession("forge_session", secret)))

// Accept both — first match wins
// Use this for apps that serve both a browser UI and an API
app.Use(forge.Authenticate(forge.AnyAuth(
    forge.BearerHMAC(secret),
    forge.CookieSession("forge_session", secret),
)))

// Generate a signed token
token := forge.SignToken(forge.User{
    ID:    "42",
    Name:  "Alice",
    Roles: []forge.Role{forge.Editor},
}, secret)

When multiple auth methods are configured, Forge tries them in order and uses the first that succeeds. A request with a valid Bearer token and no cookie is authenticated as a bearer user. A request with neither is treated as forge.Guest.

In hooks and handlers
forge.On(forge.BeforeCreate, func(ctx forge.Context, p *BlogPost) error {
    user := ctx.User()              // forge.User{ID, Name, Roles}
    user.HasRole(forge.Editor)      // true if Editor or above
    user.Is(forge.Author)           // true if exactly Author
    return nil
})

SEO & structured data

Define metadata once on your content type. Forge renders it correctly everywhere — HTML head, JSON-LD, sitemap, RSS, and AI endpoints.

Head
func (p *BlogPost) Head() forge.Head {
    return forge.Head{
        Title:       p.Title,
        Description: forge.Excerpt(p.Body, 160),
        Author:      p.Author,
        Published:   p.PublishedAt,
        Modified:    p.UpdatedAt,
        Image:       p.Cover,
        Type:        forge.Article,
        Canonical:   forge.URL("/posts/", p.Slug),
        Breadcrumbs: forge.Crumbs(
            forge.Crumb("Home",  "/"),
            forge.Crumb("Posts", "/posts"),
            forge.Crumb(p.Title, "/posts/"+p.Slug),
        ),
    }
}
Advanced: context-aware head with HeadFunc

When Head() on your content type is not enough — for example, you need request context like the site name, a per-request user preference, or a database lookup — use HeadFunc. It receives the full forge.Context alongside the item. HeadFunc takes priority over Headable when both are present.

// HeadFunc wins over the content type's Head() method when set
app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.HeadFunc(func(ctx forge.Context, p *BlogPost) forge.Head {
        return forge.Head{
            Title: p.Title + " — " + ctx.SiteName(),
        }
    }),
)
Rich result types
forge.Article       // blog posts, news articles
forge.Product       // e-commerce products
forge.FAQPage       // FAQ pages
forge.HowTo         // step-by-step guides
forge.Event         // events with dates and locations
forge.Recipe        // recipes with ingredients
forge.Review        // reviews with ratings
forge.Organization  // company / about pages
Sitemap
app.SEO(forge.SitemapConfig{
    ChangeFreq: forge.Weekly,
    Priority:   0.8,
})

Each module owns its fragment (e.g. /posts/sitemap.xml). Forge merges all fragments into /sitemap.xml automatically. Sitemaps regenerate on every publish/unpublish — never stale, never on-demand.

Robots
app.SEO(forge.RobotsConfig{
    Disallow:  []string{"/admin"},
    Sitemaps:  true,
    AIScraper: forge.AskFirst,  // respectful AI crawler policy
})

AI indexing

Available/llms.txt, AIDoc endpoints, and content negotiation for AI agents.

Forge is the first Go framework to treat AI indexing as a first-class feature.

llms.txt

Forge generates /llms.txt automatically from all registered modules. Only Published content appears. Regenerated on every publish.

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.AIIndex(forge.LLMsTxt),
)

Enable all three AI endpoints in one call:

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.AIIndex(forge.LLMsTxt, forge.LLMsTxtFull, forge.AIDoc),
)

Override with a custom template by creating templates/llms.txt:

# {{.SiteName}}

> {{.Description}}

## Posts
{{forge_llms_entries .}}
AIDoc format

Every Published content item gets a /{prefix}/{slug}/aidoc endpoint. Designed to be token-efficient and unambiguous for LLMs.

+++aidoc+v1+++
type:     article
id:       019242ab-1234-7890-abcd-ef0123456789
slug:     hello-world
title:    Hello World
author:   Alice
created:  2025-01-15
modified: 2025-03-01
tags:     [intro, welcome]
summary:  A short introduction to this blog.
+++
Full body content here — clean, stripped of HTML.

The format is designed for token efficiency:

  • status is omitted — AIDoc endpoints only serve Published content
  • Dates use YYYY-MM-DD — time and timezone are rarely meaningful for AI consumers
  • Responses are gzip-compressed at the transport layer — no token cost, significant network saving for bulk crawling
  • No binary encoding — LLMs read this directly without preprocessing

+++aidoc+v1+++ allows future evolution without breaking existing parsers.

Content negotiation for AI agents
# JSON (default)
curl /posts/hello-world

# HTML (when templates registered)
curl /posts/hello-world -H "Accept: text/html"

# Clean markdown (requires Markdown() method on content type)
curl /posts/hello-world -H "Accept: text/markdown"

# Clean plain text (always available)
curl /posts/hello-world -H "Accept: text/plain"

No configuration. Forge handles negotiation automatically.


Social sharing

Availableforge.Social(), Open Graph, and Twitter Card rendering.

func (p *BlogPost) Head() forge.Head {
    return forge.Head{
        Title:       p.Title,
        Description: forge.Excerpt(p.Body, 160),
        Image:       p.Cover,  // used for og:image and twitter:image

        // Per-platform overrides (optional)
        Social: forge.SocialOverrides{
            Twitter: forge.TwitterMeta{
                Card:    forge.SummaryLargeImage,
                Creator: "@alice",
            },
        },
    }
}
app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.Social(forge.OpenGraph, forge.TwitterCard),
)

Forge renders in <head>:

<meta property="og:title"               content="Hello World" />
<meta property="og:description"         content="..." />
<meta property="og:image"               content="https://mysite.com/img/cover.jpg" />
<meta property="og:image:width"         content="1200" />
<meta property="og:image:height"        content="630" />
<meta property="og:type"                content="article" />
<meta property="og:url"                 content="https://mysite.com/posts/hello-world" />
<meta property="article:published_time" content="2025-01-15T09:00:00Z" />
<meta property="article:author"         content="Alice" />
<meta property="article:tag"            content="intro" />
<meta name="twitter:card"               content="summary_large_image" />
<meta name="twitter:title"              content="Hello World" />
<meta name="twitter:creator"            content="@alice" />

Cookies & compliance

Available — typed cookie declarations, consent enforcement, and /.well-known/cookies.json are implemented as of Milestone 6.

Forge treats cookies as typed, declared, compliance-aware values. The category determines which API you can use — enforced at compile time. It is architecturally impossible to set a non-necessary cookie without consent handling.

Declaring cookies
var (
    // Necessary — use forge.SetCookie, no consent needed
    SessionCookie = forge.Cookie{
        Name:     "forge_session",
        Category: forge.Necessary,
        Duration: 24 * time.Hour,
        HTTPOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
        Purpose:  "Authenticates the current user session.",
    }

    // Non-necessary — must use forge.SetCookieIfConsented
    PreferenceCookie = forge.Cookie{
        Name:     "forge_prefs",
        Category: forge.Preferences,
        Duration: 365 * 24 * time.Hour,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
        Purpose:  "Remembers theme and language preferences.",
    }
)
Using cookies
// Necessary — always works
forge.SetCookie(w, r, SessionCookie, sessionID)
value, ok := forge.ReadCookie(r, SessionCookie)
forge.ClearCookie(w, SessionCookie)

// Non-necessary — silently skipped if user has not consented
set := forge.SetCookieIfConsented(w, r, PreferenceCookie, "dark-mode")
forge.Necessary    // session auth, CSRF — never requires consent
forge.Preferences  // theme, language — requires consent
forge.Analytics    // page views, funnels — requires consent
forge.Marketing    // ad targeting — requires consent
Compliance manifest
// Default — public (compliance transparency by design)
app.Cookies(SessionCookie, PreferenceCookie)

// Restricted — require Editor+ to read the manifest
app.Cookies(SessionCookie, PreferenceCookie,
    forge.ManifestAuth(forge.Editor),
)

Forge serves a live manifest at GET /.well-known/cookies.json. Any developer or AI agent can audit your cookie compliance with a single request.

{
  "generated": "2025-06-01T00:00:00Z",
  "cookies": [
    {
      "name":     "forge_session",
      "category": "necessary",
      "duration": "24h",
      "purpose":  "Authenticates the current user session.",
      "consent":  false
    },
    {
      "name":     "forge_prefs",
      "category": "preferences",
      "duration": "8760h",
      "purpose":  "Remembers theme and language preferences.",
      "consent":  true
    }
  ]
}

Storage

Forge accepts any database connection that satisfies the forge.DB interface — which *sql.DB and any pgx adapter already implement. You write SQL. Forge handles scanning and mapping.

Forge core has zero dependencies. The driver is always your choice. Performance is the default recommendation — zero-dependency is the alternative.

Choosing a driver

Recommended — pgx via stdlib shim (~1.8× faster than lib/pq)

For most PostgreSQL users. One dependency, near-native pgx speed, compatible with all standard *sql.DB tooling.

import "github.com/jackc/pgx/v5/stdlib"

db := stdlib.OpenDB(connConfig) // *sql.DB backed by pgx
app := forge.New(forge.Config{DB: db, ...})

Maximum performance — native pgx connection pool (~2.5× faster)

For high-throughput production workloads. Uses forge-pgx, a thin adapter that is a separate module from Forge core.

import (
    forgepgx "github.com/forge-cms/forge-pgx"
    "github.com/jackc/pgx/v5/pgxpool"
)

pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
app := forge.New(forge.Config{DB: forgepgx.Wrap(pool), ...})

Zero dependencies — standard database/sql

For SQLite, MySQL, or teams that cannot add any dependency. Swap the driver without changing any other Forge code.

import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3"     // SQLite
    // _ "github.com/go-sql-driver/mysql" // MySQL
    // _ "github.com/lib/pq"             // PostgreSQL (slower than pgx)
)

db, _ := sql.Open("sqlite3", "./mysite.db")
app := forge.New(forge.Config{DB: db, ...})

Switching between all three approaches requires changing exactly one value in forge.Config. Nothing else in your codebase changes.

Querying
// Single item — returns typed result, maps columns to struct fields
post, err := forge.QueryOne[*BlogPost](db,
    "SELECT * FROM posts WHERE slug = $1 AND status = $2",
    slug, forge.Published,
)
if errors.Is(err, forge.ErrNotFound) {
    // no row — use forge.ErrNotFound, not sql.ErrNoRows
}

// List with pagination
opts := forge.ListOptions{Page: 1, PerPage: 20, OrderBy: "published_at", Desc: true}

posts, err := forge.Query[*BlogPost](db,
    "SELECT * FROM posts WHERE status = $1 ORDER BY published_at DESC LIMIT $2 OFFSET $3",
    forge.Published, opts.PerPage, opts.Offset(),
)

Forge maps columns to struct fields by db tag first, then by field name. No ORM. No query builder. SQL is the query language — and AI assistants write it extremely well.

type BlogPost struct {
    forge.Node
    Title  string `forge:"required" db:"title"  json:"title"`
    Body   string `forge:"required" db:"body"   json:"body"`
    Author string `forge:"required" db:"author" json:"author"`
}
// db tag controls column mapping — omit it and Forge uses the field name lowercased
Repository interface

For testing, prototyping, and custom backends:

type Repository[T any] interface {
    FindByID(ctx context.Context, id string) (T, error)
    FindBySlug(ctx context.Context, slug string) (T, error)
    FindAll(ctx context.Context, opts forge.ListOptions) ([]T, error)
    Save(ctx context.Context, node T) error
    Delete(ctx context.Context, id string) error
}

// Zero-config in-memory implementation
repo := forge.NewMemoryRepo[*BlogPost]()
Production SQL repository

AvailableSQLRepo[T] is a production-ready Repository[T] backed by forge.DB, implemented as of Milestone 7.

SQLRepo[T] derives the table name automatically (BlogPostblog_posts) or accepts a Table() override:

// Auto-derived table name: blog_posts
repo := forge.NewSQLRepo[*BlogPost](db)

// Explicit table name
repo := forge.NewSQLRepo[*BlogPost](db, forge.Table("posts"))

// T must be a pointer type — NewSQLRepo[*BlogPost] pairs with NewModule((*BlogPost)(nil), ...)
repo := forge.NewSQLRepo[*BlogPost](db)

m := forge.NewModule((*BlogPost)(nil),
    forge.At("/posts"),
    forge.Repo(repo),
)

app.Content(m)

SQLRepo uses $N positional placeholders (PostgreSQL / pgx compatible) and upserts via ON CONFLICT (id) DO UPDATE.


Middleware

Global
app.Use(
    forge.RequestLogger(),              // structured slog output
    forge.Recoverer(),                  // panic → 500, process never crashes
    forge.CORS("https://mysite.com"),   // CORS headers
    forge.MaxBodySize(1 << 20),         // 1 MB request limit
    forge.RateLimit(100, time.Minute),  // 100 req/min per IP
    forge.SecurityHeaders(),            // HSTS, CSP, X-Frame-Options, Referrer-Policy
)
Per-module
app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.Middleware(
        forge.InMemoryCache(5*time.Minute),                        // LRU, max 1000 entries
    // forge.InMemoryCache(5*time.Minute, forge.CacheMaxEntries(500)), // custom limit
        myCustomMiddleware,
    ),
)
Writing middleware

Standard http.Handler wrapping — no Forge-specific types required:

func myCustomMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := forge.ContextFrom(w, r)  // access forge.Context if needed
        _ = ctx.User()
        next.ServeHTTP(w, r)
    })
}

Templates & rendering

Content negotiation

Forge selects the response format from the Accept header. Register templates to enable HTML. Everything else works automatically.

Accept: application/json  →  JSON (always available)
Accept: text/html         →  HTML template (requires forge.Templates)
Accept: text/markdown     →  raw markdown (requires Markdown() method)
Accept: text/plain        →  clean text (always available)
Template convention
app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.Templates("templates/posts"),        // parsed at startup, fails fast if missing
    // forge.TemplatesOptional("templates/posts"), // no startup failure if missing
    // Forge looks for:
    //   templates/posts/list.html  →  GET /posts
    //   templates/posts/show.html  →  GET /posts/{slug}
)
In templates
{{/* show.html */}}
{{template "forge:head" .}}

<article>
    <h1>{{.Content.Title}}</h1>
    <p>By {{.Content.Author}} · {{.Content.PublishedAt | forge_date}}</p>
    {{.Content.Body | forge_markdown}}
</article>

{{/* list.html */}}
{{template "forge:head" .}}

{{range .Content}}
<a href="/posts/{{.Slug}}">
    <h2>{{.Title}}</h2>
    <p>{{.Body | forge_excerpt 120}}</p>
</a>
{{end}}

The forge:head partial renders everything in <head> automatically: <title>, <meta>, canonical, Open Graph, Twitter Cards, twitter:site, app-level JSON-LD, JSON-LD, breadcrumbs, and <meta name="robots"> based on content Status.

Template data shape
type TemplateData[T Node] struct {
    Content     T                  // T for show, []T for list
    Head        forge.Head         // from Headable.Head() on T, or HeadFunc if provided (HeadFunc takes priority)
    User        forge.User         // current user (zero value if Guest)
    Request     *http.Request
    OGDefaults  *forge.OGDefaults  // app-level OG/Twitter fallbacks (nil if not configured)
    AppSchema   template.HTML      // pre-rendered app-level JSON-LD block (empty if not configured)
    HeadAssets  *forge.HeadAssets  // preconnect/stylesheets/favicons/scripts (nil if not configured)
}
Shared partials

Available

Define nav, footer, or any shared HTML once and inject it into every module template set — and into custom handler templates — automatically.

app.Partials("templates/partials") // any *.html file in the directory is a partial

Each partial file must use {{define "name"}}...{{end}} syntax (same convention as forge:head). Files are loaded in alphabetical order for determinism.

{{/* templates/partials/nav.html */}}
{{define "nav"}}
<nav><a href="/">Home</a> | <a href="/posts">Blog</a></nav>
{{end}}
{{/* templates/posts/list.html */}}
{{template "nav" .}}
{{range .Content}}<a href="/posts/{{.Slug}}"><h2>{{.Title}}</h2></a>{{end}}

For custom app.Handle() routes that also need shared partials, use App.MustParseTemplate:

// parsed once at startup — includes TemplateFuncMap, forge:head, and all partials
homeTpl := app.MustParseTemplate("templates/home.html")

app.Handle("GET /", func(w http.ResponseWriter, r *http.Request) {
    data := forge.NewTemplateData[any](ctx, nil, forge.Head{Title: "Home"}, "My Site")
    homeTpl.Execute(w, data)
})

MustParseTemplate panics on error (consistent with MustConfig), so misconfigured templates fail at startup rather than at first request.

Custom handler with forge:head

Available

Custom handler data structs can embed forge.PageHead to gain {{template "forge:head" .}} support without using TemplateData[T]:

type homeData struct {
    forge.PageHead        // promotes Head, OGDefaults, AppSchema, HeadAssets
    Posts      []*Post
    Featured   *Post
}

homeTpl := app.MustParseTemplate("templates/home.html")

app.Handle("GET /", func(w http.ResponseWriter, r *http.Request) {
    data := homeData{
        PageHead: forge.PageHead{Head: forge.Head{Title: "Home"}},
        Posts:    loadPosts(),
    }
    homeTpl.Execute(w, data)
})

In templates/home.html:

<head>{{template "forge:head" .}}</head>
<body>
    {{range .Posts}}<h2>{{.Title}}</h2>{{end}}
</body>

PageHead is the same struct that TemplateData[T] embeds internally. Any field on PageHead set by app.SEO(...) (such as HeadAssets) must be populated manually in custom handlers — module templates receive these automatically, but app.Handle() routes are outside the module render path.

Site-wide static assets

Available

Inject preconnect hints, stylesheets, favicon links, and scripts into forge:head on every page via app.SEO:

app.SEO(&forge.HeadAssets{
    Preconnect:  []string{"https://fonts.googleapis.com"},
    Stylesheets: []string{
        "https://fonts.googleapis.com/css2?family=Inter&display=swap",
        "/static/app.css",
    },
    Favicons: []forge.FaviconLink{
        {Rel: "icon", Type: "image/png", Sizes: "32x32", Href: "/favicon-32.png"},
        {Rel: "apple-touch-icon", Href: "/apple-touch-icon.png"},
    },
    Scripts: []forge.ScriptTag{
        {Src: "/static/app.js", Defer: true},
    },
})

Assets are emitted in order: preconnect → stylesheets → favicons → scripts. Inline script bodies use template.JS to opt in to verbatim emission:

forge.ScriptTag{Body: template.JS("console.log('Forge')")} // never pass user input here

Error handling

Forge uses a typed error hierarchy. Every error knows its HTTP status, its machine-readable code, and what is safe to show the client. Internal details are logged — never leaked.

Sentinel errors
forge.ErrNotFound   // 404 — resource does not exist
forge.ErrGone       // 410 — resource existed but was intentionally removed
forge.ErrForbidden  // 403 — authenticated but insufficient role
forge.ErrUnauth     // 401 — not authenticated
forge.ErrConflict   // 409 — state conflict (e.g. duplicate slug)
In hooks and custom handlers
forge.On(forge.BeforeCreate, func(ctx forge.Context, p *BlogPost) error {
    if slugExists(p.Slug) {
        return forge.ErrConflict                   // → 409
    }
    if !ctx.User().HasRole(forge.Editor) {
        return forge.ErrForbidden                  // → 403
    }
    return forge.Err("title", "already taken")     // → 422 with field detail
})
Error responses follow the Accept header
// Accept: application/json
{
  "error": {
    "code":       "validation_failed",
    "message":    "Validation failed",
    "request_id": "019242ab-1234-7890-abcd-ef0123456789",
    "fields": [
      { "field": "title", "message": "required" },
      { "field": "body",  "message": "minimum 50 characters" }
    ]
  }
}

HTML error pages are rendered from templates/errors/{status}.html if present.

Request tracing

Every request gets a X-Request-ID header (UUID v7). The same ID appears in error responses and every structured log entry — making it trivial to trace a user-reported error to an exact log line.

// Available in all hooks and handlers
id := ctx.RequestID()

Redirects & content mobility

Available — manual redirects (app.Redirect), prefix rewrites (Redirects(From(...))), 410 Gone, chain collapse, and /.well-known/redirects.json are implemented as of Milestone 7.

Forge automatically tracks every URL a piece of content has ever had. Rename a slug, change a prefix, archive a post — inbound links and SEO rankings are preserved without any developer effort.

What happens automatically
Event Previous URL response
Slug renamed 301 → new URL
Module prefix changed 301 → new prefix + slug
Node archived 410 Gone
Node deleted 410 Gone
Node drafted / scheduled 404 (does not leak existence)
Why 410 and not 404 for archived content

410 Gone tells search engines the content was intentionally removed. Google de-indexes 410 pages significantly faster than 404 pages. For a CMS, this is almost always what you want.

Manual redirects
// Bulk redirect when renaming a module prefix
app.Content(&BlogPost{},
    forge.At("/articles"),                              // new prefix
    forge.Redirects(forge.From("/posts"), "/articles"), // 301 all /posts/* → /articles/*
)

// One-off redirects
app.Redirect("/old-path",  "/new-path", forge.Permanent) // 301
app.Redirect("/removed",   "",          forge.Gone)       // 410
Optional DB persistence

To persist redirects across restarts, create the forge_redirects table and call Load at startup:

CREATE TABLE forge_redirects (
    from_path TEXT PRIMARY KEY,
    to_path   TEXT NOT NULL DEFAULT '',
    code      INTEGER NOT NULL DEFAULT 301,
    is_prefix BOOLEAN NOT NULL DEFAULT FALSE
);
if err := app.RedirectStore().Load(ctx, db); err != nil {
    log.Fatal(err)
}
Inspect the redirect table
GET /.well-known/redirects.json   (requires Editor+)

MCP integration (forge-mcp)

Available

forge-mcp is a separate module that wraps a forge.App and exposes its content modules to AI assistants via the Model Context Protocol. Schema derivation, lifecycle enforcement, and role checks are all automatic — no configuration beyond forge.MCP(...) on your existing modules.

import forgemcp "github.com/forge-cms/forge/forge-mcp"

func main() {
    app := forge.New(forge.MustConfig(forge.Config{
        BaseURL: "https://mysite.com",
        Secret:  []byte(os.Getenv("SECRET")),
    }))
    app.Content(
        forge.NewModule((*Post)(nil), forge.At("/posts"), forge.MCP(forge.MCPWrite)),
    )
    forgemcp.New(app).ServeStdio(context.Background(), os.Stdin, os.Stdout)
}

For Claude Desktop, Cursor, and SSE remote transport configuration see forge-mcp/README.md.


The AI-first design philosophy

Forge is the first Go framework explicitly designed to be maintained by AI assistants.

Intent over mechanics
forge.SEO(forge.RichArticle) — not 40 lines of JSON-LD template code. An AI assistant reads, modifies, and explains your intent without touching internals.

Declarative over imperative
Every content module is fully described by its app.Content(...) call. No tracing middleware chains. No hunting through files for route registration.

Impossible to get wrong by accident
Draft content cannot leak. Non-necessary cookies cannot be set without consent handling. These are architectural guarantees, not conventions.

Self-describing

GET /.well-known/cookies.json  →  cookie compliance audit
GET /llms.txt                  →  site structure for AI crawlers
GET /posts/hello-world.aidoc   →  token-efficient content for LLMs
GET /sitemap.xml               →  always fresh, event-driven

One right way
One way to declare cookies. One way to handle SEO. One way to register content. AI assistants never guess which pattern you used.

Consistent naming
Every exported symbol: forge.Verb(Noun) or forge.Noun. No abbreviations. No clever names. Predictable, searchable, memorable.


Minimal complete example

package main

import (
    "os"
    "time"

    "github.com/forge-cms/forge"
)

type Article struct {
    forge.Node
    Title  string      `forge:"required"         json:"title"`
    Body   string      `forge:"required,min=100"  json:"body"`
    Author string      `forge:"required"         json:"author"`
    Cover  forge.Image `                          json:"cover,omitempty"`
}

func (a *Article) Validate() error {
    if a.Status == forge.Published && a.Cover.URL == "" {
        return forge.Err("cover", "required when publishing")
    }
    return nil
}

func (a *Article) Head() forge.Head {
    return forge.Head{
        Title:       a.Title,
        Description: forge.Excerpt(a.Body, 160),
        Author:      a.Author,
        Image:       a.Cover,
        Type:        forge.Article,
        Canonical:   forge.URL("/articles/", a.Slug),
    }
}

func (a *Article) Markdown() string { return a.Body }

func main() {
    secret := []byte(os.Getenv("SECRET"))

    app := forge.New(forge.Config{
        BaseURL: "https://mysite.com",
        Secret:  secret,
    })

    app.Use(
        forge.RequestLogger(),
        forge.Recoverer(),
        forge.SecurityHeaders(),
        forge.MaxBodySize(1 << 20),
        forge.Authenticate(forge.AnyAuth(
            forge.BearerHMAC(secret),
            forge.CookieSession("session", secret),
        )),
    )

    app.SEO(forge.SitemapConfig{ChangeFreq: forge.Weekly, Priority: 0.8})
    app.SEO(forge.RobotsConfig{AIScraper: forge.AskFirst})

    app.Content(&Article{},
        forge.At("/articles"),
        forge.Auth(
            forge.Read(forge.Guest),
            forge.Write(forge.Author),
            forge.Delete(forge.Editor),
        ),
        forge.Cache(10*time.Minute),
        forge.Social(forge.OpenGraph, forge.TwitterCard),
        forge.AIIndex(forge.LLMsTxt, forge.AIDoc),
        forge.Templates("templates/articles"),
        forge.On(forge.BeforeCreate, func(ctx forge.Context, a *Article) error {
            a.Author = ctx.User().Name
            return nil
        }),
    )

    app.Run(":8080")
}

~70 lines. What you get:

Full CRUD · Role-based auth · Draft-safe lifecycle
Structured data (JSON-LD) · Event-driven sitemap · Content negotiation
Open Graph · Twitter Cards · AI indexing · RSS feed
Security headers · Graceful shutdown · Cookie compliance manifest
Scheduled publishing


Known issues

Windows: CSS files served as text/plain

Go's MIME type lookup on Windows uses the registry, which may map .css to text/plain. If your browser rejects stylesheets during local development, add this to your main() before starting the server:

import "mime"
mime.AddExtensionType(".css", "text/css")

License

AGPL v3 — free for individuals, open source projects, and companies building their own sites. A commercial license will be available for organisations running Forge as a hosted service. See COMMERCIAL.md.

Documentation

Index

Examples

Constants

View Source
const (
	Article      = "Article"      // blog posts and news articles
	Product      = "Product"      // e-commerce product pages
	FAQPage      = "FAQPage"      // frequently asked questions
	HowTo        = "HowTo"        // step-by-step guides
	Event        = "Event"        // events with dates and locations
	Recipe       = "Recipe"       // recipes with ingredients and steps
	Review       = "Review"       // reviews with star ratings
	Organization = "Organization" // company or about pages
)

Rich result type constants for Head.Type. Each maps to a schema.org type used to generate JSON-LD structured data (see schema.go).

View Source
const CSRFCookieName = "forge_csrf"

CSRFCookieName is the name of the CSRF cookie set by CookieSession. Client-side AJAX code should read this cookie and send its value as the X-CSRF-Token request header on all non-safe methods (POST, PUT, PATCH, DELETE).

Variables

View Source
var (
	// ErrNotFound indicates the requested resource does not exist. → 404
	ErrNotFound = newSentinel(http.StatusNotFound, "not_found", "Not found")

	// ErrGone indicates the resource existed but has been permanently removed. → 410
	ErrGone = newSentinel(http.StatusGone, "gone", "This content has been removed")

	// ErrForbidden indicates the authenticated user lacks permission. → 403
	ErrForbidden = newSentinel(http.StatusForbidden, "forbidden", "Forbidden")

	// ErrUnauth indicates the request requires authentication. → 401
	ErrUnauth = newSentinel(http.StatusUnauthorized, "unauthorized", "Unauthorized")

	// ErrConflict indicates a state conflict (e.g. duplicate slug). → 409
	ErrConflict = newSentinel(http.StatusConflict, "conflict", "Conflict")

	// ErrBadRequest indicates the request is malformed or unparseable. → 400
	ErrBadRequest = newSentinel(http.StatusBadRequest, "bad_request", "Bad request")

	// ErrNotAcceptable indicates the requested content type is not supported. → 406
	ErrNotAcceptable = newSentinel(http.StatusNotAcceptable, "not_acceptable", "Not acceptable")

	// ErrRequestTooLarge indicates the request body exceeds the allowed size. → 413
	ErrRequestTooLarge = newSentinel(http.StatusRequestEntityTooLarge, "request_too_large", "Request too large")

	// ErrTooManyRequests indicates the client has exceeded the rate limit. → 429
	ErrTooManyRequests = newSentinel(http.StatusTooManyRequests, "too_many_requests", "Too many requests")

	// ErrInternal indicates an unexpected server-side error. → 500
	// The public message is intentionally generic; details are logged, not exposed.
	ErrInternal = newSentinel(http.StatusInternalServerError, "internal_server_error", "Internal server error")
)

Sentinel errors for well-known HTTP failure conditions.

View Source
var GuestUser = User{}

GuestUser is the zero-value User representing an unauthenticated request. Forge sets ctx.User() to GuestUser when no authentication middleware has identified the caller.

Functions

func AbsURL added in v1.1.4

func AbsURL(base, path string) string

AbsURL joins a base URL and a path into an absolute URL. It trims any trailing slash from base before joining, so both of the following produce the same result:

forge.AbsURL("https://example.com",  "/posts/my-slug")  →  "https://example.com/posts/my-slug"
forge.AbsURL("https://example.com/", "/posts/my-slug")  →  "https://example.com/posts/my-slug"

The path argument is passed through URL first, so duplicate slashes are collapsed and a leading slash is guaranteed. Use AbsURL in Head() implementations when setting Head.Canonical, Head.Image.URL, or any other field that requires an absolute URL.

func (p *Post) Head() forge.Head {
    return forge.Head{
        Canonical: forge.AbsURL(siteBaseURL, forge.URL("/posts", p.Slug)),
    }
}

func Authenticate

func Authenticate(auth AuthFunc) func(http.Handler) http.Handler

Authenticate returns middleware that runs auth on every request and stores the resulting User in the request context so [Context.User] returns it.

Apply it globally before any module that enforces role checks via Auth, Read, or Write:

app.Use(forge.Authenticate(forge.BearerHMAC(secret)))

Unauthenticated requests — where auth returns false — pass through unchanged. ContextFrom then falls back to GuestUser, which is the correct behaviour for public read endpoints protected by forge.Read(forge.Guest).

Example

ExampleAuthenticate demonstrates wiring bearer token and cookie session auth via AnyAuth so that both APIs and browser clients are supported. The first matching auth method wins on each request.

const secretStr = "example-secret-key-32-bytes!!!!!"
secretBytes := []byte(secretStr)

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  secretBytes,
})
app.Use(Authenticate(AnyAuth(
	BearerHMAC(secretStr),
	CookieSession("session", secretStr),
)))
_ = app.Handler()

func CORS

func CORS(origin string) func(http.Handler) http.Handler

CORS returns middleware that sets cross-origin resource sharing headers allowing requests from origin. On OPTIONS preflight requests it responds with 204 No Content without calling the next handler.

func CSRF

func CSRF(auth AuthFunc) func(http.Handler) http.Handler

CSRF returns middleware that validates the X-CSRF-Token request header against the forge_csrf cookie on non-safe HTTP methods (POST, PUT, PATCH, DELETE). It only activates when auth implements [csrfAware] and CSRF is enabled (i.e. CookieSession without WithoutCSRF).

The middleware also issues a new forge_csrf cookie when none is present, allowing JavaScript clients to read it and send it as X-CSRF-Token.

Apply CSRF after your auth middleware in the global chain or per-module:

app.Use(forge.CSRF(myAuth))

func Chain

func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler

Chain applies a list of middleware to an http.Handler. The first middleware in the slice becomes the outermost wrapper (executed first on each request).

Chain(myHandler, RequestLogger(), Recoverer(), SecurityHeaders())

func ClearCookie

func ClearCookie(w http.ResponseWriter, c Cookie)

ClearCookie expires c immediately by setting MaxAge to -1 and an Expires time in the past.

func ConsentFor

func ConsentFor(r *http.Request, cat CookieCategory) bool

ConsentFor reports whether the request carries consent for the given category. Necessary always returns true regardless of the consent cookie.

func Excerpt

func Excerpt(text string, maxLen int) string

Excerpt returns a plain-text summary truncated at the last word boundary within maxLen characters. A Unicode ellipsis ("…") is appended when the text is truncated. Use it to populate Head.Description.

forge.Excerpt(p.Body, 160)

func GenerateSlug

func GenerateSlug(input string) string

GenerateSlug converts input into a URL-safe slug. The algorithm:

  1. Lowercase (Unicode-aware)
  2. Spaces, hyphens, and underscores become hyphens
  3. All other non-[a-z0-9] bytes are dropped
  4. Consecutive hyphens are collapsed to one
  5. Leading and trailing hyphens are trimmed
  6. Result is truncated to 200 bytes

Returns "untitled" if the result would be empty.

The implementation uses a byte loop — no regexp — to avoid allocations on the hot path.

func GrantConsent

func GrantConsent(w http.ResponseWriter, cats ...CookieCategory)

GrantConsent writes the forge_consent cookie to w with the given categories. Necessary is always implicitly consented and is not stored in the cookie value. Subsequent calls overwrite the previous consent state.

func HasRole

func HasRole(userRoles []Role, required Role) bool

HasRole reports whether any role in userRoles has a level greater than or equal to the level of required. This is a hierarchical check: an Admin satisfies a check for Editor, Author, or Guest.

Unknown roles (not registered) have level 0 and never satisfy any check.

func InMemoryCache

func InMemoryCache(ttl time.Duration, opts ...Option) func(http.Handler) http.Handler

InMemoryCache returns middleware that caches successful GET responses in an LRU cache. Responses are keyed by method + full URL (including query parameters) + Accept header. Every response receives an X-Cache header (HIT or MISS).

Default capacity is 1000 entries. Use CacheMaxEntries to override. A background goroutine sweeps expired entries every 60 seconds.

func IsRole

func IsRole(userRoles []Role, required Role) bool

IsRole reports whether any role in userRoles exactly matches required. Unlike HasRole, this is not hierarchical — Admin does not satisfy Editor.

func MaxBodySize

func MaxBodySize(n int64) func(http.Handler) http.Handler

MaxBodySize returns middleware that limits the size of request bodies to n bytes. Requests exceeding the limit receive a 413 error response.

func NewID

func NewID() string

NewID returns a new UUID v7 string. UUID v7 is time-ordered (48-bit millisecond timestamp) with 74 bits of cryptographic randomness, which keeps B-tree indexes compact while providing the same collision resistance as UUID v4. See Amendment S1.

Panics if crypto/rand is unavailable — this indicates an unrecoverable platform error and should never occur in practice.

func NewRole

func NewRole(name string) roleBuilder

NewRole begins the registration of a custom role. Call [roleBuilder.Above] or [roleBuilder.Below] to position it, then [roleBuilder.Register] to commit it to the role registry.

r, err := forge.NewRole("publisher").Above(forge.Author).Below(forge.Editor).Register()

func Query

func Query[T any](ctx context.Context, db DB, query string, args ...any) ([]T, error)

Query executes a SQL query and scans the result rows into a slice of T. T may be a struct type or a pointer to a struct (e.g. *BlogPost). Columns are matched to fields by db struct tag first, then by lowercased field name. Unrecognised columns are discarded without error. Returns an empty (non-nil) slice when no rows match.

func QueryOne

func QueryOne[T any](ctx context.Context, db DB, query string, args ...any) (T, error)

QueryOne executes a SQL query and returns the first scanned row as T. Returns ErrNotFound when no rows match.

func RateLimit

func RateLimit(n int, d time.Duration, opts ...Option) func(http.Handler) http.Handler

RateLimit returns middleware that enforces a per-IP token bucket rate limit of n requests per duration d. Requests exceeding the limit receive a 429 Too Many Requests response with a Retry-After header.

Pass TrustedProxy when the application runs behind a reverse proxy so that the real client IP is read from X-Real-IP / X-Forwarded-For.

A background goroutine sweeps stale IP buckets every d to bound memory usage.

func ReadCookie

func ReadCookie(r *http.Request, name string) (string, bool)

ReadCookie returns the value of the named cookie from r, and whether it was present. Returns ("", false) when the cookie is absent.

func Recoverer

func Recoverer() func(http.Handler) http.Handler

Recoverer returns middleware that recovers from panics in downstream handlers. On panic it returns a 500 response via WriteError and logs the stack trace. The process is never crashed.

func RequestLogger

func RequestLogger() func(http.Handler) http.Handler

RequestLogger returns middleware that logs each request using structured log/slog output. Fields: method, path, status, duration_ms, request_id.

RequestLogger calls ContextFrom before the next handler, which ensures X-Request-ID is set on the response prior to any downstream code running. It should be the outermost middleware in [app.Use].

func Require

func Require(errs ...error) error

Require collects ValidationError values from errs into a single ValidationError. Nil values are silently skipped. Returns nil if every input is nil. Returns the first non-nil non-ValidationError error unchanged.

return forge.Require(
    forge.Err("title", "required"),
    forge.Err("body",  "minimum 50 characters"),
)

func RevokeConsent

func RevokeConsent(w http.ResponseWriter)

RevokeConsent clears the forge_consent cookie, withdrawing all non-Necessary consent. Subsequent calls to ConsentFor for non-Necessary categories return false until GrantConsent is called again.

func RobotsTxt

func RobotsTxt(cfg RobotsConfig, baseURL string) string

RobotsTxt generates a well-formed robots.txt string from cfg.

The output always begins with a User-agent: * block. If cfg.Disallow contains paths, each becomes a Disallow directive; otherwise an empty Disallow line is emitted (allow all).

When cfg.AIScraper is AskFirst, individual User-agent / Disallow: / blocks are appended for each known AI training crawler, leaving the User-agent: * block permissive. When cfg.AIScraper is Disallow, the same is done for an extended crawler list.

When cfg.Sitemaps is true and baseURL is non-empty, a Sitemap directive is appended at the end pointing to <baseURL>/sitemap.xml.

func RobotsTxtHandler

func RobotsTxtHandler(cfg RobotsConfig, baseURL string) http.HandlerFunc

RobotsTxtHandler returns an http.HandlerFunc that serves the robots.txt content generated from cfg.

The content is generated once at construction time — not per request — so the handler is safe to share across goroutines and incurs no per-request allocation.

Responses carry Content-Type: text/plain; charset=utf-8 and Cache-Control: max-age=86400 (one day).

func RunValidation

func RunValidation(v any) error

RunValidation runs the full validation pipeline on v:

  1. ValidateStruct — struct-tag constraints (required, min, max, email, …)
  2. If tags pass and v implements Validatable, calls v.Validate()

If step 1 fails, step 2 is skipped — the caller receives only the tag errors. This matches Decision 10: "Tag validation runs before Validate(); if tags fail, Validate() is not called."

func SchemaFor

func SchemaFor(head Head, content any) string

SchemaFor generates one or two <script type="application/ld+json"> blocks for the given head and content value.

The primary block is determined by head.Type (Article, Product, FAQPage, HowTo, Event, Recipe, Review, Organization). An empty head.Type returns "". Unknown types return "". Types that require a provider interface (FAQPage, HowTo, Event, Recipe, Review, Organization) return "" when content does not implement the required interface.

A second BreadcrumbList block is appended (separated by "\n") when head.Breadcrumbs is non-empty.

SchemaFor never panics.

func SecurityHeaders

func SecurityHeaders() func(http.Handler) http.Handler

SecurityHeaders returns middleware that sets a standard set of security response headers on every response:

  • Strict-Transport-Security (2-year max-age, includeSubDomains)
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy: default-src 'self'; frame-ancestors 'none'

func SetCookie

func SetCookie(w http.ResponseWriter, c Cookie, value string)

SetCookie writes a Necessary cookie to w.

SetCookie panics if c.Category is not Necessary. This enforces Decision 5 at the point of misuse — before any response is sent in production. For non-Necessary categories use SetCookieIfConsented.

func SetCookieIfConsented

func SetCookieIfConsented(w http.ResponseWriter, r *http.Request, c Cookie, value string) bool

SetCookieIfConsented writes a non-Necessary cookie to w only when the request carries consent for c.Category. Returns true when the cookie was set, false when skipped due to missing consent.

SetCookieIfConsented panics if c.Category is Necessary. Necessary cookies do not require consent and must use SetCookie instead.

func SignToken

func SignToken(user User, secret string, ttl time.Duration) (string, error)

SignToken produces a signed token encoding the given User. Pass the token to the client (e.g. as a JSON response body); validate it later with BearerHMAC or CookieSession.

When ttl > 0 the token contains an expiry timestamp; [decodeToken] rejects tokens whose expiry has passed. Use ttl = 0 for tokens with no expiry.

The token format is: base64url(json(User)) + "." + base64url(hmac-sha256(secret, payload)). Roles are stored as strings for forward compatibility (Decision 15).

func TemplateFuncMap

func TemplateFuncMap() template.FuncMap

TemplateFuncMap returns a template.FuncMap containing all Forge template helper functions. Pass it to template.Template.Funcs before parsing:

tpl := template.New("page").Funcs(forge.TemplateFuncMap())

Available functions:

forge_meta         — JSON-LD <script> block: {{forge_meta .Head .Content}}
forge_date         — formatted date string: {{.PublishedAt | forge_date}}
forge_markdown     — Markdown → HTML: {{.Body | forge_markdown}}
forge_excerpt      — truncated excerpt: {{.Body | forge_excerpt 160}}
forge_csrf_token   — hidden CSRF input: {{forge_csrf_token .Request}}
forge_rfc3339      — RFC 3339 timestamp: {{forge_rfc3339 .Head.Published}}
forge_llms_entries — AI doc entry links (LLMsTemplateData): {{forge_llms_entries .}}
markdown           — full Markdown → HTML (tables, hr, language class): {{.Body | markdown}}

func URL

func URL(parts ...string) string

URL joins path segments into a root-relative URL. It collapses duplicate slashes, ensures a leading slash, and trims any trailing slash (the root "/" is preserved).

forge.URL("/posts/", p.Slug)  →  "/posts/my-slug"

func UniqueSlug

func UniqueSlug(base string, exists func(string) bool) string

UniqueSlug returns base if exists(base) is false, otherwise tries base-2, base-3, … until exists returns false. Callers must ensure the namespace is finite; this function has no upper bound.

func ValidateStruct

func ValidateStruct(v any) error

ValidateStruct runs struct-tag validation on v. v must be a struct or a pointer to a struct. Field constraints are parsed once per type and cached.

Returns a *ValidationError if any constraint fails, otherwise nil. Returns all field errors — does not short-circuit on the first failure.

func WriteError

func WriteError(w http.ResponseWriter, r *http.Request, err error)

WriteError writes the correct HTTP error response for err. It should be the only error-to-HTTP translation in handler code — call it and return.

Behaviour by error type:

  • *ValidationError → 422 with a JSON fields array
  • forge.Error 4xx → the error's own status, code, and public message
  • forge.Error 5xx → logged internally; generic 500 sent to client
  • any other error → logged internally; generic 500 sent to client

The X-Request-ID header is echoed from the response (if already set by upstream middleware) or from the incoming request. A new ID is never generated here — that is ContextFrom's responsibility.

func WriteSitemapFragment

func WriteSitemapFragment(w io.Writer, entries []SitemapEntry) error

WriteSitemapFragment writes a complete XML sitemap fragment to w. It streams the document via xml.NewEncoder — the full document is never held in memory. Returns the first write or encode error.

Entries with a zero [SitemapEntry.LastMod] omit the <lastmod> element. An empty entries slice produces a valid empty <urlset/>.

func WriteSitemapIndex

func WriteSitemapIndex(w io.Writer, fragmentURLs []string, lastMod time.Time) error

WriteSitemapIndex writes a sitemap index document to w. Each URL in fragmentURLs becomes one <sitemap> entry. lastMod is written as a date-only string and omitted when zero. An empty fragmentURLs slice produces a valid empty <sitemapindex/>.

Types

type AIDocSummary

type AIDocSummary interface{ AISummary() string }

AIDocSummary is implemented by content types that provide a concise, human-readable summary optimised for AI consumption. The summary is used in /llms.txt entries and the summary: field of AIDoc output.

When a content type implements neither AIDocSummary nor Markdownable, Forge falls back to Head.Description.

type AIFeature

type AIFeature int

AIFeature selects which AI indexing endpoints are enabled for a module. Pass one or more AIFeature constants to AIIndex.

const (
	// LLMsTxt enables the /llms.txt compact content index for the module.
	// Only Published items appear. Regenerated on every publish event.
	LLMsTxt AIFeature = 1

	// LLMsTxtFull enables the /llms-full.txt full markdown corpus for the module.
	// Each Published item is rendered as a full document with a header.
	// Only Published items appear. Regenerated on every publish event.
	LLMsTxtFull AIFeature = 2

	// AIDoc enables per-item /{prefix}/{slug}.aidoc endpoints. Each endpoint
	// returns the item in token-efficient AIDoc format (text/plain).
	// Only Published items are served; non-Published items return 404.
	AIDoc AIFeature = 3
)

type Alternate

type Alternate struct {
	Locale string // BCP 47 language tag, e.g. "en-GB"
	URL    string // absolute URL for this locale
}

Alternate is an hreflang entry for internationalised pages. Reserved for v2 — Forge always generates an empty Alternates slice in v1.

type App

type App struct {
	// contains filtered or unexported fields
}

App is the central registry for a Forge application. It couples the HTTP router, global middleware, and all content modules into a single value.

Create an App with New, wire in modules with App.Content, add global middleware with App.Use, then serve with App.Run or App.Handler.

Optional cross-cutting features are configured directly on the App:

App is not safe for concurrent configuration: set it up in main before calling Run or Handler, then treat it as read-only.

func New

func New(cfg Config) *App

New creates a new App from cfg.

New calls MustConfig on cfg automatically, so it panics at startup if BaseURL is empty or not a valid absolute URL, or if Secret is shorter than 16 bytes. Configuration errors are always caught at process start, never at first request.

Default timeouts are applied if the corresponding Config fields are zero: ReadTimeout 5 s, WriteTimeout 10 s, IdleTimeout 120 s.

func (*App) Content

func (a *App) Content(v any, opts ...Option)

Content registers a content module with the App.

If v implements Registrator (which *Module does), its Register method is called directly and opts are ignored. This is the idiomatic path:

posts := forge.NewModule[*Post](&Post{}, forge.Repo(repo), forge.At("/posts"))
app.Content(posts)

If v does not implement Registrator, Content calls NewModule[any](v, opts...) and registers the result. In this case forge.Repo must be supplied as a repoOption[any] — type safety is lost. Prefer the Registrator path for all production code.

func (*App) Cookies

func (a *App) Cookies(decls ...Cookie)

Cookies registers cookie declarations for the compliance manifest at /.well-known/cookies.json. Call once at startup with all cookies the application may set.

Duplicate declarations (same Name) are silently deduplicated; the first declaration with a given name wins.

Optionally pass ManifestAuth to restrict the manifest endpoint to authenticated requests:

app.Cookies(
    forge.Cookie{Name: "session", Category: forge.Necessary, ...},
    forge.Cookie{Name: "prefs",   Category: forge.Preferences, ...},
)

func (*App) CookiesManifestAuth

func (a *App) CookiesManifestAuth(auth AuthFunc)

CookiesManifestAuth sets the AuthFunc that guards /.well-known/cookies.json. Call before App.Handler or App.Run.

app.CookiesManifestAuth(forge.BearerHMAC(secret, forge.Editor))

func (*App) Handle

func (a *App) Handle(pattern string, handler http.Handler)

Handle registers a raw http.Handler at the given pattern on the App's internal mux. The pattern follows the same rules as http.ServeMux.

Use Handle for endpoints that are not managed by a Module:

app.Handle("GET /healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
}))

func (*App) Handler

func (a *App) Handler() http.Handler

Handler returns the composed http.Handler that serves all registered routes behind the global middleware stack.

When Config.HTTPS is true, an HTTP→HTTPS redirect middleware is prepended before all user-supplied middleware.

Handler is called automatically by App.Run. Call it directly when you need to hand the handler to your own server (e.g. for testing or embedding):

srv := &http.Server{Handler: app.Handler()}

func (*App) Health added in v1.0.6

func (a *App) Health()

Health mounts GET /_health on the App's mux.

The endpoint always returns HTTP 200 with Content-Type application/json. Framework versions are read from the binary's embedded build info and included in the response. The forge core version uses the key "forge"; companion modules use a key derived from their sub-path (e.g. "forge_mcp"). When build info is unavailable, only {"status":"ok"} is returned.

Call Health before App.Handler or App.Run:

app.Health()
// GET /_health → {"status":"ok","forge":"1.1.6","forge_mcp":"1.0.5"}

func (*App) MCPModules added in v1.1.0

func (a *App) MCPModules() []MCPModule

MCPModules returns all content modules registered with MCP. forge-mcp calls this once in its New constructor to build its resource and tool registry. The returned slice is the App's live internal slice and must not be modified by the caller.

func (*App) MustParseTemplate added in v1.2.0

func (a *App) MustParseTemplate(path string) *template.Template

MustParseTemplate parses the HTML template at path and registers TemplateFuncMap, the forge:head partial, and any partials configured via App.Partials. Panics on any error.

Use this for custom route handlers that need access to the same shared partials as module templates:

app.Partials("templates/partials")
homeTpl := app.MustParseTemplate("templates/home.html")
app.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    homeTpl.Execute(w, data)
}))

func (*App) Partials added in v1.2.0

func (a *App) Partials(dir string) *App

Partials sets the directory from which shared HTML partial templates are loaded. Every *.html file in dir is registered into each module's template set (list.html and show.html) at App.Run time, making them available via:

{{template "nav" .}}

Each partial file must use {{define "name"}}...{{end}} syntax. Any name except "forge:head" may be used. Files are registered in alphabetical order.

Partials returns the App so multiple calls can be chained:

app.Partials("templates/partials")

Use App.MustParseTemplate to parse custom handler templates (e.g. a home page) with the same partials and forge:head registered.

Example

ExampleApp_Partials demonstrates registering a shared partials directory so that nav, footer, and other common HTML fragments are available in every module template and in custom handler templates parsed via MustParseTemplate.

app := New(MustConfig(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
}))

// Any *.html file in templates/partials is injected into every module
// template set and into templates parsed via MustParseTemplate.
app.Partials("templates/partials")

_ = app.Handler()

func (*App) Redirect

func (a *App) Redirect(from, to string, code RedirectCode)

Redirect registers a manual redirect rule. Chain collapse is applied automatically: if from already redirects to an intermediate path and this call adds a rule for that intermediate path, the chain is collapsed (A→B + B→C = A→C). Maximum collapse depth is 10 (Decision 24).

To issue a 301 Moved Permanently:

app.Redirect("/old-path", "/new-path", forge.Permanent)

To issue a 410 Gone (pass an empty destination):

app.Redirect("/removed", "", forge.Gone)

func (*App) RedirectManifestAuth

func (a *App) RedirectManifestAuth(auth AuthFunc)

RedirectManifestAuth sets the AuthFunc that guards /.well-known/redirects.json. Call before App.Handler or App.Run.

app.RedirectManifestAuth(forge.BearerHMAC(secret, forge.Editor))

func (*App) RedirectStore

func (a *App) RedirectStore() *RedirectStore

RedirectStore returns the App's RedirectStore, which can be used to load persisted redirects from a database at startup, or to save/remove entries at runtime:

if err := app.RedirectStore().Load(ctx, db); err != nil {
    log.Fatal(err)
}

func (*App) Run

func (a *App) Run(addr string) error

Run starts the HTTP server on addr (e.g. ":8080") and blocks until SIGINT or SIGTERM is received.

On receiving a signal, Run initiates a graceful shutdown with a 5-second deadline, waits for active connections to drain, and returns nil. Non-shutdown errors from ListenAndServe are returned directly.

if err := app.Run(":8080"); err != nil {
    log.Fatal(err)
}

func (*App) SEO

func (a *App) SEO(opts ...SEOOption)

SEO applies one or more app-level SEO options.

Call SEO before App.Handler or App.Run so the configuration is applied before routes are registered. SEO may be called multiple times; later calls override earlier values for the same option type.

app.SEO(&forge.RobotsConfig{AIScraper: forge.AskFirst, Sitemaps: true})

func (*App) Secret added in v1.1.0

func (a *App) Secret() []byte

Secret returns the HMAC signing secret from the application configuration. It is intended for use by forge-mcp and other companion packages that must verify tokens minted with SignToken but cannot access Config directly.

func (*App) Use

func (a *App) Use(mws ...func(http.Handler) http.Handler)

Use appends one or more global middleware to the App's middleware stack.

Middleware is applied in the order it is added: the first call to Use wraps the outermost layer. Use may be called multiple times; all calls are additive.

app.Use(forge.RequestLogger(), forge.Recoverer(), forge.SecurityHeaders())

type AppSchema added in v1.1.9

type AppSchema struct {
	// Type is the JSON-LD @type, e.g. "Organization" or "WebSite".
	Type string

	// Name is the human-readable name of the organisation or site.
	Name string

	// URL is the canonical URL of the organisation or site's home page.
	URL string

	Logo string
}

AppSchema registers app-level JSON-LD structured data emitted in every page's <head> by forge:head. Use it to declare site-wide Organisation or WebSite metadata once rather than per content type.

Apply via App.SEO:

app.SEO(&forge.AppSchema{
    Type: "Organization",
    Name: "Acme Corp",
    URL:  "https://acme.com",
    Logo: "https://acme.com/logo.png",
})
Example

ExampleAppSchema demonstrates registering app-level JSON-LD structured data. The block is emitted automatically by forge:head on every page.

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.SEO(&AppSchema{
	Type: "Organization",
	Name: "Acme Corp",
	URL:  "https://example.com",
	Logo: "https://example.com/logo.png",
})
_ = app.Handler()

type AuthFunc

type AuthFunc interface {
	// contains filtered or unexported methods
}

AuthFunc authenticates an incoming HTTP request and returns the identified User and whether authentication succeeded. Use BearerHMAC, CookieSession, BasicAuth, or AnyAuth to obtain an AuthFunc. Implement this interface to provide a custom authentication scheme.

The unexported authenticate method is intentional: it prevents accidental direct calls and allows future additions to the interface without breaking existing implementations (consistent with Option and Signal).

func AnyAuth

func AnyAuth(fns ...AuthFunc) AuthFunc

AnyAuth returns an AuthFunc that tries each provided AuthFunc in order and returns the first successful result. If none match, it returns GuestUser.

AnyAuth forwards [productionWarner] and [csrfAware] capability calls to any child that implements them.

func BasicAuth

func BasicAuth(username, password string) AuthFunc

BasicAuth returns an AuthFunc that validates HTTP Basic Auth credentials. On success it returns a synthetic User with ID and Name set to the username and Roles set to Guest.

BasicAuth should not be used in production. Consider BearerHMAC or CookieSession for production use. See Amendment S7.

func BearerHMAC

func BearerHMAC(secret string) AuthFunc

BearerHMAC returns an AuthFunc that validates HMAC-signed bearer tokens from the Authorization header (format: "Bearer <token>"). Generate tokens with SignToken.

func CookieSession

func CookieSession(name, secret string, opts ...Option) AuthFunc

CookieSession returns an AuthFunc that reads a named cookie containing a signed user token (same format as BearerHMAC). CSRF protection is enabled by default — pass WithoutCSRF to opt out (strongly discouraged).

The CSRF cookie is named CSRFCookieName. See [Amendment S6].

type Breadcrumb struct {
	Label string // human-readable label
	URL   string // root-relative or absolute URL
}

Breadcrumb is a single step in a breadcrumb trail. Build slices using the Crumb constructor and the Crumbs helper.

func Crumb

func Crumb(label, url string) Breadcrumb

Crumb returns a single Breadcrumb entry. Use with Crumbs to build Head.Breadcrumbs:

forge.Crumbs(
    forge.Crumb("Home",  "/"),
    forge.Crumb("Posts", "/posts"),
    forge.Crumb(p.Title, "/posts/"+p.Slug),
)

func Crumbs

func Crumbs(crumbs ...Breadcrumb) []Breadcrumb

Crumbs collects Breadcrumb entries for use in Head.Breadcrumbs.

type CacheStore

type CacheStore struct {
	// contains filtered or unexported fields
}

CacheStore is a thread-safe LRU cache of HTTP responses used by InMemoryCache and by Module for signal-triggered invalidation. Use NewCacheStore to create one.

func NewCacheStore

func NewCacheStore(ttl time.Duration, max int) *CacheStore

NewCacheStore returns a CacheStore with the given TTL per entry and maximum entry count. When the store is full, the least-recently-used entry is evicted.

func (*CacheStore) Flush

func (c *CacheStore) Flush()

Flush removes all entries from the cache immediately. Used by Module to invalidate the cache after a write operation (create, update, delete).

func (*CacheStore) Sweep

func (c *CacheStore) Sweep()

Sweep removes all expired entries. Called periodically by InMemoryCache background goroutine and available for use by Module.

type ChangeFreq

type ChangeFreq string

ChangeFreq is the value of a sitemap <changefreq> element, indicating how frequently the page content is likely to change.

const (
	// Always signals the URL changes with every access. Use for live data.
	Always ChangeFreq = "always"

	// Hourly signals the URL is updated approximately once per hour.
	Hourly ChangeFreq = "hourly"

	// Daily signals the URL is updated approximately once per day.
	Daily ChangeFreq = "daily"

	// Weekly signals the URL is updated approximately once per week.
	// This is the default when [SitemapConfig.ChangeFreq] is empty.
	Weekly ChangeFreq = "weekly"

	// Monthly signals the URL is updated approximately once per month.
	Monthly ChangeFreq = "monthly"

	// Yearly signals the URL is updated approximately once per year.
	Yearly ChangeFreq = "yearly"

	// Never signals the URL is permanently archived and will not change.
	Never ChangeFreq = "never"
)

type Config

type Config struct {
	// BaseURL is the canonical URL of the site, e.g. "https://example.com"
	// (no trailing slash). Required.
	BaseURL string

	// Secret is the HMAC signing key used by [BearerHMAC], [CookieSession], and
	// [SignToken]. It must be at least 16 bytes. Required.
	//
	// When Auth is nil, Secret is used to validate Bearer tokens automatically
	// via [BearerHMAC]. Set [Config.Auth] to override.
	Secret []byte

	// Auth is the [AuthFunc] used to authenticate requests. When nil, Forge
	// defaults to [BearerHMAC] using [Config.Secret]. Set this explicitly to
	// use [CookieSession], [AnyAuth], or a custom [AuthFunc].
	//
	// Example — cookie sessions instead of bearer tokens:
	//
	//	Auth: forge.CookieSession("session", secret)
	//
	// Example — both bearer tokens and cookie sessions:
	//
	//	Auth: forge.AnyAuth(
	//	    forge.BearerHMAC(secret),
	//	    forge.CookieSession("session", secret),
	//	)
	Auth AuthFunc

	// Version is the application version string. It is an optional field for
	// application authors who want to track their own release version; forge
	// itself does not use this field in any built-in endpoint.
	Version string

	// DB is the database connection used by content modules.
	// It accepts *sql.DB, *sql.Tx, or any value that satisfies [DB].
	// Optional — leave nil to use in-memory repositories only.
	DB DB

	// HTTPS forces an HTTP→HTTPS redirect for all plain-HTTP requests when
	// true. The App handler checks r.TLS and the X-Forwarded-Proto header so
	// this works correctly behind a reverse proxy. Optional.
	HTTPS bool

	// ReadTimeout is the maximum time to read the full request, including the
	// body. Defaults to 5 s. Optional.
	ReadTimeout time.Duration

	// WriteTimeout is the maximum time to write the full response. Defaults to
	// 10 s. Optional.
	WriteTimeout time.Duration

	// IdleTimeout is the maximum keep-alive idle time between requests.
	// Defaults to 120 s. Optional.
	IdleTimeout time.Duration
}

Config holds the application-wide configuration passed to New.

BaseURL and Secret are required; all other fields are optional.

Timeouts default to 5 s (read), 10 s (write), and 120 s (idle) when left as zero. Set them explicitly to override.

func MustConfig

func MustConfig(cfg Config) Config

MustConfig validates cfg and returns it unchanged.

Panics with a descriptive message if:

  • Config.BaseURL is empty or not a valid absolute URL
  • Config.Secret is fewer than 16 bytes

Typical usage:

app := forge.New(forge.MustConfig(forge.Config{
    BaseURL: os.Getenv("BASE_URL"),
    Secret:  []byte(os.Getenv("SECRET")),
}))

type Context

type Context interface {
	context.Context

	// User returns the authenticated identity for this request.
	// Returns [GuestUser] (zero value) for unauthenticated requests.
	User() User

	// Locale returns the BCP 47 language tag for this request.
	// Always "en" in v1; i18n support is planned for v2 (Decision 11).
	Locale() string

	// SiteName returns the configured site name. Always "" in v1 until
	// wired in forge.go (Step 11).
	SiteName() string

	// RequestID returns the UUID v7 assigned to this request for
	// end-to-end traceability. Set as X-Request-ID on the response.
	RequestID() string

	// Request returns the underlying *http.Request.
	Request() *http.Request

	// Response returns the http.ResponseWriter for this request.
	Response() http.ResponseWriter
}

Context is the request-scoped value passed to every Forge hook and handler. It embeds context.Context for full compatibility with stdlib and third-party libraries, while exposing Forge-specific accessors without key-based lookups.

forge.Context is always non-nil — Forge guarantees this before any user code is called. The internal implementation is [contextImpl] (unexported). Use ContextFrom in production and NewTestContext in tests.

func ContextFrom

func ContextFrom(w http.ResponseWriter, r *http.Request) Context

ContextFrom builds a Context from a live HTTP request. It:

  • Derives the RequestID from X-Request-ID response header, then request header, generating a fresh UUID v7 if neither is present
  • Writes the final RequestID to the X-Request-ID response header
  • Reads the authenticated User from the request's context (set by auth middleware); uses GuestUser if absent
  • Sets Locale to "en" (i18n deferred to v2)
  • Sets SiteName to "" (wired in forge.go, Step 11)

func NewBackgroundContext

func NewBackgroundContext(siteName string) Context

NewBackgroundContext returns a Context for use in background goroutines such as the scheduled-publishing ticker. It has no HTTP lifecycle and never times out:

  • Request() returns a synthetic GET / request backed by context.Background
  • Response() returns a *httptest.ResponseRecorder (discards output)
  • User is GuestUser; Locale is "en"; RequestID is a generated UUID v7

siteName should be the hostname portion of [Config.BaseURL] (e.g. "example.com").

func NewContextWithUser added in v1.1.0

func NewContextWithUser(user User) Context

NewContextWithUser returns a Context for use in background goroutines or non-HTTP transports (e.g. stdio MCP) that require a real User identity. Unlike NewTestContext, this function may appear in production code. Unlike NewBackgroundContext, the User is caller-supplied rather than hardcoded to GuestUser.

  • Request() returns a synthetic GET / request backed by context.Background
  • Response() returns a *httptest.ResponseRecorder (discards output)
  • Locale is "en"; SiteName is ""; RequestID is a generated UUID v7

func NewTestContext

func NewTestContext(user User) Context

NewTestContext returns a Context suitable for unit tests. It requires no running HTTP server:

  • Request() returns a synthetic GET / request
  • Response() returns a *httptest.ResponseRecorder
  • Locale is "en", SiteName is "", RequestID is a generated UUID v7

Pass GuestUser (or a zero User) for unauthenticated test scenarios.

type Cookie struct {
	// Name is the cookie name as set on the wire.
	Name string

	// Category classifies the cookie for consent enforcement.
	Category CookieCategory

	// Path scopes the cookie to a URL prefix. Defaults to "/" if empty.
	Path string

	// Domain optionally scopes the cookie to a domain.
	Domain string

	// Secure restricts the cookie to HTTPS connections.
	Secure bool

	// HttpOnly prevents JavaScript from accessing the cookie.
	HttpOnly bool

	// SameSite controls cross-site request behaviour.
	// Defaults to http.SameSiteStrictMode when zero.
	SameSite http.SameSite

	// MaxAge is the cookie lifetime in seconds.
	// 0 = session cookie; negative = delete immediately.
	MaxAge int

	// Purpose is a human-readable description for the compliance manifest.
	Purpose string
}

Cookie declares a typed cookie with its category, attributes, and purpose.

Category determines which set API is legal (Decision 5):

Purpose is a human-readable description included in the compliance manifest at /.well-known/cookies.json.

type CookieCategory

type CookieCategory string

CookieCategory classifies a cookie by its GDPR consent requirement. The category determines which set API is legal (Decision 5).

const (
	// Necessary cookies are required for the site to function.
	// They do not require user consent and must be set with [SetCookie].
	Necessary CookieCategory = "necessary"

	// Preferences cookies remember user settings (e.g. language, theme).
	// They require consent and must be set with [SetCookieIfConsented].
	Preferences CookieCategory = "preferences"

	// Analytics cookies collect anonymous usage statistics.
	// They require consent and must be set with [SetCookieIfConsented].
	Analytics CookieCategory = "analytics"

	// Marketing cookies track users across sites for advertising purposes.
	// They require consent and must be set with [SetCookieIfConsented].
	Marketing CookieCategory = "marketing"
)

type CrawlerPolicy

type CrawlerPolicy string

CrawlerPolicy controls how AI web crawlers are treated in the generated robots.txt. The zero value is Allow.

const (
	// Allow permits all crawlers, including AI training scrapers.
	// This is the zero-value default.
	Allow CrawlerPolicy = "allow"

	// Disallow blocks all known AI training crawlers by adding individual
	// User-agent / Disallow: / entries for each identified bot.
	Disallow CrawlerPolicy = "disallow"

	// AskFirst blocks known AI training crawlers while permitting AI
	// assistants that respect the robots.txt contract. Recommended for
	// sites that wish to be indexed by AI search but not scraped for
	// training.
	AskFirst CrawlerPolicy = "ask-first"
)

type DB

type DB interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

DB is satisfied by *sql.DB, *sql.Tx, and any pgx adapter such as forgepgx.Wrap(pool). Users pass a concrete implementation to forge.Config — they do not implement DB directly.

type Error

type Error interface {
	error
	// Code returns a machine-readable error identifier (e.g. "not_found").
	Code() string
	// HTTPStatus returns the HTTP status code that should be sent to the client.
	HTTPStatus() int
	// Public returns a message that is safe to expose to API clients.
	Public() string
}

Error is implemented by all Forge errors. Callers should use errors.As to inspect the concrete type — never type-assert directly against a sentinel.

type EventDetails

type EventDetails struct {
	StartDate time.Time
	EndDate   time.Time
	Location  string // venue name
	Address   string // street address or city
}

EventDetails carries the extra fields required for Event rich results.

type EventProvider

type EventProvider interface{ EventDetails() EventDetails }

EventProvider is implemented by content types that supply event structured data.

type FAQEntry

type FAQEntry struct {
	Question string
	Answer   string
}

FAQEntry is a single question-and-answer pair for FAQPage rich results.

type FAQProvider

type FAQProvider interface{ FAQEntries() []FAQEntry }

FAQProvider is implemented by content types that supply FAQ structured data. Return a non-empty slice to enable FAQPage JSON-LD generation via SchemaFor.

type FaviconLink struct {
	Rel   string // "icon", "apple-touch-icon", etc.
	Type  string // MIME type, e.g. "image/png"; omitted when empty
	Sizes string // e.g. "32x32"; omitted when empty
	Href  string // URL to the icon file
}

FaviconLink declares a single <link> element for a favicon or touch icon. Rel is required ("icon", "apple-touch-icon"). Type and Sizes are optional; omit them by leaving the fields empty.

type FeedConfig

type FeedConfig struct {
	// Title is the channel title shown in feed readers.
	// Defaults to the capitalised prefix (e.g. "Posts").
	Title string

	// Description is the channel description.
	// Defaults to the site hostname when empty.
	Description string

	// Language is the BCP 47 language code for the feed.
	// Defaults to "en".
	Language string
}

FeedConfig configures the RSS 2.0 feed for a content module. Pass it to Feed to enable feed generation for the module.

All fields are optional. Title defaults to the capitalised module prefix (e.g. "/posts" → "Posts"). Language defaults to "en".

type FeedStore

type FeedStore struct {
	// contains filtered or unexported fields
}

FeedStore holds pre-built RSS item fragments from all Feed-enabled content modules. It is shared across modules via App.Content and provides per-module and aggregate /feed.xml HTTP handlers.

All public methods are safe for concurrent use.

func NewFeedStore

func NewFeedStore(siteName, baseURL string) *FeedStore

NewFeedStore constructs a FeedStore for the given site hostname and base URL. Called by App.Content when the first Feed-enabled module is registered.

func (*FeedStore) HasFeeds

func (s *FeedStore) HasFeeds() bool

HasFeeds reports whether at least one module has registered a feed fragment. Used by App.Handler to decide whether to mount GET /feed.xml.

func (*FeedStore) IndexHandler

func (s *FeedStore) IndexHandler() http.Handler

IndexHandler returns an http.Handler that serves a merged RSS 2.0 feed of all Published items from every Feed-enabled module, sorted by pubDate descending, at /feed.xml.

func (*FeedStore) ModuleHandler

func (s *FeedStore) ModuleHandler(prefix string) http.Handler

ModuleHandler returns an http.Handler that serves the RSS 2.0 feed for the given module prefix at /{prefix}/feed.xml.

The channel title comes from FeedConfig.Title (falling back to the capitalised prefix). Language defaults to "en".

func (*FeedStore) Set

func (s *FeedStore) Set(prefix string, cfg FeedConfig, items []rssItem)

Set stores the RSS items and config for the given module prefix. Passing nil items registers the prefix without content (used at startup). Called by regenerateFeed on every publish event and by setFeedStore at startup.

type From

type From string

From is the old URL prefix supplied to the Redirects module option. Wrapping in a named type makes call sites self-documenting:

forge.Redirects(forge.From("/posts"), "/articles")
type Head struct {
	Title       string          // page title; used in <title>, og:title, and JSON-LD
	Description string          // meta description; recommended max 160 characters
	Author      string          // author name; used in <meta name="author"> and JSON-LD
	Published   time.Time       // publication date; zero value omits date tags
	Modified    time.Time       // last-modified date; zero value omits date tags
	Image       Image           // primary image; zero URL omits all image tags
	Type        string          // rich result type (Article, Product, etc.); empty omits JSON-LD
	Canonical   string          // canonical URL; empty omits the canonical tag
	Tags        []string        // content tags; used for article:tag meta and RSS categories
	Breadcrumbs []Breadcrumb    // breadcrumb trail; empty omits BreadcrumbList JSON-LD
	Alternates  []Alternate     // hreflang entries; always empty in v1
	Social      SocialOverrides // per-item social sharing overrides; zero value uses defaults
	NoIndex     bool            // true renders <meta name="robots" content="noindex">
}

Head carries all SEO and social metadata for a content page. Define it on your content type via the Headable interface. Forge uses the Head to populate HTML <head> tags, JSON-LD structured data, sitemaps, RSS feeds, and AI endpoints.

All fields are optional: the zero value is safe and produces a minimal page header.

type HeadAssets added in v1.3.0

type HeadAssets struct {
	Preconnect  []string      // <link rel="preconnect" href="…">
	Stylesheets []string      // <link rel="stylesheet" href="…">
	Favicons    []FaviconLink // <link rel="icon" …> / <link rel="apple-touch-icon" …>
	Scripts     []ScriptTag   // <script …>
}

HeadAssets is an SEOOption that injects static linked assets — preconnect hints, stylesheets, favicons, and scripts — into the forge:head partial on every page.

Apply it via App.SEO:

app.SEO(&forge.HeadAssets{
    Preconnect:  []string{"https://fonts.googleapis.com"},
    Stylesheets: []string{"https://fonts.googleapis.com/css2?family=Inter&display=swap"},
    Favicons: []forge.FaviconLink{
        {Rel: "icon", Type: "image/png", Sizes: "32x32", Href: "/favicon-32.png"},
    },
    Scripts: []forge.ScriptTag{
        {Src: "/static/app.js", Defer: true},
    },
})

Assets are emitted in order: preconnect → stylesheets → favicons → scripts.

Example

ExampleHeadAssets demonstrates injecting site-wide static assets — preconnect hints, stylesheets, favicons, and scripts — into forge:head on every page via app.SEO.

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.SEO(&HeadAssets{
	Preconnect:  []string{"https://fonts.googleapis.com"},
	Stylesheets: []string{"https://fonts.googleapis.com/css2?family=Inter&display=swap", "/static/app.css"},
	Favicons: []FaviconLink{
		{Rel: "icon", Type: "image/png", Sizes: "32x32", Href: "/favicon-32.png"},
		{Rel: "apple-touch-icon", Href: "/apple-touch-icon.png"},
	},
	Scripts: []ScriptTag{
		{Src: "/static/app.js", Defer: true},
	},
})
_ = app.Handler()

type Headable

type Headable interface{ Head() Head }

Headable is implemented by content types that provide their own SEO metadata. Module[T] calls Head() automatically when building HTML responses, sitemaps, RSS feeds, and AI endpoints — no HeadFunc option required. HeadFunc takes priority over Headable when both are present.

type HowToProvider

type HowToProvider interface{ HowToSteps() []HowToStep }

HowToProvider is implemented by content types that supply step-by-step structured data for HowTo rich results.

type HowToStep

type HowToStep struct {
	Name string // short label for the step
	Text string // full instruction text
}

HowToStep is a single step in a HowTo or Recipe structured data block.

type Image

type Image struct {
	URL    string // absolute or root-relative
	Alt    string // accessibility and SEO description
	Width  int    // pixels; required for og:image:width
	Height int    // pixels; required for og:image:height
}

Image is a typed image reference. Width and Height are required for optimal Open Graph rendering and Twitter Card display. The zero value (empty URL) renders no image tags — safe to leave unset.

type LLMsEntry

type LLMsEntry struct {
	// Title is the content item's title. Required.
	Title string

	// URL is the canonical URL for this item. Required.
	URL string

	// Summary is a short plain-text description. Optional; omitted when empty.
	Summary string
}

LLMsEntry is a single compact entry in /llms.txt output. Fields map to the llmstxt.org compact format: - [Title](URL): Summary

type LLMsStore

type LLMsStore struct {
	// contains filtered or unexported fields
}

LLMsStore holds compact and full content fragments for /llms.txt and /llms-full.txt. Thread-safe. Analogous to SitemapStore.

Created by App.Content when the first module registers with AIIndex. Passed to each module via setAIRegistry.

func NewLLMsStore

func NewLLMsStore(siteName string) *LLMsStore

NewLLMsStore creates an LLMsStore for the given site name.

func (*LLMsStore) CompactHandler

func (s *LLMsStore) CompactHandler() http.Handler

CompactHandler returns an http.Handler that serves the /llms.txt endpoint. The built-in format follows the llmstxt.org convention: site name header followed by per-item entries as "- [Title](URL): Summary". Responses are gzip-compressed when the client sends Accept-Encoding: gzip and the body exceeds [gzipMinBytes] (Amendment A17).

func (*LLMsStore) FullHandler

func (s *LLMsStore) FullHandler() http.Handler

FullHandler returns an http.Handler that serves the /llms-full.txt endpoint. The corpus header identifies the site name, generation date, and item count. Each item is rendered as a full document separated by "---". Responses are gzip-compressed when the client sends Accept-Encoding: gzip and the body exceeds [gzipMinBytes] (Amendment A17).

func (*LLMsStore) HasCompact

func (s *LLMsStore) HasCompact() bool

HasCompact reports whether any module registered with LLMsTxt.

func (*LLMsStore) HasFull

func (s *LLMsStore) HasFull() bool

HasFull reports whether any module registered with LLMsTxtFull.

func (*LLMsStore) SetCompact

func (s *LLMsStore) SetCompact(prefix string, entries []LLMsEntry)

SetCompact stores compact entries for the given module prefix. Called by Module.regenerateAI after every publish event.

func (*LLMsStore) SetFull

func (s *LLMsStore) SetFull(prefix string, body string)

SetFull stores the full markdown corpus fragment for the given module prefix. Called by Module.regenerateAI after every publish event.

type LLMsTemplateData

type LLMsTemplateData struct {
	// SiteName is the hostname of the site (e.g. "example.com").
	SiteName string

	// Description is a one-line site description. Empty by default;
	// set manually in custom templates.
	Description string

	// Entries contains all compact entries across all registered modules.
	Entries []LLMsEntry

	// GeneratedAt is the generation date in YYYY-MM-DD format.
	GeneratedAt string

	// ItemCount is the total number of Published items across all modules.
	ItemCount int
}

LLMsTemplateData is the data value passed to custom llms.txt templates. Create templates/llms.txt in your template directory to override the built-in format:

# {{.SiteName}}

> {{.Description}}

## All Content
{{forge_llms_entries .}}

type ListOptions

type ListOptions struct {
	// Page is one-based. Values ≤ 0 are treated as page 1.
	Page int
	// PerPage is the maximum number of items per page.
	// A value of 0 means return all items.
	PerPage int
	// OrderBy is the Go field name to sort by (e.g. "Title").
	// Sorting applies only to exported string fields; other types are ignored.
	OrderBy string
	// Desc reverses the sort order when true.
	Desc bool
	// Status restricts results to items whose Status field matches one of the
	// given values. An empty or nil slice means return all statuses.
	Status []Status
}

ListOptions controls pagination and ordering for FindAll queries.

func (ListOptions) Offset

func (o ListOptions) Offset() int

Offset returns the zero-based row offset for the page described by o.

type MCPField added in v1.1.0

type MCPField struct {
	Name      string // Go field name
	JSONName  string // lowercase snake_case name used in MCP messages
	Type      string // "string" | "number" | "boolean" | "datetime"
	Required  bool
	MinLength int      // 0 = no constraint
	MaxLength int      // 0 = no constraint
	Enum      []string // nil = no constraint
}

MCPField describes a single field in a content type's MCP schema, derived automatically from the Go struct type and forge: struct tags. Returned by [MCPModule.MCPSchema].

type MCPMeta added in v1.1.0

type MCPMeta struct {
	Prefix     string         // URL prefix, e.g. "/posts"
	TypeName   string         // content type name, e.g. "BlogPost"
	Operations []MCPOperation // MCPRead and/or MCPWrite
}

MCPMeta describes the MCP registration of a content module. Returned by [MCPModule.MCPMeta].

type MCPModule added in v1.1.0

type MCPModule interface {
	// MCPMeta returns the module's MCP registration metadata.
	MCPMeta() MCPMeta
	// MCPSchema returns the field schema derived from the content type's
	// struct tags.
	MCPSchema() []MCPField
	// MCPList returns all items matching the given statuses (all statuses if
	// none are given).
	MCPList(ctx Context, status ...Status) ([]any, error)
	// MCPGet returns the item with the given slug.
	// MCPGet does not filter by lifecycle status — it returns the item
	// regardless of status. Callers are responsible for enforcing lifecycle
	// rules (e.g. forge-mcp checks that the item is Published before
	// including it in a resources/read response).
	MCPGet(ctx Context, slug string) (any, error)
	// MCPCreate creates a new item from the given fields map.
	MCPCreate(ctx Context, fields map[string]any) (any, error)
	// MCPUpdate applies a partial update to the item with the given slug.
	MCPUpdate(ctx Context, slug string, fields map[string]any) (any, error)
	// MCPPublish transitions the item with the given slug to Published.
	MCPPublish(ctx Context, slug string) error
	// MCPSchedule sets the item with the given slug to publish at the given time.
	MCPSchedule(ctx Context, slug string, at time.Time) error
	// MCPArchive transitions the item with the given slug to Archived.
	MCPArchive(ctx Context, slug string) error
	// MCPDelete permanently deletes the item with the given slug.
	MCPDelete(ctx Context, slug string) error
}

MCPModule is implemented by any Module[T] that has been registered with MCP. forge-mcp reads this interface to build MCP resources and tools without accessing Module internals directly.

All methods receive a Context carrying the authenticated user. Callers must construct the Context with the appropriate Role before calling any mutating method — the MCPModule implementation enforces roles and validation identically to the HTTP layer.

type MCPOperation

type MCPOperation string

MCPOperation is an option flag for the MCP function. Only MCPRead and MCPWrite are defined.

const (
	// MCPRead signals that this module should be exposed as a read-only MCP
	// resource. The forge-mcp server will include it in resources/list and
	// resources/read responses. See [MCPModule].
	MCPRead MCPOperation = "read"

	// MCPWrite signals that this module should be exposed as a read+write MCP
	// resource. The forge-mcp server will generate tools for create, update,
	// publish, schedule, archive, and delete operations. See [MCPModule].
	MCPWrite MCPOperation = "write"
)

type Markdownable

type Markdownable interface{ Markdown() string }

Markdownable is implemented by content types that render directly to Markdown. When T implements Markdownable, Module serves text/markdown responses without requiring forge.Templates to be configured. The Markdown body is also used in AIDoc output and /llms-full.txt corpus entries.

type MemoryRepo

type MemoryRepo[T any] struct {
	// contains filtered or unexported fields
}

MemoryRepo is a thread-safe in-memory implementation of Repository. It is intended for unit tests and prototyping — not production use. Fields named ID and Slug are located via cached reflection on first use.

func NewMemoryRepo

func NewMemoryRepo[T any]() *MemoryRepo[T]

NewMemoryRepo returns an empty MemoryRepo[T] ready for use.

func (*MemoryRepo[T]) Delete

func (r *MemoryRepo[T]) Delete(_ context.Context, id string) error

Delete removes the item with the given ID. Returns ErrNotFound if absent.

func (*MemoryRepo[T]) FindAll

func (r *MemoryRepo[T]) FindAll(_ context.Context, opts ListOptions) ([]T, error)

FindAll returns items in insertion order, with optional sorting and pagination from opts. When opts.PerPage is 0, all items are returned.

func (*MemoryRepo[T]) FindByID

func (r *MemoryRepo[T]) FindByID(_ context.Context, id string) (T, error)

FindByID returns the item with the given ID, or ErrNotFound.

func (*MemoryRepo[T]) FindBySlug

func (r *MemoryRepo[T]) FindBySlug(_ context.Context, slug string) (T, error)

FindBySlug returns the first item whose Slug field matches slug, or ErrNotFound.

func (*MemoryRepo[T]) Save

func (r *MemoryRepo[T]) Save(_ context.Context, node T) error

Save upserts node into the repository keyed by its ID field. On insert the ID is appended to the internal order list. On update the existing position in the order list is preserved.

type Module

type Module[T any] struct {
	// contains filtered or unexported fields
}

Module is the core routing and lifecycle unit for a content type T. T must embed Node — its struct must have exported ID, Slug, and Status fields. Use NewModule to construct; Registration onto a ServeMux is done via [Register]. App.Content handles both steps automatically.

func NewModule

func NewModule[T any](proto T, opts ...Option) *Module[T]

NewModule constructs a Module for content type T.

proto is a representative value of T (typically a nil pointer: (*Post)(nil)) used to derive the default URL prefix and to detect capabilities.

Required options (supplied automatically by App.Content):

  • Repo: provides the Repository[T]

Optional options:

  • At: override URL prefix (default: "/"+lowercase(TypeName)+"s")
  • Auth: set per-operation role requirements
  • Cache: enable per-module LRU response cache
  • Middleware: wrap all routes with the given middleware
  • On: register signal handlers

Panics if no Repo option is present — this is a programming error caught at startup, never at request time.

Example

ExampleNewModule demonstrates creating a typed content module and registering it with an App. This is the idiomatic two-step path: NewModule[T] preserves full type safety and ensures all App-level wiring (sitemap, feed, AI) runs.

secret := []byte("example-secret-key-32-bytes!!!!!")

repo := NewMemoryRepo[*examplePost]()
m := NewModule(&examplePost{},
	At("/posts"),
	Repo(repo),
	Auth(
		Read(Guest),
		Write(Author),
		Delete(Editor),
	),
	Cache(5*time.Minute),
	AIIndex(LLMsTxt, AIDoc),
)

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  secret,
})
app.Content(m)
_ = app.Handler()

func (*Module[T]) MCPArchive added in v1.1.0

func (m *Module[T]) MCPArchive(ctx Context, slug string) error

MCPArchive transitions the item with the given slug to Archived, fires AfterArchive, and triggers derived-content rebuild.

func (*Module[T]) MCPCreate added in v1.1.0

func (m *Module[T]) MCPCreate(ctx Context, fields map[string]any) (any, error)

MCPCreate creates a new content item from the supplied fields map. A new ID is always generated; the slug is auto-derived when absent. The item is validated before persistence. AfterCreate signals are dispatched asynchronously.

func (*Module[T]) MCPDelete added in v1.1.0

func (m *Module[T]) MCPDelete(ctx Context, slug string) error

MCPDelete permanently removes the item with the given slug, fires AfterDelete, and triggers derived-content rebuild.

func (*Module[T]) MCPGet added in v1.1.0

func (m *Module[T]) MCPGet(ctx Context, slug string) (any, error)

MCPGet returns the item with the given slug regardless of its lifecycle status. The caller is responsible for enforcing visibility rules.

func (*Module[T]) MCPList added in v1.1.0

func (m *Module[T]) MCPList(ctx Context, status ...Status) ([]any, error)

MCPList returns all content items matching the given statuses. If no statuses are provided, items of all statuses are returned.

func (*Module[T]) MCPMeta added in v1.1.0

func (m *Module[T]) MCPMeta() MCPMeta

MCPMeta returns the MCP registration metadata for this module.

func (*Module[T]) MCPPublish added in v1.1.0

func (m *Module[T]) MCPPublish(ctx Context, slug string) error

MCPPublish transitions the item with the given slug to Published, sets PublishedAt to now, fires AfterPublish, and triggers derived-content rebuild.

func (*Module[T]) MCPSchedule added in v1.1.0

func (m *Module[T]) MCPSchedule(ctx Context, slug string, at time.Time) error

MCPSchedule sets the item with the given slug to Scheduled and records the time at which it will be automatically published.

func (*Module[T]) MCPSchema added in v1.1.0

func (m *Module[T]) MCPSchema() []MCPField

MCPSchema derives the field schema for this module's content type from Go struct fields and forge: struct tags. The embedded forge.Node fields Slug, Status, PublishedAt, and ScheduledAt are included; ID, CreatedAt, and UpdatedAt are omitted because they are managed by the framework.

func (*Module[T]) MCPUpdate added in v1.1.0

func (m *Module[T]) MCPUpdate(ctx Context, slug string, fields map[string]any) (any, error)

MCPUpdate applies a partial update to the item with the given slug. Fields present in the map overlay the existing item; absent fields are preserved. Node.ID, Node.Slug, and Node.Status are always restored after the merge — use the dedicated lifecycle methods to change status.

func (*Module[T]) Register

func (m *Module[T]) Register(mux *http.ServeMux)

Register mounts the five standard routes for this module onto mux. Called automatically by App.Content.

GET    /{prefix}          → list
GET    /{prefix}/{slug}   → show
POST   /{prefix}          → create
PUT    /{prefix}/{slug}   → update
DELETE /{prefix}/{slug}   → delete

func (*Module[T]) Stop added in v1.0.5

func (m *Module[T]) Stop()

Stop terminates background goroutines started by this module (cache sweep ticker and any pending debounce timer). It is called automatically by App.Run during graceful shutdown. Stop is idempotent — calling it more than once is safe.

type Node

type Node struct {
	// ID is the UUID v7 primary key. Set by the storage layer on insert;
	// immutable thereafter. See [NewID] and Amendment S1.
	ID string

	// Slug is the URL-safe identifier used in all public URLs. Unique within
	// a module. Auto-generated from the first required string field if not
	// set explicitly. May be changed; the old URL should redirect.
	Slug string

	// Status is the lifecycle state. Forge enforces this on every public
	// endpoint. See Decision 14.
	Status Status

	// PublishedAt is the time the content was first published. Zero until
	// the first transition to Published.
	PublishedAt time.Time `db:"published_at"`

	// ScheduledAt is the time at which a Scheduled item will be published.
	// Nil for all other lifecycle states.
	ScheduledAt *time.Time `db:"scheduled_at"`

	// CreatedAt is set by the storage layer on insert and never updated.
	CreatedAt time.Time `db:"created_at"`

	// UpdatedAt is set by the storage layer on every Save.
	UpdatedAt time.Time `db:"updated_at"`
}

Node is the base type embedded by every Forge content type. It carries the stable UUID identity, the URL slug, and the full content lifecycle.

Content types must embed Node as a value (not a pointer):

type BlogPost struct {
    forge.Node
    Title string `forge:"required"`
    Body  string `forge:"required,min=50"`
}

Never store a Node by pointer inside your content type — the storage and validation layers require a contiguous struct layout.

func (*Node) GetPublishedAt

func (n *Node) GetPublishedAt() time.Time

GetPublishedAt returns the time this node was first published. The zero time indicates the node has never been published.

func (*Node) GetSlug

func (n *Node) GetSlug() string

GetSlug returns the URL slug for this node. Satisfies the SitemapNode constraint, enabling generic sitemap generation without reflection.

func (*Node) GetStatus

func (n *Node) GetStatus() Status

GetStatus returns the lifecycle status of this node.

type OGDefaults added in v1.1.9

type OGDefaults struct {
	// Image is the fallback og:image used when a content item's Head.Image.URL
	// is empty. Width and Height are recommended for optimal Twitter Card display.
	Image Image

	// TwitterSite is the twitter:site handle for the site (e.g. "@mycompany").
	// Always emitted on every page; not overridable per item.
	TwitterSite string

	// TwitterCreator is the fallback twitter:creator handle used when the
	// content item's Head.Social.Twitter.Creator is empty.
	TwitterCreator string
}

OGDefaults sets app-level Open Graph and Twitter Card fallback values. Apply via App.SEO; values are merged into every page's Head by forge:head when the content item does not supply its own.

  • Image — fallback og:image when [Head.Image].URL is empty.
  • TwitterSite — twitter:site handle (e.g. "@mycompany"); always app-level, emitted on every page.
  • TwitterCreator — fallback twitter:creator when [Head.Social].Twitter.Creator is empty.

Example:

app.SEO(&forge.OGDefaults{
    Image:          forge.Image{URL: "https://example.com/og.png", Width: 1200, Height: 630},
    TwitterSite:    "@mycompany",
    TwitterCreator: "@editor",
})
Example

ExampleOGDefaults demonstrates setting app-level Open Graph and Twitter Card fallback values. These are merged into every page's Head by forge:head when the content item does not supply its own image or Twitter creator handle.

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.SEO(&OGDefaults{
	Image:          Image{URL: "https://example.com/og-default.png", Width: 1200, Height: 630},
	TwitterSite:    "@mycompany",
	TwitterCreator: "@editor",
})
_ = app.Handler()

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option configures a Module or App at registration time. Option values are created by functions such as Read, Write, Delete, At, Cache, and forge.On. They are consumed during module or app setup and have no effect after App.Run is called.

var WithoutCSRF Option = withoutCSRFOption{}

WithoutCSRF is an Option passed to CookieSession to disable automatic CSRF protection. This is strongly discouraged for production use.

func AIIndex

func AIIndex(features ...AIFeature) Option

AIIndex returns an Option that enables AI indexing endpoints for a module. Pass one or more AIFeature constants to select which endpoints are registered.

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.AIIndex(forge.LLMsTxt, forge.LLMsTxtFull, forge.AIDoc),
)
Example

ExampleAIIndex demonstrates enabling AI indexing on a content module. LLMsTxt registers the module in /llms.txt, LLMsTxtFull produces a full markdown corpus at /llms-full.txt, and AIDoc adds /{slug}/aidoc endpoints.

repo := NewMemoryRepo[*examplePost]()
m := NewModule(&examplePost{},
	At("/posts"),
	Repo(repo),
	AIIndex(LLMsTxt, LLMsTxtFull, AIDoc),
)

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.Content(m)
_ = app.Handler()

func At

func At(prefix string) Option

At returns an Option that sets the URL prefix for a module. The prefix must start with "/" and must not end with "/". Example: forge.At("/posts")

func Auth

func Auth(opts ...Option) Option

Auth returns an Option that sets the minimum role for each HTTP operation on this module. Accepts Read, Write, and Delete role options.

forge.Auth(
    forge.Read(forge.Guest),
    forge.Write(forge.Author),
    forge.Delete(forge.Editor),
)
Example

ExampleAuth demonstrates declaring role-based access for read, write, and delete operations on a content module.

repo := NewMemoryRepo[*examplePost]()
m := NewModule(&examplePost{},
	At("/posts"),
	Repo(repo),
	Auth(
		Read(Guest),
		Write(Author),
		Delete(Editor),
	),
)

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.Content(m)
_ = app.Handler()

func Cache

func Cache(ttl time.Duration) Option

Cache returns an Option that enables a per-module LRU response cache with the given TTL. Cached entries are flushed automatically on any create, update, or delete operation. The cache holds at most 1000 entries (LRU eviction).

func CacheMaxEntries

func CacheMaxEntries(n int) Option

CacheMaxEntries returns an Option that configures InMemoryCache to hold at most n entries, evicting the least-recently-used entry when full. The default is 1000 entries.

func Delete

func Delete(r Role) Option

Delete returns an Option that restricts delete access to users whose role satisfies the required role. Wired in Step 10 (module.go).

func DisableFeed added in v1.0.5

func DisableFeed() Option

DisableFeed returns an Option that explicitly opts a module out of RSS feed generation. This is a defensive marker for modules where a feed endpoint would be inappropriate (e.g. admin-only or API-only modules).

func Feed

func Feed(cfg FeedConfig) Option

Feed returns an Option that enables RSS 2.0 feed generation for the module. The feed is served at /{prefix}/feed.xml and regenerated on every publish event. An aggregate feed at /feed.xml merges all Published items from every Feed-enabled module, sorted by publish date descending.

app.Content(&Post{},
    forge.At("/posts"),
    forge.Feed(forge.FeedConfig{Title: "Blog", Description: "Latest posts"}),
)

func HeadFunc

func HeadFunc[T any](fn func(Context, T) Head) Option

HeadFunc returns an Option that overrides a content type's Head method at the module level. The function receives the current request context and the content item; its return value takes precedence over the content type's own Head() implementation.

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.HeadFunc(func(ctx forge.Context, p *BlogPost) forge.Head {
        return forge.Head{Title: p.Title + " — " + ctx.SiteName()}
    }),
)

func MCP

func MCP(ops ...MCPOperation) Option

MCP marks a module as an MCP (Model Context Protocol) resource. Pass MCPRead to expose content as resources, MCPWrite to also generate write tools. See MCPModule for the interface implemented by Module.

Example:

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.MCP(forge.MCPRead, forge.MCPWrite),
)

func ManifestAuth

func ManifestAuth(auth AuthFunc) Option

ManifestAuth returns an Option that restricts the /.well-known/cookies.json endpoint to requests that pass the given AuthFunc.

A 401 Unauthorized response is returned for unauthenticated requests. Omit ManifestAuth to make the endpoint publicly accessible.

func Middleware

func Middleware(mws ...func(http.Handler) http.Handler) Option

Middleware returns an Option that wraps every route in this module with the provided middleware. Applied in the same order as Chain (index 0 is outermost).

func On

func On[T any](signal Signal, h func(Context, T) error) Option

On registers a typed signal handler as a module Option. The handler receives the content value as its concrete type T — no type assertion required at the call site.

Example:

forge.On(forge.BeforeCreate, func(ctx forge.Context, p *Post) error {
    p.Author = ctx.User().Name
    return nil
})
Example

ExampleOn demonstrates registering a typed signal handler on a content module. The handler fires after a post is published and receives the full forge.Context and the typed item.

repo := NewMemoryRepo[*examplePost]()
m := NewModule(&examplePost{},
	At("/posts"),
	Repo(repo),
	On(AfterPublish, func(_ Context, p *examplePost) error {
		_ = p.Title // access typed fields
		return nil
	}),
)

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.Content(m)
_ = app.Handler()

func Read

func Read(r Role) Option

Read returns an Option that restricts read (list + show) access to users whose role satisfies the required role. Wired in Step 10 (module.go).

func Redirects

func Redirects(from From, to string) Option

Redirects returns a module Option that registers a 301 prefix redirect from old to to. Use it when renaming a module's URL prefix so all inbound links are preserved automatically:

app.Content(&BlogPost{},
    forge.At("/articles"),
    forge.Redirects(forge.From("/posts"), "/articles"),
)

func Repo

func Repo[T any](r Repository[T]) Option

Repo returns an Option that provides the Repository for a Module. This is called internally by App.Content. In unit tests pass a MemoryRepo:

forge.Repo(forge.NewMemoryRepo[*Post]())

func Social

func Social(features ...SocialFeature) Option

Social returns an Option that documents which social sharing tag sets a module emits. The forge:head partial always renders Open Graph and Twitter Card tags when [Head.Title] is non-empty — Social() is declarative metadata that makes intent explicit at the call site.

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.Social(forge.OpenGraph, forge.TwitterCard),
)

To customise per-item Twitter output, set [Head.Social] on the content type's Head() method:

func (p *BlogPost) Head() forge.Head {
    return forge.Head{
        // ...
        Social: forge.SocialOverrides{
            Twitter: forge.TwitterMeta{
                Card:    forge.SummaryLargeImage,
                Creator: "@alice",
            },
        },
    }
}
Example

ExampleSocial demonstrates enabling Open Graph and Twitter Card metadata on a content module. Head fields (Title, Description, Image) are sourced from the content type's Head() method automatically (Amendment A28).

repo := NewMemoryRepo[*examplePost]()
m := NewModule(&examplePost{},
	At("/posts"),
	Repo(repo),
	Social(OpenGraph, TwitterCard),
)

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.Content(m)
_ = app.Handler()

func Templates

func Templates(dir string) Option

Templates returns an Option that sets the directory containing HTML templates for a module. The directory must contain list.html and show.html; if either file is absent App.Run returns an error before the server starts.

Template files are parsed once at startup. The expected layout is:

{dir}/list.html        — rendered for GET /{prefix}
{dir}/show.html        — rendered for GET /{prefix}/{slug}
{dir}/errors/404.html  — (optional) custom error page for 404 responses

Use TemplatesOptional during development when template files are added incrementally.

func TemplatesOptional

func TemplatesOptional(dir string) Option

TemplatesOptional returns an Option that sets the template directory but treats absent files as a silent no-op. HTML content negotiation is only enabled for a handler when its corresponding template file is found.

Use this during development when templates are added incrementally.

func TrustedProxy

func TrustedProxy() Option

TrustedProxy returns an Option for RateLimit that reads the real client IP from X-Real-IP or X-Forwarded-For headers instead of r.RemoteAddr. Use this when the application runs behind a reverse proxy (nginx, Caddy, load balancer).

func WithoutID

func WithoutID() Option

WithoutID returns an Option that omits the id: line from AIDoc output. Apply alongside AIIndex when content UUIDs must not be exposed to AI consumers.

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.AIIndex(forge.AIDoc),
    forge.WithoutID(),
)

func Write

func Write(r Role) Option

Write returns an Option that restricts write (create + update) access to users whose role satisfies the required role. Wired in Step 10 (module.go).

type OrganizationDetails

type OrganizationDetails struct {
	Name        string
	URL         string
	Description string
}

OrganizationDetails carries the extra fields required for Organization rich results.

type OrganizationProvider

type OrganizationProvider interface{ OrganizationDetails() OrganizationDetails }

OrganizationProvider is implemented by content types that supply organization structured data.

type PageHead struct {
	// Head carries SEO and social metadata for this page.
	Head Head

	// OGDefaults holds the app-level Open Graph and Twitter Card fallback values.
	OGDefaults *OGDefaults

	// AppSchema is a pre-rendered <script type="application/ld+json"> block
	// for app-level structured data.
	AppSchema template.HTML

	// HeadAssets holds the app-level static assets (preconnect, stylesheets,
	// favicons, scripts) set via [App.SEO] with [HeadAssets].
	HeadAssets *HeadAssets
}

PageHead holds the framework-owned fields that [forge:head] reads. Embed PageHead in any custom handler data struct to enable {{template "forge:head" .}} without using TemplateData.

Example:

type homeData struct {
    forge.PageHead
    Posts []*Post
}

func homeHandler(app *forge.App) http.HandlerFunc {
    tmpl := app.MustParseTemplate("templates/home.html")
    return func(w http.ResponseWriter, r *http.Request) {
        data := homeData{
            PageHead: forge.PageHead{Head: forge.Head{Title: "Home"}},
            Posts:    loadPosts(),
        }
        tmpl.ExecuteTemplate(w, "home.html", data)
    }
}
Example

ExamplePageHead demonstrates embedding PageHead in a custom handler data struct to enable {{template "forge:head" .}} without using TemplateData.

type homeData struct {
	PageHead
	Featured string
}

data := homeData{
	PageHead: PageHead{
		Head: Head{Title: "Home — My Site"},
	},
	Featured: "Welcome post",
}

// data.Head, data.OGDefaults, data.AppSchema, and data.HeadAssets are all
// accessible at the top level of homeData because PageHead is embedded
// anonymously. forge:head reads them identically to TemplateData[T].
_ = data.Head.Title // "Home — My Site"

type RecipeDetails

type RecipeDetails struct {
	Ingredients []string
	Steps       []HowToStep
}

RecipeDetails carries the extra fields required for Recipe rich results.

type RecipeProvider

type RecipeProvider interface{ RecipeDetails() RecipeDetails }

RecipeProvider is implemented by content types that supply recipe structured data.

type RedirectCode

type RedirectCode int

RedirectCode is the HTTP status code issued for a redirect entry. Use Permanent (301) for URL changes that search engines should follow and update, and Gone (410) for content that has been intentionally removed. 410 signals de-indexing significantly faster than 404.

const (
	// Permanent issues a 301 Moved Permanently response.
	// Use when the resource has moved to a new URL and the change is final.
	Permanent RedirectCode = http.StatusMovedPermanently

	// Gone issues a 410 Gone response.
	// Use when the resource has been intentionally removed.
	// Pass an empty string as the destination to [App.Redirect].
	Gone RedirectCode = http.StatusGone
)

type RedirectEntry

type RedirectEntry struct {
	From     string       // absolute path to match
	To       string       // destination path; empty = 410 Gone
	Code     RedirectCode // Permanent (301) or Gone (410)
	IsPrefix bool         // prefix-rewrite semantics (Decision 17 amendment)
}

RedirectEntry describes a single redirect rule. Obtain entries via App.Redirect or the Redirects module option; do not construct them directly in production code unless building a custom migration tool.

  • From is the absolute request path that triggers the rule, e.g. "/posts/hello".
  • To is the destination path. An empty To with Code == Gone issues 410.
  • IsPrefix, when true, matches any path whose prefix equals From and rewrites the suffix onto To at request time — a single entry covers an entire renamed module prefix with zero per-request allocations beyond the destination string concatenation.

type RedirectStore

type RedirectStore struct {
	// contains filtered or unexported fields
}

RedirectStore holds the runtime redirect table. Exact lookups are O(1) map reads; prefix lookups iterate a short slice sorted longest-first, ending on the first match. The store is safe for concurrent use.

func NewRedirectStore

func NewRedirectStore() *RedirectStore

NewRedirectStore returns an empty RedirectStore ready for use.

func (*RedirectStore) Add

func (s *RedirectStore) Add(e RedirectEntry)

Add registers e in the store. For exact entries, if e.To is already the From of an existing entry the chain is collapsed (A→B + B→C = A→C). The maximum collapse depth is 10; exceeding it panics with a descriptive message (Decision 24). Gone entries are never collapsed through — a Gone destination is terminal.

For prefix entries (e.IsPrefix == true) the entry is appended to the prefix slice which is then re-sorted descending by len(From) to ensure longest-prefix-first lookup.

func (*RedirectStore) All

func (s *RedirectStore) All() []RedirectEntry

All returns a deterministically sorted slice of all registered entries (exact + prefix), sorted ascending by From. Intended for manifest serialisation.

func (*RedirectStore) Get

func (s *RedirectStore) Get(path string) (RedirectEntry, bool)

Get returns the RedirectEntry matching path, or (RedirectEntry{}, false) when no rule applies. Exact entries are checked first; if no exact match is found the prefix slice is scanned longest-first.

func (*RedirectStore) Len

func (s *RedirectStore) Len() int

Len returns the total number of registered entries (exact + prefix).

func (*RedirectStore) Load

func (s *RedirectStore) Load(ctx context.Context, db DB) error

Load reads all rows from the forge_redirects table and registers them via RedirectStore.Add. Chain collapse and validation rules are applied during load. The forge_redirects table must exist — see the README for the schema.

func (*RedirectStore) Remove

func (s *RedirectStore) Remove(ctx context.Context, db DB, from string) error

Remove deletes the entry with the given from path from the forge_redirects table. The forge_redirects table must exist — see the README for the schema.

func (*RedirectStore) Save

func (s *RedirectStore) Save(ctx context.Context, db DB, e RedirectEntry) error

Save upserts e into the forge_redirects table. The forge_redirects table must exist — see the README for the schema.

type Registrator

type Registrator interface {
	Register(mux *http.ServeMux)
}

Registrator is implemented by any value that can register its HTTP routes on a http.ServeMux. *Module satisfies this interface automatically.

Pass a pre-built *Module to App.Content to register it:

posts := forge.NewModule[*Post](&Post{}, forge.Repo(repo))
app.Content(posts)

type Repository

type Repository[T any] interface {
	FindByID(ctx context.Context, id string) (T, error)
	FindBySlug(ctx context.Context, slug string) (T, error)
	FindAll(ctx context.Context, opts ListOptions) ([]T, error)
	Save(ctx context.Context, node T) error
	Delete(ctx context.Context, id string) error
}

Repository is the storage interface for a content type. Implement it to provide a custom storage backend. Use NewMemoryRepo for in-process testing and prototyping.

type ReviewDetails

type ReviewDetails struct {
	Body        string
	Rating      float64
	BestRating  float64
	WorstRating float64
}

ReviewDetails carries the extra fields required for Review rich results.

type ReviewProvider

type ReviewProvider interface{ ReviewDetails() ReviewDetails }

ReviewProvider is implemented by content types that supply review structured data.

type RobotsConfig

type RobotsConfig struct {
	// Disallow lists URL paths to block for all crawlers (e.g. "/admin").
	Disallow []string

	// Sitemaps appends a Sitemap directive pointing to <baseURL>/sitemap.xml
	// when true. Requires a non-empty baseURL on [App].
	Sitemaps bool

	// AIScraper sets the AI crawler policy. Defaults to [Allow] when zero.
	AIScraper CrawlerPolicy
}

RobotsConfig configures the auto-generated robots.txt. Pass a pointer to App.SEO to register the /robots.txt endpoint:

app.SEO(&forge.RobotsConfig{
    AIScraper: forge.AskFirst,
    Sitemaps:  true,
})
Example

ExampleRobotsConfig demonstrates configuring robots.txt with an explicit disallow list, automatic sitemap inclusion, and an AI crawler policy of AskFirst — which disallows known AI training crawlers by name.

app := New(Config{
	BaseURL: "https://example.com",
	Secret:  []byte("example-secret-key-32-bytes!!!!!"),
})
app.SEO(&RobotsConfig{
	Disallow:  []string{"/admin"},
	Sitemaps:  true,
	AIScraper: AskFirst,
})
_ = app.Handler()

type Role

type Role string

Role is a named permission level. The four built-in roles cover most applications; custom roles can be registered via NewRole.

Roles are stored as plain strings in tokens and sessions. The numeric level is derived at runtime via a registry lookup, not stored with the role name.

const (
	// Guest is the implicit role for unauthenticated requests (level 1).
	Guest Role = "guest"
	// Author can create and manage their own content (level 2).
	Author Role = "author"
	// Editor can manage all content (level 3).
	Editor Role = "editor"
	// Admin has full access including app configuration (level 4).
	Admin Role = "admin"
)

Built-in role constants in ascending permission order.

type SEOOption

type SEOOption interface {
	// contains filtered or unexported methods
}

SEOOption is implemented by any value that modifies the app-level SEO configuration. Pass SEOOption values to App.SEO:

app.SEO(&forge.RobotsConfig{AIScraper: forge.AskFirst, Sitemaps: true})

type SQLRepo

type SQLRepo[T any] struct {
	// contains filtered or unexported fields
}

SQLRepo is a production Repository[T] backed by forge.DB. T must embed forge.Node — its fields are mapped to SQL columns via `db` struct tags, falling back to lowercase field names (the same rules as Query and QueryOne).

All queries use $N positional placeholders (PostgreSQL / pgx compatible). Use the Table option to override the automatically derived table name.

func NewSQLRepo

func NewSQLRepo[T any](db DB, opts ...SQLRepoOption) *SQLRepo[T]

NewSQLRepo returns a SQLRepo[T] ready for use. The table name is derived automatically from T (e.g. BlogPost → "blog_posts"); pass Table to override.

T must be a pointer type and must match the proto passed to NewModule:

repo := forge.NewSQLRepo[*Post](db)
m := forge.NewModule((*Post)(nil), forge.Repo(repo))

Using a value type (NewSQLRepo[Post]) will compile but will not satisfy Repository[*Post] — the type parameters must match throughout.

func (*SQLRepo[T]) Delete

func (r *SQLRepo[T]) Delete(ctx context.Context, id string) error

Delete removes the item with the given id. Returns ErrNotFound if no row was deleted.

func (*SQLRepo[T]) FindAll

func (r *SQLRepo[T]) FindAll(ctx context.Context, opts ListOptions) ([]T, error)

FindAll returns items matching opts. Status filter, ordering, and pagination are translated to SQL WHERE / ORDER BY / LIMIT OFFSET clauses.

func (*SQLRepo[T]) FindByID

func (r *SQLRepo[T]) FindByID(ctx context.Context, id string) (T, error)

FindByID returns the item with the given id, or ErrNotFound.

func (*SQLRepo[T]) FindBySlug

func (r *SQLRepo[T]) FindBySlug(ctx context.Context, slug string) (T, error)

FindBySlug returns the item with the given slug, or ErrNotFound.

func (*SQLRepo[T]) Save

func (r *SQLRepo[T]) Save(ctx context.Context, item T) error

Save upserts item into the table. UpdatedAt is set to the current UTC time. CreatedAt is set only when its current value is the zero time. The upsert key is the "id" column.

type SQLRepoOption

type SQLRepoOption interface {
	// contains filtered or unexported methods
}

SQLRepoOption configures a SQLRepo. Obtain values via Table.

func Table

func Table(name string) SQLRepoOption

Table returns a SQLRepoOption that overrides the automatically derived table name for a SQLRepo. Use it when the default snake_case plural derivation does not produce the correct name.

repo := forge.NewSQLRepo[BlogPost](db, forge.Table("posts"))

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler drives the Scheduled→Published transition for all content modules registered with App.Content. A single background goroutine runs with an adaptive timer: after each tick the timer is reset to fire at the soonest remaining ScheduledAt across all modules, falling back to 60 seconds when no scheduled items exist.

The Scheduler is created and started by App.Run and stopped as part of graceful shutdown. Applications do not create Schedulers directly.

func (*Scheduler) Start

func (s *Scheduler) Start(ctx context.Context)

Start spawns the scheduler goroutine. The goroutine exits when ctx is cancelled. Call Scheduler.Wait after cancellation to block until the goroutine has fully exited.

func (*Scheduler) Wait

func (s *Scheduler) Wait()

Wait blocks until the goroutine started by Scheduler.Start has exited. It should be called after cancelling the context passed to Start to ensure clean shutdown.

type ScriptTag added in v1.3.0

type ScriptTag struct {
	Src   string      // external script URL; empty means inline
	Body  template.JS // inline JavaScript body; used when Src is empty
	Async bool        // adds async attribute (external scripts only)
	Defer bool        // adds defer attribute (external scripts only)
}

ScriptTag declares a single <script> element. Src loads an external script; Body inlines a JavaScript body when Src is empty. Body is typed as html/template.JS — convert a string literal with template.JS("…") to mark it as safe for emission inside a <script> block; never use this with user-supplied content. Async and Defer are only emitted for external scripts (Src non-empty).

type Signal

type Signal string

Signal identifies a lifecycle event fired by a content module. Handlers are registered with On and receive the content value as their concrete type T — no type assertion required.

const (
	// BeforeCreate fires before a new content item is persisted.
	// Return an error to abort the operation.
	BeforeCreate Signal = "before_create"

	// AfterCreate fires after a new content item has been persisted.
	// Runs asynchronously — errors and panics are logged, never returned.
	AfterCreate Signal = "after_create"

	// BeforeUpdate fires before an existing content item is updated.
	// Return an error to abort the operation.
	BeforeUpdate Signal = "before_update"

	// AfterUpdate fires after a content item has been updated.
	// Runs asynchronously — errors and panics are logged, never returned.
	AfterUpdate Signal = "after_update"

	// BeforeDelete fires before a content item is deleted.
	// Return an error to abort the operation.
	BeforeDelete Signal = "before_delete"

	// AfterDelete fires after a content item has been deleted.
	// Runs asynchronously — errors and panics are logged, never returned.
	AfterDelete Signal = "after_delete"

	// AfterPublish fires after a content item transitions to Published.
	// Runs asynchronously — triggers sitemap and feed regeneration.
	AfterPublish Signal = "after_publish"

	// AfterUnpublish fires after a content item is moved out of Published status.
	// Runs asynchronously — triggers sitemap and feed regeneration.
	AfterUnpublish Signal = "after_unpublish"

	// AfterArchive fires after a content item transitions to Archived.
	// Runs asynchronously — triggers sitemap and feed regeneration.
	AfterArchive Signal = "after_archive"

	// SitemapRegenerate is fired internally after AfterPublish, AfterUnpublish,
	// AfterArchive, and AfterDelete. It is debounced to coalesce burst changes
	// into a single sitemap and feed rebuild.
	SitemapRegenerate Signal = "sitemap_regenerate"
)

Lifecycle signals fired by content modules.

type SitemapConfig

type SitemapConfig struct {
	// ChangeFreq is the expected update frequency for URLs in this module.
	// Defaults to [Weekly] when empty.
	ChangeFreq ChangeFreq

	// Priority is the relative importance of URLs in this module, in the range
	// 0.0–1.0. Defaults to 0.5 when zero or negative.
	Priority float64
}

SitemapConfig configures the per-module sitemap fragment. Pass it to App.Content as an option alongside At, Cache, and similar options.

app.Content(posts, forge.SitemapConfig{ChangeFreq: forge.Weekly, Priority: 0.8})

ChangeFreq defaults to Weekly when zero. Priority defaults to 0.5 when zero or negative.

type SitemapEntry

type SitemapEntry struct {
	// Loc is the canonical URL of the page.
	Loc string

	// LastMod is the date-time the content was last modified. It is formatted
	// as a date-only string (YYYY-MM-DD) in the output. Zero value is omitted.
	LastMod time.Time

	// ChangeFreq is the expected update frequency. Defaults to [Weekly].
	ChangeFreq ChangeFreq

	// Priority is the relative importance, 0.0–1.0. Defaults to 0.5.
	Priority float64
}

SitemapEntry is a single URL entry in a sitemap fragment. A zero LastMod is omitted from the XML output.

func SitemapEntries

func SitemapEntries[T SitemapNode](items []T, baseURL string, cfg SitemapConfig) []SitemapEntry

SitemapEntries builds a slice of SitemapEntry values from items, applying the rules in cfg. Only Published items are included.

Loc is taken from [Head.Canonical]; if empty it falls back to strings.TrimRight(baseURL, "/") + "/" + item.GetSlug().

ChangeFreq defaults to Weekly when cfg.ChangeFreq is empty. Priority is taken from SitemapPrioritiser if implemented, then from cfg.Priority if positive, otherwise defaults to 0.5.

type SitemapNode

type SitemapNode interface {
	Headable
	GetSlug() string
	GetPublishedAt() time.Time
	GetStatus() Status
}

SitemapNode is the type constraint for SitemapEntries. It is satisfied by any pointer to a struct that embeds Node and implements Headable. All Forge content types that embed Node satisfy this constraint automatically after Amendment A2.

type SitemapPrioritiser

type SitemapPrioritiser interface {
	SitemapPriority() float64
}

SitemapPrioritiser may be implemented by content types to provide a per-item priority override in the sitemap. When not implemented, [SitemapConfig.Priority] is used (defaulting to 0.5).

type SitemapStore

type SitemapStore struct {
	// contains filtered or unexported fields
}

SitemapStore holds the latest generated sitemap fragments in memory. Forge populates it automatically via the debouncer on every publish/unpublish event. It is safe for concurrent use by multiple goroutines.

func NewSitemapStore

func NewSitemapStore() *SitemapStore

NewSitemapStore returns an initialised, empty SitemapStore.

func (*SitemapStore) Get

func (s *SitemapStore) Get(path string) ([]byte, bool)

Get returns the stored bytes for path and whether the path exists.

func (*SitemapStore) Handler

func (s *SitemapStore) Handler() http.Handler

Handler returns an http.Handler that serves stored fragment bytes by request path. Responds with 404 when the path has no stored fragment. Content-Type is set to application/xml; charset=utf-8.

func (*SitemapStore) IndexHandler

func (s *SitemapStore) IndexHandler(baseURL string) http.Handler

IndexHandler returns an http.Handler that generates the sitemap index on each request from all currently stored fragment paths. baseURL is prepended to each path to form the full fragment URL (e.g. "https://example.com/posts/sitemap.xml").

func (*SitemapStore) Paths

func (s *SitemapStore) Paths() []string

Paths returns a sorted slice of all stored fragment paths. Used by SitemapStore.IndexHandler to enumerate fragments when building the index.

func (*SitemapStore) Set

func (s *SitemapStore) Set(path string, data []byte)

Set stores a copy of data keyed by path (e.g. "/posts/sitemap.xml"). Subsequent calls replace the previous value for the same path.

type SocialFeature

type SocialFeature int

SocialFeature selects which social sharing meta tags forge:head emits for a module. Use the predefined constants OpenGraph and TwitterCard.

const (
	// OpenGraph enables Open Graph meta tags (og:title, og:description,
	// og:image, og:type, og:url, and article:* for Article content).
	OpenGraph SocialFeature = 1

	// TwitterCard enables Twitter Card meta tags (twitter:card, twitter:title,
	// twitter:description, twitter:image, twitter:creator).
	TwitterCard SocialFeature = 2
)

type SocialOverrides

type SocialOverrides struct {
	Twitter TwitterMeta // Twitter Card overrides for this item
}

SocialOverrides carries per-item social sharing overrides. Set on [Head.Social] to customise Open Graph and Twitter Card output.

type Status

type Status string

Status is the content lifecycle state. All content types embed Node and therefore always carry a Status. Forge enforces lifecycle rules on all public endpoints — non-Published content is never publicly visible.

const (
	// Draft is the default state for newly created content. Not publicly visible.
	Draft Status = "draft"

	// Published content is publicly visible and included in sitemaps, feeds,
	// and AI indexes.
	Published Status = "published"

	// Scheduled content will be automatically transitioned to Published at
	// [Node.ScheduledAt]. Not publicly visible until the transition fires.
	Scheduled Status = "scheduled"

	// Archived content has been retired. Not publicly visible. Does not appear
	// in sitemaps or feeds. Returns 410 Gone from public endpoints.
	Archived Status = "archived"
)

type TemplateData

type TemplateData[T any] struct {
	// PageHead promotes Head, OGDefaults, AppSchema, and HeadAssets to the
	// top level of TemplateData. Templates access them as .Head, .OGDefaults,
	// .AppSchema, and .HeadAssets — identical to before embedding was used.
	PageHead

	// Content is the page payload — a single item for show templates,
	// a slice for list templates.
	Content T

	// User is the authenticated user for this request. Zero value ([GuestUser])
	// when the request is unauthenticated.
	User User

	// Request is the live *http.Request for this response. Use it in
	// templates for URL introspection, query parameters, or helpers that
	// require the request (e.g. [forge_csrf_token]).
	Request *http.Request

	// SiteName is the hostname extracted from [Config.BaseURL] at module
	// registration time (e.g. "example.com"). Uses the hostname rather than
	// [Context.SiteName] because SiteName() always returns "" in v1.
	SiteName string
}

TemplateData is the value passed to every HTML template rendered by Forge. T is the content type for show handlers (e.g. *BlogPost) or a slice type for list handlers (e.g. []*BlogPost).

The framework-owned head fields (Head, OGDefaults, AppSchema, HeadAssets) are promoted from the embedded PageHead field and remain accessible at the top level of the struct — existing template calls like {{.Head.Title}} are unchanged.

To use {{template "forge:head" .}} in a custom handler without TemplateData, embed PageHead directly in your own data struct:

type homeData struct {
    forge.PageHead
    Posts []*Post
}

Show handler:

TemplateData[*BlogPost]{
    PageHead: forge.PageHead{Head: post.Head()},
    Content:  post,
    User:     ctx.User(),
    Request:  r,
    SiteName: "example.com",
}

In templates:

{{template "forge:head" .}}
<h1>{{.Content.Title}}</h1>
<p>Welcome, {{.User.Name}}</p>

func NewTemplateData

func NewTemplateData[T any](ctx Context, content T, head Head, siteName string) TemplateData[T]

NewTemplateData constructs a TemplateData[T] for the given context, content, merged head, and site name.

siteName should be the hostname extracted from [Config.BaseURL] (e.g. "example.com"), set once at module registration.

type TwitterCardType

type TwitterCardType string

TwitterCardType is the value of the twitter:card meta property. Use the predefined constants Summary, SummaryLargeImage, AppCard, PlayerCard.

const (
	Summary           TwitterCardType = "summary"             // small card with title and description
	SummaryLargeImage TwitterCardType = "summary_large_image" // large image above the title
	AppCard           TwitterCardType = "app"                 // deep-link to a mobile app
	PlayerCard        TwitterCardType = "player"              // inline video or audio player
)

type TwitterMeta

type TwitterMeta struct {
	Card    TwitterCardType // overrides the default card type; empty uses a sensible default
	Creator string          // @handle of the content author; populates twitter:creator
}

TwitterMeta carries per-item Twitter Card overrides. Set on [Head.Social] to customise Twitter Card output for a specific content item.

type User

type User struct {
	// ID is the user's stable UUID. Empty for unauthenticated guests.
	ID string

	// Name is the display name. Empty for unauthenticated guests.
	Name string

	// Roles is the set of roles held by this user. Forge's hierarchical
	// permission checks ([HasRole], [IsRole]) operate on this slice.
	Roles []Role
}

User represents an authenticated identity. The zero value is an unauthenticated guest — equivalent to GuestUser. See Amendment R3.

The User type is declared here (context.go) rather than auth.go because [Context.User] returns it and context.go is in a lower dependency layer than auth.go. auth.go adds authentication machinery on top of this type.

func VerifyBearerToken added in v1.1.0

func VerifyBearerToken(r *http.Request, secret []byte) (User, bool)

VerifyBearerToken extracts and verifies the HMAC-signed bearer token from r's Authorization header. It returns the authenticated User and true on success, or GuestUser and false if the header is absent, malformed, or the signature is invalid. secret must be the same value used to sign the token with SignToken. This is the public counterpart to the unexported authenticate method on BearerHMAC and is intended for use outside the forge package (e.g. forge-mcp SSE transport) where AuthFunc is not directly callable.

func (User) HasRole

func (u User) HasRole(role Role) bool

HasRole reports whether the user holds at least the given role level. This is hierarchical: an Admin satisfies HasRole(forge.Editor). Delegates to the free function HasRole in roles.go.

func (User) Is

func (u User) Is(role Role) bool

Is reports whether the user holds exactly the given role (exact match only). An Admin does not satisfy Is(forge.Editor). Delegates to the free function IsRole in roles.go.

type Validatable

type Validatable interface {
	Validate() error
}

Validatable is implemented by content types that have business-rule validation beyond struct-tag constraints. RunValidation calls Validate() after tag validation passes — if tags fail, Validate() is not called.

func (p *BlogPost) Validate() error {
    if p.Status == forge.Published && len(p.Tags) == 0 {
        return forge.Err("tags", "required when publishing")
    }
    return nil
}

type ValidationError

type ValidationError struct {
	// contains filtered or unexported fields
}

ValidationError is returned when one or more fields fail validation. It implements forge.Error with HTTP status 422.

Create with Err for a single field, or Require to collect several.

func Err

func Err(field, message string) *ValidationError

Err returns a ValidationError for a single field. The returned error implements forge.Error and will produce a 422 response with field details.

return forge.Err("title", "required")

func (*ValidationError) Code

func (e *ValidationError) Code() string

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error returns a human-readable summary of all validation failures.

func (*ValidationError) HTTPStatus

func (e *ValidationError) HTTPStatus() int

func (*ValidationError) Public

func (e *ValidationError) Public() string

Directories

Path Synopsis
forge-mcp module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL