forge

package module
v1.14.1 Latest Latest
Warning

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

Go to latest
Published: May 2, 2026 License: AGPL-3.0 Imports: 35 Imported by: 0

README

Forge

A Go framework for content-driven applications. Zero dependencies. AI-native by default.

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

30-second start

git clone https://github.com/forge-cms/forge
cd example/blog
go run .
# open http://localhost:8080

What Forge gives you

Content

  • Full CRUD — create, update, publish, archive, and delete through a single Module[T]
  • Draft-safe lifecycle — drafts return 404 to guests; only Published content is visible
  • Scheduled publishing — set a future ScheduledAt; Forge transitions to Published automatically
  • Content negotiation — one endpoint serves JSON, HTML, or Markdown based on Accept

Auth & security

  • Role-based auth — Guest → Author → Editor → Admin enforced per-module, per-operation
  • Cookie compliance/.well-known/cookies.json declares cookie categories for GDPR tooling
  • Security headers — CSP, HSTS, X-Frame-Options wired in one middleware call

Discovery

  • Structured data (JSON-LD) — Article, Product, FAQ, and more emitted in every page <head>
  • Event-driven sitemap — regenerated on publish, update, and delete; no cron job required
  • Open Graph — og:title, og:description, og:image meta tags for social link previews
  • Twitter Cards — twitter: meta tags with summary and summary_large_image support
  • RSS feed — per-module feed at /{prefix}/feed.xml plus a global aggregate at /feed.xml

AI-native

  • AI indexing/llms.txt compact index, /llms-full.txt Markdown corpus, and per-item /aidoc endpoints
  • MCP integration — connect AI agents to read and write content via the Model Context Protocol

Infrastructure

  • Graceful shutdown — drains in-flight requests before exiting on SIGINT/SIGTERM

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

Installation

go get forge-cms.dev/forge

Requires Go 1.22+. No other dependencies.


Minimal example

Define a content type, wire it, run it.

package main

import (
	"log"

	"forge-cms.dev/forge"
)

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

func (p *Post) Head() forge.Head {
	return forge.Head{
		Title:       p.Title,
		Description: forge.Excerpt(p.Body, 160),
	}
}

func (p *Post) Markdown() string { return p.Body }

func main() {
	repo := forge.NewMemoryRepo[*Post]()
	m := forge.NewModule((*Post)(nil), // nil pointer — type parameter inferred, no allocation
		forge.At("/posts"),
		forge.Repo(repo),
		forge.Auth(forge.Read(forge.Guest), forge.Write(forge.Author)),
	)
	app := forge.New(forge.MustConfig(forge.Config{
		BaseURL: "http://localhost:8080",
		Secret:  []byte("change-this-secret-in-production"),
	}))
	app.Content(m)
	if err := app.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

Routes: GET /posts, GET /posts/{slug}, POST /posts, PUT /posts/{slug}, DELETE /posts/{slug}, GET /sitemap.xml. Draft posts return 404 for guests.


Full feature showcase

Same content type. Add options — each line unlocks a production feature.

m := forge.NewModule((*Post)(nil),
	forge.At("/posts"),
	forge.Repo(forge.NewMemoryRepo[*Post]()),
	forge.Auth(forge.Read(forge.Guest), forge.Write(forge.Author)),
	forge.SitemapConfig{ChangeFreq: forge.Weekly, Priority: 0.8}, // /posts/sitemap.xml
	forge.Social(forge.OpenGraph, forge.TwitterCard),              // og: and twitter: meta tags
	forge.AIIndex(forge.LLMsTxt, forge.LLMsTxtFull, forge.AIDoc), // /llms.txt + /posts/{slug}/aidoc
	forge.Feed(forge.FeedConfig{Title: "My Blog"}),               // /posts/feed.xml + /feed.xml
	forge.Templates("templates/posts"),                           // HTML at Accept: text/html
	forge.On(forge.AfterPublish, func(_ forge.Context, p *Post) error {
		log.Printf("published: %s", p.Slug) // fires on publish and scheduled→Published
		return nil
	}),
)

Three runnable examples are in example/:

  • example/blog — devlog with seeded posts, RSS, AI indexing, and scheduled publishing
  • example/api — headless JSON API with role-based auth and a redirect manifest
  • example/docs — documentation site with AI indexing, /llms.txt, and AIDoc endpoints

Each runs with: cd example/blog && go run .


Reference

Full API reference: REFERENCE.md
Web docs: forge-cms.dev/docs


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")

	// ErrLastAdmin is returned by [TokenStore.Revoke] when the token being
	// revoked is the last active (non-revoked, non-expired) admin token. → 409
	ErrLastAdmin = newSentinel(http.StatusConflict, "last_admin", "Cannot revoke the last active admin token")

	// 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

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_html         — trusted raw HTML passthrough: {{.Content.Embed | forge_html}}
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) Config

func (a *App) Config() Config

Config returns a copy of the application configuration. It is intended for use by companion packages (such as forge-media) that need access to configuration fields — [Config.BaseURL], [Config.MediaPath], [Config.MediaMaxSize] — without the host application repeating those values at the call site. The returned value is a copy and cannot be used to mutate the running configuration.

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

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

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

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) Nav

func (a *App) Nav(items ...NavItem)

Nav registers navigation items for NavModeCode. Calling Nav implicitly activates code-mode navigation — no database table is created or read. Items are deep-copied at App.Handler time; the caller's slice is not retained after that point.

Nav must be called before App.Handler or App.Run.

func (*App) NavTree

func (a *App) NavTree() *NavTree

NavTree returns the application's NavTree, or nil when navigation has not been configured. NavTree is non-nil only after App.Handler or App.Run has been called.

forge-mcp calls this in its New constructor to wire nav tools.

func (*App) Partials

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

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) TokenStore

func (a *App) TokenStore() *TokenStore

TokenStore returns the configured TokenStore for database-backed named token management, or nil when token management is not configured. forge-mcp calls this in its New constructor to wire token tools and revocation checks.

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

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

	// TokenStore enables database-backed named bearer token management. When
	// set, [VerifyBearerToken] checks the forge_tokens table to validate that
	// a token has not been revoked. The forge_tokens table must exist before
	// the application starts; see [TokenStore] for the required DDL.
	// Optional — leave nil to use HMAC-only token validation.
	TokenStore *TokenStore

	// 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

	// NavMode selects how the application's navigation tree is populated.
	// Use [NavModeDB] to persist navigation items in the forge_nav database
	// table (requires [Config.DB]; panics at startup if DB is nil).
	// Use [NavModeCode] to supply items directly via [App.Nav].
	// The zero value disables navigation entirely. Optional.
	NavMode NavMode

	// AppSchema sets the app-level JSON-LD structured data block emitted on
	// every page as a <script type="application/ld+json"> element. When set
	// here, it is applied automatically at startup; an explicit [App.SEO] call
	// with an [AppSchema] takes precedence. This field is also populated when
	// a forge.config file contains the org_name or org_type keys. Optional.
	AppSchema *AppSchema

	// OGDefaults sets the app-level Open Graph and Twitter Card fallback values
	// applied to every page. When set here, it is applied automatically at
	// startup; an explicit [App.SEO] call with an [OGDefaults] takes
	// precedence. Also populated from forge.config og_image and twitter_site
	// keys. Optional.
	OGDefaults *OGDefaults

	// MediaPath is the filesystem directory where forge-media stores uploaded
	// files. Defaults to "./media" when zero. Optional — only read by
	// forge-media; ignored by forge core.
	MediaPath string

	// MediaMaxSize is the maximum permitted upload size in bytes. Defaults to
	// 5242880 (5 MB) when zero. Optional — only read by forge-media; ignored
	// by forge core.
	MediaMaxSize int64
}

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

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 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

type HeadAssets struct {
	Preconnect  []string    // <link rel="preconnect" href="…">
	Stylesheets []string    // <link rel="stylesheet" href="…">
	Links       []HeadLink  // any <link> element — icons, rel="me", rel="manifest", etc.
	Scripts     []ScriptTag // <script …>
}

HeadAssets is an SEOOption that injects static linked assets — preconnect hints, stylesheets, link elements, 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"},
    Links: []forge.HeadLink{
        {Rel: "icon", Type: "image/png", Sizes: "32x32", Href: "/favicon-32.png"},
        {Rel: "me", Href: "https://mastodon.social/@you"},
    },
    Scripts: []forge.ScriptTag{
        {Src: "/static/app.js", Defer: true},
    },
})

Assets are emitted in order: preconnect → stylesheets → links → 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"},
	Links: []HeadLink{
		{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 HeadLink struct {
	Rel   string // e.g. "icon", "apple-touch-icon", "me", "manifest"
	Type  string // MIME type, e.g. "image/png"; omitted when empty
	Sizes string // e.g. "32x32"; omitted when empty
	Href  string // URL for the href attribute
}

HeadLink declares a single HTML <link> element. Use it for any link relationship: favicons, touch icons, rel="me" profile verification, rel="manifest", or any other rel value. Rel and Href are required; Type and Sizes are optional and omitted when empty.

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

type MCPField struct {
	Name        string // Go field name
	JSONName    string // lowercase snake_case name used in MCP messages
	Type        string // "string" | "number" | "boolean" | "datetime"
	Format      string // "" when no forge_format tag present; e.g. "markdown", "html"
	Description string // "" when no forge_description tag present; free-text authoring guidance
	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].

The optional [MCPField.Format] and [MCPField.Description] fields are populated from the forge_format and forge_description struct tags respectively (Decision 27). Both are hints only — Forge performs no validation based on either value.

type MCPMeta

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

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"). Use when the default pluralisation is wrong: Story → "/storys". Example: forge.At("/solved") or forge.At("/stories").
  • 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

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

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

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

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

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

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

MCPMeta returns the MCP registration metadata for this module.

func (*Module[T]) MCPPublish

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

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

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

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

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 NavItem struct {
	// ID is the unique identifier. Generated automatically as a UUIDv7 on
	// create when empty.
	ID string

	// Label is the display text rendered in navigations and breadcrumbs.
	Label string

	// Path is the URL prefix for this item, e.g. "/learn". An empty Path
	// marks the item as a ghost — it has no backing route and is
	// non-clickable everywhere.
	Path string

	// ParentID is the ID of the parent NavItem. Empty for top-level items.
	ParentID string

	// Module is the Forge module table name this item maps to, e.g.
	// "posts". Empty for custom or ghost items not backed by a content module.
	Module string

	// Hidden excludes this item from rendered navigation while keeping it
	// accessible in breadcrumbs. A hidden item is still clickable.
	Hidden bool

	// Ghost marks this item as non-clickable everywhere. Ghost items appear
	// in navigation (unless also Hidden) but have no backing route. Use
	// ghost items as structural grouping nodes.
	Ghost bool

	// SortOrder controls the display order within a parent level. Lower
	// values appear first. Items with equal SortOrder are sorted
	// alphabetically by Label.
	SortOrder int

	// Children holds the item's direct children in SortOrder order.
	// Children is populated in memory during tree construction and is
	// never persisted to the database.
	Children []*NavItem
}

NavItem represents a single entry in the navigation tree. Items are stored in the forge_nav table (NavModeDB) or supplied via App.Nav (NavModeCode).

The Hidden and Ghost flags determine where the item appears:

Hidden=false Ghost=false — shown in navigation; in breadcrumb; clickable
Hidden=true  Ghost=false — hidden from navigation; in breadcrumb; clickable
Hidden=false Ghost=true  — shown in navigation; in breadcrumb; not clickable
Hidden=true  Ghost=true  — hidden from navigation; in breadcrumb; not clickable
type NavMode int

NavMode controls how the application's navigation tree is populated. The zero value means no navigation tree is active.

const (
	// NavModeDB populates the navigation tree from the forge_nav database
	// table. Requires [Config.DB] to be non-nil; panics at startup if DB
	// is nil when this mode is selected.
	NavModeDB NavMode = iota + 1

	// NavModeCode populates the navigation tree from items supplied via
	// [App.Nav]. No database access is performed.
	NavModeCode
)
type NavTree struct {
	// contains filtered or unexported fields
}

NavTree holds the in-memory navigation tree and provides thread-safe access for both reading (templates) and writing (MCP tools).

Obtain a NavTree from App.NavTree after calling App.Handler.

func (n *NavTree) Create(ctx context.Context, item NavItem) (NavItem, error)

Create inserts a new NavItem into the database, rebuilds the in-memory tree, and returns the inserted item with its ID populated. Returns an error when the NavTree is in code mode (NavModeCode).

func (n *NavTree) Delete(ctx context.Context, id string) error

Delete permanently removes the NavItem with the given id and all of its descendants from the database, then rebuilds the in-memory tree. Returns ErrNotFound when no item with that id exists. Returns an error when the NavTree is in code mode (NavModeCode).

func (n *NavTree) Get(id string) (NavItem, bool)

Get returns a copy of the NavItem with the given ID. Returns (NavItem{}, false) when no item with that ID exists.

func (n *NavTree) HasDB() bool

HasDB reports whether the NavTree is backed by a database (NavModeDB). Only when HasDB is true can Create, Update, and Delete be called.

func (n *NavTree) List() []NavItem

List returns a flat slice of all navigation items ordered by SortOrder then Label. Children is always nil in the returned items. Returns nil when the tree is empty.

func (n *NavTree) Tree() []NavItem

Tree returns a deep copy of the root navigation items with their Children populated. The returned slice is safe for concurrent use and modification. Returns nil when the tree is empty.

func (n *NavTree) Update(ctx context.Context, item NavItem) (NavItem, error)

Update replaces the stored NavItem (matched by ID) and rebuilds the in-memory tree. Returns ErrNotFound when no item with item.ID exists. Returns an error when the NavTree is in code mode (NavModeCode).

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

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 ContextFunc

func ContextFunc(fn func(ctx Context, item any) (any, error)) Option

ContextFunc returns an Option that registers a function called at render time for every list and show request. The return value is stored in TemplateData.Extra and is available in templates as .Extra.

Use ContextFunc to supply sidebar data, navigation trees, related items, or any per-request data that the content item itself does not carry:

forge.ContextFunc(func(ctx forge.Context, _ any) (any, error) {
    return docRepo.FindAll(ctx, forge.ListOptions{
        Status: []forge.Status{forge.Published},
    })
})

The item argument is the content item being rendered (T for show, []T for list). Cast it inside the function if the concrete type is needed.

Errors from ContextFunc are logged and Extra is set to nil — they do not abort the render.

Example

ExampleContextFunc demonstrates passing per-request sidebar data to a module show template via ContextFunc. The function is called once per render; its return value is available as .Extra in the template.

type DocPage struct {
	Node
	Title string `forge:"required"`
	Body  string
}

docRepo := NewMemoryRepo[*DocPage]()

m := NewModule((*DocPage)(nil),
	At("/docs"),
	Repo(docRepo),
	ContextFunc(func(ctx Context, _ any) (any, error) {
		// Return all published docs for use as a navigation sidebar.
		return docRepo.FindAll(ctx, ListOptions{
			Status: []Status{Published},
		})
	}),
)

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

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

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 ListHeadFunc added in v1.14.1

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

ListHeadFunc returns an Option that sets the <title> and meta tags for a module's list page. The function receives the current request context and the slice of published items returned by the repository.

app.Content(&BlogPost{},
    forge.At("/posts"),
    forge.ListHeadFunc(func(ctx forge.Context, posts []*BlogPost) forge.Head {
        return forge.Head{Title: "All posts — " + 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,
	// links, 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"
_ = data.Featured   // "Welcome post"

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 — for example, types whose plural is not formed by appending "s" (Story → "storys", not "stories").

repo := forge.NewSQLRepo[*Story](db, forge.Table("stories"))
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

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

	// Extra holds the value returned by the [ContextFunc] option for this
	// request. It is nil when no ContextFunc is configured. Templates access
	// it as {{.Extra}} and may assign it to a typed variable using a template
	// helper or direct assignment:
	//
	//	{{- $nav := .Extra}}
	//	{{template "sidebar" $nav}}
	Extra any

	// Nav holds the top-level navigation items with their Children populated.
	// It is nil when no navigation tree is configured. Templates access it
	// as {{.Nav}} to render site-wide navigation:
	//
	//	{{range .Nav}}
	//	  <a href="{{.Path}}">{{.Label}}</a>
	//	{{end}}
	Nav []NavItem
}

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 TokenRecord

type TokenRecord struct {
	// ID is the SHA-256 hex fingerprint of the raw token. Tokens are never
	// stored in plaintext; only this fingerprint is persisted.
	ID string

	// Name is the human-readable label provided when the token was created.
	Name string

	// Role is the role string assigned to this token (e.g. "author", "editor").
	Role string

	// ExpiresAt is the UTC time after which the token is no longer valid.
	ExpiresAt time.Time

	// RevokedAt is the UTC time at which this token was revoked. A zero value
	// means the token has not been revoked.
	RevokedAt time.Time

	// CreatedAt is the UTC time at which the token was created.
	CreatedAt time.Time
}

TokenRecord is a named bearer token entry stored in the forge_tokens table. Retrieve records with TokenStore.List; revoke with TokenStore.Revoke.

type TokenStore

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

TokenStore manages named, revocable bearer tokens stored in a forge_tokens database table. Use NewTokenStore to create one; wire it into [Config.TokenStore] to activate database-backed token verification.

The forge_tokens table must exist before the application starts. Forge does not create or migrate it automatically. Required DDL:

CREATE TABLE forge_tokens (
    id         TEXT PRIMARY KEY,  -- SHA-256 hex fingerprint of the raw token
    name       TEXT NOT NULL,
    role       TEXT NOT NULL,
    expires_at TEXT NOT NULL,     -- RFC3339 UTC
    revoked_at TEXT,              -- NULL when not revoked; RFC3339 UTC when revoked
    created_at TEXT NOT NULL      -- RFC3339 UTC
);

func NewTokenStore

func NewTokenStore(db DB, secret string) *TokenStore

NewTokenStore creates a TokenStore backed by db using secret as the HMAC signing key. The secret must match [Config.Secret] so that tokens created here are verifiable by VerifyBearerToken.

func (*TokenStore) Create

func (ts *TokenStore) Create(ctx context.Context, name, role string, ttl time.Duration) (string, error)

Create generates a signed named bearer token with the given role and ttl, stores its SHA-256 fingerprint in forge_tokens, and returns the raw token string. The raw token is never persisted; it cannot be retrieved after this call — pass it to the client through a secure channel.

role must be a valid Role string ("author", "editor", "admin"). ttl must be positive.

func (*TokenStore) List

func (ts *TokenStore) List(ctx context.Context) ([]TokenRecord, error)

List returns all token records from forge_tokens ordered by created_at descending (newest first). Revoked and expired tokens are included; inspect [TokenRecord.RevokedAt] to filter client-side.

func (*TokenStore) Revoke

func (ts *TokenStore) Revoke(ctx context.Context, id string) error

Revoke marks the token with the given fingerprint ID as revoked in forge_tokens. Returns ErrLastAdmin if the token being revoked is the last active (non-revoked, non-expired) admin token — create a replacement admin token before revoking this one. Subsequent VerifyBearerToken calls with a non-nil TokenStore reject revoked tokens immediately. Use TokenStore.List to obtain token IDs.

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

func VerifyBearerToken(r *http.Request, secret []byte, store *TokenStore) (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.

When store is non-nil, VerifyBearerToken additionally checks the forge_tokens table: the token's SHA-256 fingerprint must be present and not revoked. Pass nil to skip database verification and use HMAC-only validation.

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL