Nova Framework Router & HTML Engine
Nova provides a minimal, zero-dependency HTTP router and a programmatic HTML generation engine, built upon Go’s standard net/http
package. It simplifies web development in Go by offering:
- Dynamic Route Parameters: Define routes with named parameters (e.g.,
/users/{id}
). - Regex Validation in Paths: Optionally constrain parameters using regular expressions (e.g.,
/users/{id:[0-9]+}
). - Programmatic HTML Generation: A fluent API for building HTML documents and elements directly in Go code, featuring automatic escaping and comprehensive tag support.
- Composable Middleware: Apply middleware globally, to groups of routes, or individually.
- Route Grouping: Organize related routes under a common path prefix and shared middleware.
- Subrouters: Mount independent router instances under specific path prefixes for modular applications.
- Standard
http.Handler
Compatibility: Integrates seamlessly with standard Go HTTP handlers and middleware. - Customizable 404/405 Handlers: Provide your own handlers for “Not Found” and “Method Not Allowed” responses.
- Context-Based Parameter Access: Retrieve URL parameters easily within your handlers.
- Enhanced Handler Signature (
HandlerFunc
): Use with aResponseContext
for cleaner error handling, convenient response helpers, and automatic data binding/validation. - Rich
ResponseContext
Helpers: Methods for sending JSON, programmatically generated HTML, text, and redirect responses. - Data Binding: Automatic binding of JSON and form data to Go structs.
- Struct Validation & Localization: Built-in validation for bound data using struct tags, with multi-language error messages (EN, ES, FR, DE, NL supported).
- Static File Serving: Serve static files and directories easily.
- Server Management: Integrated server utilities for graceful shutdown, live reloading, and configurable logging.
Table of Contents
- Getting Started
- Core Concepts
- Defining Routes
- Route Parameters
- Middleware
- Route Groups
- Subrouters
- Custom Error Handlers
- Serving Static Files
- Programmatic HTML Generation
- Response Helpers (
ResponseContext
) - Data Binding and Validation
- Server Management (
nova.Serve
) - Full Example
1. Getting Started
Here’s a minimal example to create a router, serve a simple HTML page, and start an HTTP server using Nova’s integrated server utilities:
package main
import (
"log"
"net/http"
"os"
"github.com/xlc-dev/nova/nova"
)
func main() {
router := nova.NewRouter()
router.GetFunc("/", func(ctx *nova.ResponseContext) error {
// Use Nova's programmatic HTML generation
page := nova.Document(
nova.DocumentConfig{Title: "Hello Nova!"}, // Basic document configuration
nova.H1().Text("Welcome to the Nova Framework"),
nova.P().Text("This page is rendered programmatically using Nova's HTML engine."),
)
return ctx.HTML(http.StatusOK, page) // Send the HTML page as response
})
// Setup CLI for server commands (like --port, --watch, etc.)
cli, err := nova.NewCLI(&nova.CLI{
Name: "minimal-nova-app",
Version: "0.0.1",
Action: func(cliCtx *nova.Context) error {
// nova.Serve handles server lifecycle, logging, and live reload.
return nova.Serve(cliCtx, router)
},
})
if err != nil {
log.Fatal(err)
}
// Run the application
if err := cli.Run(os.Args); err != nil {
log.Fatal(err)
}
}
Build and run:
go build -o app
./app serve # Or simply ./app if 'serve' is the default action
Test the route in your browser or with curl
:
curl http://localhost:8080/
# Output will be the rendered HTML page.
2. Core Concepts
The Router
Struct
The nova.Router
is the main component for defining routes and handling HTTP requests. You create one using nova.NewRouter()
. It implements the standard http.Handler
interface, so it can be passed directly to http.ListenAndServe
or, more commonly, to Nova’s Serve
function for enhanced server management.
Key responsibilities:
- Registering routes with standard or enhanced handlers.
- Applying global middleware.
- Managing route groups and subrouters.
- Matching incoming requests to registered routes.
- Extracting URL parameters.
- Dispatching requests to appropriate handlers.
The ResponseContext
Struct
When using enhanced handlers (HandlerFunc
), your function receives a *nova.ResponseContext
. This struct wraps the standard http.ResponseWriter
and http.Request
, providing a rich set of helper methods for:
- Sending various types of responses (JSON, HTML, text, redirects).
- Accessing URL parameters.
- Binding request data (JSON, form) to Go structs.
- Performing validation on bound data.
- Accessing the underlying
http.Request
andhttp.ResponseWriter
.
The Group
Struct
A nova.Group
allows you to define a set of routes that share a common URL prefix and/or a specific stack of middleware. It’s a lightweight helper created via router.Group("/prefix", optionalMiddleware...)
. Routes added to the group automatically inherit its prefix and middleware.
Route Matching
Nova matches routes based on the request path segments:
- The incoming request path is split by
/
. - The router iterates through its registered routes. Subrouters are checked first if their base path (e.g.,
/admin
) is a prefix of the request path. - For each route, it compares the request path segments with the route’s pre-compiled segments.
- Literal segments must match exactly.
- Parameter segments (
{name}
) match any value in that position and capture it. - Regex-constrained parameter segments (
{name:regex}
) must match the provided regular expression (the regex is automatically anchored with^
and$
). - If a route pattern matches the path:
- If the HTTP method also matches, the handler (with middleware) is executed. URL parameters are added to the request context.
- If the HTTP method doesn’t match, a 405 Method Not Allowed response is sent (using the custom handler if set).
- If no route pattern matches the path, a 404 Not Found response is sent (using the custom handler if set).
3. Defining Routes
Nova supports two primary handler signatures for defining routes.
Standard Handlers
These use the standard Go http.HandlerFunc
signature: func(w http.ResponseWriter, r *http.Request)
. You can register them using HTTP method-specific functions on a Router
or Group
instance.
func listItemsHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "List of items")
}
func createItemHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "Item created")
}
router.Get("/items", listItemsHandler)
router.Post("/items", createItemHandler)
Other standard handler registration methods include Put
, Patch
, Delete
, and the generic Handle(method, pattern, handler)
.
Enhanced Handlers (HandlerFunc
)
For cleaner error handling, convenient response helpers, and automatic data binding/validation, Nova provides an enhanced handler signature: func(ctx *nova.ResponseContext) error
.
func greetUserHandler(ctx *nova.ResponseContext) error {
userName := ctx.URLParam("name")
if userName == "" {
return ctx.Text(http.StatusBadRequest, "Name parameter is missing.")
}
greeting := fmt.Sprintf("Hello, %s!", userName)
return ctx.Text(http.StatusOK, greeting)
}
router.GetFunc("/greet/{name}", greetUserHandler)
Enhanced handler registration methods include GetFunc
, PostFunc
, PutFunc
, PatchFunc
, DeleteFunc
, and the generic HandleFunc(method, pattern, handler)
. Returning an error from an enhanced handler will typically result in a 500 Internal Server Error response, though this can be customized.
Route Options
All route registration methods (Handle
, HandleFunc
, Get
, GetFunc
, etc.) accept optional *RouteOptions
as the last argument. The RouteOptions
struct itself is not defined by Nova; it’s intended for users to define if they need to pass metadata associated with a route, for example, for OpenAPI documentation generation or other custom processing.
// Example: User-defined RouteOptions for OpenAPI
type MyRouteOptions struct {
Summary string
Description string
Tags []string
Deprecated bool
}
func getUserProfile(ctx *nova.ResponseContext) error { /* ... */ }
routeOpts := &MyRouteOptions{
Summary: "Get user profile",
Tags: []string{"users", "profile"},
}
router.GetFunc("/users/{id}/profile", getUserProfile, routeOpts) // Pass your custom options
Nova’s router will store this pointer, but it’s up to other parts of your application or third-party tools to interpret these options.
4. Route Parameters
Parameters allow parts of the URL path to be dynamic and captured for use in your handlers.
Basic Parameters
Define parameters using curly braces: {name}
. The value captured for this segment will be available via the parameter name.
router.GetFunc("/store/{category}/items/{itemID}", func(ctx *nova.ResponseContext) error {
category := ctx.URLParam("category")
itemID := ctx.URLParam("itemID")
return ctx.Text(http.StatusOK, fmt.Sprintf("Store Category: %s, Item ID: %s", category, itemID))
})
Regex Constrained Parameters
You can add validation to a parameter by appending a colon and a Go regular expression within the curly braces: {name:regex}
. The regex is automatically anchored with ^
and $
by the router. If the path segment does not match the regex, the route will not be considered a match.
// Matches /users/123 but not /users/abc
router.GetFunc("/users/{id:[0-9]+}", func(ctx *nova.ResponseContext) error {
userID := ctx.URLParam("id") // userID is guaranteed to be a sequence of digits
return ctx.Text(http.StatusOK, "Fetching user with numeric ID: "+userID)
})
// Matches /files/image.jpg but not /files/document.pdf if you want specific extensions
router.GetFunc("/files/{filename:[a-zA-Z0-9_]+\\.(jpg|png|gif)}", func(ctx *nova.ResponseContext) error {
filename := ctx.URLParam("filename")
return ctx.Text(http.StatusOK, "Fetching image file: "+filename)
})
Accessing Parameters (URLParam
)
- Inside an enhanced handler (
HandlerFunc
): Usectx.URLParam("key")
to retrieve the value of a captured parameter.name := ctx.URLParam("name")
- Inside a standard handler (
http.HandlerFunc
): You can userouter.URLParam(req, "key")
. This requires having access to therouter
instance within the handler.
Using the// Assuming 'router' is accessible, e.g., via a global variable or closure // userID := router.URLParam(r, "id")
ResponseContext
method within an enhanced handler is generally preferred for cleaner code.
5. Middleware
Middleware provides a way to add cross-cutting concerns (like logging, authentication, compression, CORS) to your request handling pipeline.
- Type:
type Middleware func(http.Handler) http.Handler
- Functionality: A middleware function takes an
http.Handler
(the “next” handler in the chain) and returns a newhttp.Handler
. This returned handler typically performs some action before and/or after calling theServeHTTP
method of the “next” handler.
// Example: Logging Middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
log.Printf("Started %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r) // Call the next handler in the chain
log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, time.Since(startTime))
})
}
Global Middleware (router.Use
)
Middleware added via router.Use(mws...)
applies to all routes handled by that router instance and any subrouters or groups created from it after the Use
call. Middleware functions are applied in the order they are added.
router := nova.NewRouter()
router.Use(loggingMiddleware) // Applied first
router.Use(panicRecoveryMiddleware) // Applied second (wraps loggingMiddleware's output)
Group Middleware (group.Use
)
Middleware added via group.Use(mws...)
applies only to routes registered through that specific group instance. It runs after any global middleware defined on the parent router. You can also pass middleware directly when creating the group.
adminGroup := router.Group("/admin")
adminGroup.Use(requireAdminAuthMiddleware) // Applies only to routes in adminGroup
// Alternatively, pass middleware at group creation:
apiGroup := router.Group("/api", apiAuthMiddleware, rateLimitingMiddleware)
apiGroup.GetFunc("/data", func(ctx *nova.ResponseContext) error {
// This handler will have global, apiAuth, and rateLimiting middleware applied.
return ctx.JSON(http.StatusOK, map[string]string{"message": "Sensitive data"})
})
Execution Order
Middleware execution follows a standard “onion” model:
- Request comes in.
- Global middleware (added via
router.Use
) executes. The last one added wraps the ones added before it, so it executes “first” on the way in. - Group middleware (added via
group.Use
orrouter.Group
) executes, similarly in a LIFO (Last-In, First-Out) wrapping order for that group. - The route’s specific handler executes.
- The response travels back out through the middleware in the reverse order of execution on the way in (FIFO relative to addition).
6. Route Groups
Groups simplify managing routes that share a common URL prefix and/or a common set of middleware. Create a group using router.Group(prefix, optionalMiddleware...)
.
router := nova.NewRouter()
// API v1 routes
v1 := router.Group("/api/v1", apiVersionMiddleware("v1"))
v1.GetFunc("/users", listV1UsersHandler) // Path: /api/v1/users
v1.PostFunc("/products", createV1ProductHandler) // Path: /api/v1/products
// API v2 routes with additional authentication
v2AuthMiddleware := func(next http.Handler) http.Handler { /* ... */ return next }
v2 := router.Group("/api/v2", apiVersionMiddleware("v2"), v2AuthMiddleware)
v2.GetFunc("/users", listV2UsersHandler) // Path: /api/v2/users
// Public website section
web := router.Group("") // No prefix, can be used to apply middleware to a set of top-level routes
web.Use(commonWebMiddleware)
web.GetFunc("/about", aboutPageHandler) // Path: /about
Routes defined within a group automatically have the group’s prefix prepended to their pattern.
7. Subrouters
Subrouters allow you to mount a completely separate nova.Router
instance at a specific URL prefix. This is useful for modularizing large applications where different sections might have entirely different routing logic, middleware stacks, or even error handlers (though error handlers are inherited by default).
A subrouter created from a parent router inherits:
- The parent’s
paramsKey
for context. - A clone of the parent’s global middleware stack at the time of subrouter creation. Subsequent changes to the parent’s global middleware do not affect already created subrouters.
- The parent’s
notFoundHandler
andmethodNotAllowedHandler
by default. These can be overridden on the subrouter. - The subrouter’s
basePath
will be the parent’sbasePath
joined with the prefix provided during subrouter creation.
mainRouter := nova.NewRouter()
mainRouter.Use(globalLoggingMiddleware)
// Create a subrouter for an admin section
adminRouter := mainRouter.Subrouter("/admin")
adminRouter.Use(adminAuthenticationMiddleware) // Middleware specific to adminRouter
adminRouter.GetFunc("/dashboard", func(ctx *nova.ResponseContext) error {
// Path: /admin/dashboard
// Will have globalLoggingMiddleware and adminAuthenticationMiddleware applied.
return ctx.HTML(http.StatusOK, nova.Document(nova.DocumentConfig{Title: "Admin Dashboard"}, nova.H1().Text("Admin Panel")))
})
adminRouter.GetFunc("/users", func(ctx *nova.ResponseContext) error {
// Path: /admin/users
return ctx.Text(http.StatusOK, "Admin User List")
})
// Another subrouter for a public API
publicApiRouter := mainRouter.Subrouter("/public-api")
publicApiRouter.GetFunc("/version", func(ctx *nova.ResponseContext) error {
// Path: /public-api/version
// Will only have globalLoggingMiddleware applied.
return ctx.JSON(http.StatusOK, map[string]string{"version": "1.0"})
})
// mainRouter.ServeHTTP will delegate to adminRouter or publicApiRouter if the path matches their basePath.
When a request comes in, the main router first checks if the request path matches the basePath
of any of its subrouters. If a match is found, the request is delegated to that subrouter’s ServeHTTP
method.
8. Custom Error Handlers
You can customize the responses for 404 (Not Found) and 405 (Method Not Allowed) errors by providing your own http.Handler
.
Not Found (404)
This handler is invoked when no route matches the requested URL path.
func customNotFoundHandler(w http.ResponseWriter, r *http.Request) {
// Using ResponseContext for convenience, even in a standard handler
rc := nova.ResponseContext{W: w, R: r} // Router field will be nil, but basic W/R ops are fine
errorPage := nova.Document(
nova.DocumentConfig{Title: "404 - Page Not Found"},
nova.H1().Text("Oops! Page Not Found"),
nova.P(nova.Text(fmt.Sprintf("The page you requested (%s) could not be found.", r.URL.Path))),
nova.A("/", nova.Text("Go to Homepage")),
)
// Manually set status and content type for HTML
w.Header().Set("Content-Type", "text/html; charset=utf-f-8")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, errorPage.Render())
}
router := nova.NewRouter()
router.SetNotFoundHandler(http.HandlerFunc(customNotFoundHandler))
If using an enhanced handler with ResponseContext
is preferred for error pages, you’d typically handle this within your application logic or a dedicated error handling middleware if a route isn’t found by Nova’s router. The SetNotFoundHandler
expects a standard http.Handler
.
Method Not Allowed (405)
This handler is invoked when a route pattern matches the URL path, but not the HTTP method (e.g., a POST request to a GET-only route).
func customMethodNotAllowedHandler(w http.ResponseWriter, r *http.Request) {
// It's good practice to set the 'Allow' header with permitted methods.
// This information isn't directly available from Nova's router to this handler by default.
// You might need to determine allowed methods based on your routing setup if you want to be precise.
w.Header().Set("Allow", "GET, POST") // Example
rc := nova.ResponseContext{W: w, R: r}
rc.JSONError(http.StatusMethodNotAllowed,
fmt.Sprintf("Method %s is not allowed for the resource %s.", r.Method, r.URL.Path))
}
router := nova.NewRouter()
router.SetMethodNotAllowedHandler(http.HandlerFunc(customMethodNotAllowedHandler))
9. Serving Static Files
Nova allows you to serve static files (like CSS, JavaScript, images) from an fs.FS
(such as one created from embed.FS
or os.DirFS
) under a specified URL prefix.
The router.Static(urlPathPrefix string, subFS fs.FS)
method sets up routes for GET and HEAD requests to serve files from subFS
under the given urlPathPrefix
.
import (
"embed"
"io/fs"
"log"
"net/http"
"github.com/xlc-dev/nova/nova"
)
//go:embed assets/*
var embeddedAssets embed.FS
func main() {
router := nova.NewRouter()
// Create a sub-filesystem for the 'public' directory within 'assets'.
// This is necessary if your files are in a subdirectory of the embedded FS.
// If 'assets' itself is the root of your static files, you can use 'embeddedAssets' directly.
publicFilesFS, err := fs.Sub(embeddedAssets, "assets/public")
if err != nil {
log.Fatalf("Failed to create sub FS for static files: %v", err)
}
// Serve files from the 'publicFilesFS' (i.e., 'assets/public' directory)
// under the URL prefix '/static'.
// Example: A file at 'assets/public/css/style.css' will be accessible at '/static/css/style.css'.
// Example: A file at 'assets/public/images/logo.png' will be accessible at '/static/images/logo.png'.
router.Static("/static", publicFilesFS)
// Example route that might use these static files
router.GetFunc("/", func(ctx *nova.ResponseContext) error {
page := nova.Document(
nova.DocumentConfig{
Title: "Static Files Example",
HeadExtras: []nova.HTMLElement{
nova.StyleSheet("/static/css/main.css"),
},
},
nova.H1().Text("Page with Static Assets"),
nova.Img("/static/images/banner.jpg", "Banner Image"),
nova.Script("/static/js/app.js").Attr("defer","true"),
)
return ctx.HTML(http.StatusOK, page)
})
// rest of your server setup (CLI, nova.Serve)
}
This setup uses http.FileServer
and http.StripPrefix
internally to serve the files efficiently.
10. Programmatic HTML Generation
Nova includes a powerful and fluent API for generating HTML programmatically within your Go code. This allows for type-safe construction of HTML structures without relying on traditional template files.
Overview
The system is built around the HTMLElement
interface. You construct HTML by creating Element
instances (representing tags like <div>
, <p>
, etc.) or textNode
instances, and composing them. Numerous helper functions (e.g., nova.Div()
, nova.P()
, nova.Img()
) simplify element creation.
The HTMLElement
Interface
This is the core of the HTML generation system:
type HTMLElement interface {
Render() string
}
Any type that implements this interface can be rendered as part of an HTML structure and can be passed to ctx.HTML()
. Both *nova.Element
and *nova.HTMLDocument
(and textNode
) implement this interface.
Creating Elements (nova.Div
, nova.P
, etc.)
Nova provides helper functions for most standard HTML tags. These functions return an *nova.Element
, allowing for method chaining.
import "github.com/xlc-dev/nova/nova" // Or your specific import path
myDiv := nova.Div()
myParagraph := nova.P()
myImage := nova.Img("path/to/image.jpg", "Alternative text") // src, alt
myLink := nova.A("https://example.com", nova.Text("Click me")) // href, content
A comprehensive list of helpers includes Html
, Head
, Body
, TitleEl
, Meta
, LinkTag
, Script
, StyleTag
, H1
-H6
, Table
, Form
, Input
, Button
, and many more covering semantic, form, and media elements.
Adding Content and Children
Elements can have direct text content or child HTMLElement
s.
-
Direct Text Content: Use the
.Text(string) *Element
method. This content is HTML-escaped during rendering (except for<script>
and<style>
tags).titleHeader := nova.H1().Text("Page Title") // Renders: <h1>Page Title</h1>
-
Child Elements: Pass
HTMLElement
s as variadic arguments to the element creation function (e.g.,nova.Div(child1, child2)
) or use the.Add(children ...HTMLElement) *Element
method.container := nova.Div( nova.H2().Text("Subtitle"), nova.P().Text("Some paragraph text."), ) // Or using .Add(): anotherContainer := nova.Div().Add( nova.Span().Text("Part 1"), nova.Span().Text("Part 2"), )
Setting Attributes (.Attr()
, .Class()
, .ID()
)
Attributes are set using fluent methods on an *nova.Element
:
.Attr(key, value string) *Element
: Sets a generic attribute..Class(class string) *Element
: Sets theclass
attribute..ID(id string) *Element
: Sets theid
attribute..Style(style string) *Element
: Sets thestyle
attribute..BoolAttr(key string, present bool) *Element
: Adds a boolean attribute (e.g.,<input disabled>
) ifpresent
is true. The attribute is rendered askey="key"
. Ifpresent
is false, the attribute is removed if it exists.
styledDiv := nova.Div().
ID("main-content").
Class("container theme-dark").
Style("border: 1px solid red; padding: 10px;").
Attr("data-custom", "my-value")
inputField := nova.Input("text").BoolAttr("required", true).Attr("placeholder", "Enter name")
// Renders: <input type="text" required="required" placeholder="Enter name" />
Self-Closing Tags
Some elements, like <img>
, <br>
, <hr>
, <input>
, <meta>
, and <link>
, are self-closing. The helper functions for these tags create elements that render correctly with />
.
separator := nova.Hr() // Renders: <hr />
iconLink := nova.LinkTag().Attr("rel", "icon").Attr("href", "/favicon.ico") // Renders: <link rel="icon" href="/favicon.ico" />
charsetMeta := nova.MetaCharset("UTF-16") // Renders: <meta charset="UTF-16" />
Text Nodes (nova.Text()
)
For adding plain text that should be HTML-escaped when used as a child of another element, use nova.Text(content string) HTMLElement
. This returns a textNode
which implements HTMLElement
.
paragraphWithMixedContent := nova.P(
nova.Text("This is "),
nova.Strong().Text("bold"),
nova.Text(" text, and this is "),
nova.Em().Text("italic"),
nova.Text("."),
)
// Renders: <p>This is <strong>bold</strong> text, and this is <em>italic</em>.</p>
Building Full HTML Documents (nova.Document()
)
To create a complete HTML5 page, use the nova.Document(config DocumentConfig, bodyContent ...HTMLElement) *HTMLDocument
function. It takes a nova.DocumentConfig
for head customizations and variadic HTMLElement
s for the body content. The returned *HTMLDocument
also implements HTMLElement
.
// In your handler:
page := nova.Document(
nova.DocumentConfig{
Lang: "en-US",
Title: "My Awesome App",
Description: "A fantastic application built with Nova.",
Keywords: "nova, web, go, framework",
Author: "Awesome Dev",
HeadExtras: []nova.HTMLElement{ // Add custom links, scripts, meta tags to head
nova.StyleSheet("/css/theme.css"),
nova.Script("/js/app.js").Attr("defer", "defer"), // Note: Script tag is not self-closing
nova.MetaNameContent("robots", "index, follow"),
},
},
// Body content starts here
nova.Header(
nova.H1().Text("Welcome to My Awesome App!"),
),
nova.Main(
nova.P().Text("This is the main content of the page, demonstrating a full document structure."),
nova.Img("/images/logo.png", "App Logo").Class("logo"),
),
nova.Footer(
nova.P().Text("© 2025 My Company"),
),
)
// err := ctx.HTML(http.StatusOK, page)
DocumentConfig
allows customization of:
Lang
:<html>
lang attribute (default “en”).Title
:<title>
tag content (default “Document”).Charset
:<meta charset>
(default “utf-8”).Viewport
:<meta name="viewport">
(default “width=device-width, initial-scale=1”).Description
,Keywords
,Author
: For respective meta tags (omitted if empty).HeadExtras
: Slice ofHTMLElement
s to add to the<head>
section (e.g., additional stylesheets, scripts, meta tags).
The nova.Document()
function constructs the <!DOCTYPE html>
, <html>
, <head>
(with specified meta tags and title), and <body>
structure for you.
HTML Escaping
By default, text content set via .Text()
or nova.Text()
and attribute values are HTML-escaped using html.EscapeString
to prevent XSS vulnerabilities.
Exception: The direct content of <script>
and <style>
elements (set via .Text()
or passed to InlineScript()
/ StyleTag()
) is not escaped. This allows you to embed raw JavaScript and CSS directly.
inlineJS := nova.InlineScript("if (a < b && c > d) { console.log('Condition met'); }")
// Renders: <script>if (a < b && c > d) { console.log('Condition met'); }</script> (content is raw)
inlineCSS := nova.StyleTag("body > p { font-weight: bold; color: #333; }")
// Renders: <style>body > p { font-weight: bold; color: #333; }</style> (content is raw)
Be cautious when embedding user-provided data into inline scripts or styles.
11. Response Helpers (ResponseContext
)
These methods are available on *nova.ResponseContext
within HandlerFunc
(enhanced handlers).
JSON Responses
JSON(statusCode int, data any) error
: Encodesdata
to JSON and sends it. SetsContent-Type: application/json
.JSONError(statusCode int, message string) error
: Sends a standardized JSON error:{"error": "message"}
.
type Item struct { ID string `json:"id"`; Name string `json:"name"` }
func getItemHandler(ctx *nova.ResponseContext) error {
item := Item{ID: "item123", Name: "Example Item"}
return ctx.JSON(http.StatusOK, item)
}
HTML Responses (ctx.HTML()
)
HTML(statusCode int, content HTMLElement) error
: Sends an HTML response. SetsContent-Type: text/html; charset=utf-8
. Thecontent
argument must be a type that implements thenova.HTMLElement
interface (like*nova.Element
,*nova.HTMLDocument
, or a custom type that has aRender() string
method).
// In a handler:
func serveSimpleHTMLPage(ctx *nova.ResponseContext) error {
// Using Nova's programmatic HTML builders
myPageContent := nova.Div().
Class("container").
Add(
nova.H1().Text("Hello from Nova!"),
nova.P().Text("This HTML was generated in Go using Nova's fluent API."),
nova.A("https://example.com", nova.Text("Learn more about Nova")),
)
// If you need a full document structure:
fullDoc := nova.Document(
nova.DocumentConfig{Title: "My Nova Page"},
myPageContent, // Add the div as body content
)
return ctx.HTML(http.StatusOK, fullDoc) // Send the full document
}
Text Responses
Text(statusCode int, text string) error
: Sends a plain text response. SetsContent-Type: text/plain; charset=utf-8
.
func healthCheckHandler(ctx *nova.ResponseContext) error {
return ctx.Text(http.StatusOK, "Server is healthy and running.")
}
Redirects
Redirect(statusCode int, url string) error
: Sends an HTTP redirect. Sets theLocation
header and writes the status code. Common status codes:http.StatusMovedPermanently
(301),http.StatusFound
(302),http.StatusTemporaryRedirect
(307).
func oldPathHandler(ctx *nova.ResponseContext) error {
// Permanent redirect from an old path to a new one
return ctx.Redirect(http.StatusMovedPermanently, "/new-path-for-this-resource")
}
func loginRequiredHandler(ctx *nova.ResponseContext) error {
// Temporary redirect to login page if user is not authenticated
return ctx.Redirect(http.StatusFound, "/login?returnTo="+ctx.Request().URL.Path)
}
Accessing Underlying Writer/Request
Request() *http.Request
: Returns the underlying*http.Request
instance.Writer() http.ResponseWriter
: Returns the underlyinghttp.ResponseWriter
instance. These are useful for advanced scenarios or when integrating with third-party libraries that expect these standard Go types.
func advancedHandler(ctx *nova.ResponseContext) error {
// Access raw request for specific header not covered by helpers
apiKey := ctx.Request().Header.Get("X-API-Key")
if apiKey == "" {
return ctx.JSONError(http.StatusUnauthorized, "API Key required")
}
// Use raw writer for something like streaming a response
// ctx.Writer().Header().Set("Content-Type", "application/octet-stream")
// flusher, ok := ctx.Writer().(http.Flusher)
// if !ok { /* handle error */ }
// ... stream data ...
return nil // Or an error if streaming fails
}
Content Negotiation (WantsJSON
)
WantsJSON() bool
: Returnstrue
if the request’sContent-Type
orAccept
header suggests a preference for JSON (i.e., contains “application/json”). This is useful for creating endpoints that can serve multiple content types based on client preference.
func versatileDataHandler(ctx *nova.ResponseContext) error {
data := map[string]string{"id": "data123", "value": "Some important information"}
if ctx.WantsJSON() {
return ctx.JSON(http.StatusOK, data)
}
// Fallback to HTML representation
htmlContent := nova.Document(
nova.DocumentConfig{Title: "Data Details"},
nova.H1().Text("Data Item: "+data["id"]),
nova.Dl( // Definition List example
nova.Dt().Text("ID"),
nova.Dd().Text(data["id"]),
nova.Dt().Text("Value"),
nova.Dd().Text(data["value"]),
),
)
return ctx.HTML(http.StatusOK, htmlContent)
}
12. Data Binding and Validation
Nova simplifies handling incoming request data (JSON, forms) and validating it using struct tags.
Binding Request Data
Use ResponseContext
methods within an enhanced handler (HandlerFunc
):
ctx.Bind(v any) error
: Automatically detectsContent-Type
(supports “application/json” and URL-encoded form data) and binds the request data to the provided structv
(which must be a pointer).ctx.BindJSON(v any) error
: Specifically for binding JSON request bodies tov
.ctx.BindForm(v any) error
: Specifically for parsing URL-encoded form data (fromr.Form
) and binding it tov
.- For form binding, it supports
string
,bool
(recognizes “on”, “true”, “1” as true, otherwise false if field is bool), numeric types (int*
,uint*
,float*
), and[]string
(from comma-separated values in a single field or multiple form fields with the same name). - Field name matching uses
json
struct tags (e.g.,json:"user_name"
), falling back to the struct field names if nojson
tag is present or if the tag is"-"
.
- For form binding, it supports
type CreateUserInput struct {
Username string `json:"username"` // Used for both JSON and form field name matching
Email string `json:"email"`
Age int `json:"age,omitempty"`
Tags []string `json:"tags"` // For forms, can be 'tag1,tag2' or multiple 'tags=tag1&tags=tag2'
}
func handleCreateUser(ctx *nova.ResponseContext) error {
var input CreateUserInput
if err := ctx.Bind(&input); err != nil { // Binds JSON or Form data
return ctx.JSONError(http.StatusBadRequest, "Invalid input data: "+err.Error())
}
// Process input (e.g., save to database)
log.Printf("User data received: %+v", input)
return ctx.JSON(http.StatusCreated, input)
}
Validating Structs
ctx.BindValidated(v any) error
: This is the most convenient method. It first binds the request data (JSON or form) to the structv
(pointer) and then validatesv
using rules defined in struct tags.- If validation fails, it returns a
nova.ValidationErrors
type (which is[]error
). TheError()
method ofValidationErrors
returns a semicolon-separated string of all validation messages. - The
error:"custom message"
tag on a struct field allows you to specify a custom error message for any validation failure on that field, overriding the default localized message for that specific field’s validation. - If a field in a struct is intended to be required, ensure its
json
tag does not includeomitempty
. If such a field is its zero value after binding (e.g., empty string forstring
, 0 forint
), it will trigger a “required” validation error.
type SignupInput struct {
FullName string `json:"fullName" minlength:"2" maxlength:"50"`
Email string `json:"email" format:"email"` // Implicitly required if no 'omitempty'
Age int `json:"age,omitempty" min:"18" max:"120"` // Optional due to omitempty
Password string `json:"password" format:"password" error:"Your password is too weak, please choose a stronger one."`
}
func handleSignup(ctx *nova.ResponseContext) error {
var input SignupInput
if err := ctx.BindValidated(&input); err != nil {
// err will be of type nova.ValidationErrors
return ctx.JSONError(http.StatusBadRequest, err.Error())
}
// Process valid input (e.g., create user account)
log.Printf("Signup successful for: %+v", input)
return ctx.JSON(http.StatusOK, map[string]string{"message": "Signup successful!"})
}
Validation is recursive: if a struct field is itself a struct or a pointer to a struct, validateStruct
will be called on it. For slices of structs, each element in the slice is validated.
Supported Validation Tags
required
: (Implicit) If a field’sjson
tag does not containomitempty
and the field has its zero value after binding.minlength:"<value>"
: Minimum string length (forstring
type).maxlength:"<value>"
: Maximum string length (forstring
type).min:"<value>"
: Minimum numeric value (forint*
,uint*
,float*
types).max:"<value>"
: Maximum numeric value (forint*
,uint*
,float*
types).pattern:"<regex>"
: String must match the Go regular expression (forstring
type).enum:"<val1>|<val2>|..."
: String must be one of the specified pipe-separated values (forstring
type).format:"<type>"
: Predefined format validation for strings. Supported types:email
: Validates usingmail.ParseAddress
.url
: Validates usingurl.ParseRequestURI
, ensuring scheme and host are present.uuid
: Validates against RFC-4122 v4 UUID format.date-time
: Validates againsttime.RFC3339
format (e.g.,2023-10-26T10:00:00Z
).date
: Validates againstYYYY-MM-DD
format (e.g.,2023-10-26
).time
: Validates againstHH:MM:SS
format (e.g.,10:00:00
).password
: Basic check, ensures string length is at least 8 characters.phone
: Basic international phone number pattern^[\+]?[1-9][\d\s\-\(\)]{7,15}$
.alphanumeric
: Contains only A-Z, a-z, 0-9.alpha
: Contains only A-Z, a-z.numeric
: Contains only 0-9.
multipleOf:"<value>"
: Number must be a multiple of the value (for numeric types).minItems:"<value>"
: Minimum number of items in a slice/array.maxItems:"<value>"
: Maximum number of items in a slice/array.uniqueItems:"true"
: All items in a slice/array must be unique. (Compares underlying values).error:"<custom_message>"
: Overrides the default/localized validation error message for any validation rule that fails on this specific field.
Localization
Validation error messages are automatically localized based on the Accept-Language
HTTP header in the request.
- Supported languages (defined in
validationMessages
map): English (en
), Spanish (es
), French (fr
), German (de
), Dutch (nl
). - English (
en
) serves as the fallback if a requested language or a specific message key within a language is not found. - The
detectLanguage
internal function parses theAccept-Language
header (simplified parsing) to pick the best available match from the supported languages.
13. Server Management (nova.Serve
)
Nova provides a Serve(ctx *nova.Context, router http.Handler) error
function to simplify server startup, management, and add features like live reloading and configurable logging. It’s typically used as the action for a nova.CLI
command.
The *nova.Context
argument provides access to parsed command-line flags and configuration.
Key features of nova.Serve
:
- Server Startup: Listens on a host and port. These can be configured via
*nova.Context
(typically from CLI flags).host
(string, default: “localhost”, e.g.,--host 0.0.0.0
)port
(int, default: 8080, e.g.,--port 3000
)
- Graceful Shutdown: Catches
SIGINT
(Ctrl+C) andSIGTERM
signals to shut down the HTTP server gracefully within a 5-second timeout. - Live Reloading (Hot Reload):
- Enable by setting
watch: true
in the*nova.Context
(e.g., via a--watch
CLI flag). - Monitors file changes in the current working directory (or a specified directory).
- Watched file extensions are configurable via an
extensions
string in*nova.Context
(comma-separated, default: “.go”, e.g.,--extensions .go,.html,.css
). - When a change in a watched file is detected,
nova.Serve
rebuilds the application (go build -o <current_executable_path> .
) and then re-executes the new binary, effectively restarting the server with the new code.
- Enable by setting
- Configurable Logging (slog):
- Log output format can be set via
log_format
in*nova.Context
(string, “text” or “json”, default: “text”, e.g.,--log-format json
). - Log level can be set via
log_level
in*nova.Context
(string, “debug”, “info”, “warn”/“warning”, “error”, default: “info”, e.g.,--log-level debug
). - This configures the global
slog.Default
logger.
- Log output format can be set via
- Verbose Mode:
- Setting
verbose: true
in*nova.Context
(e.g., via a--verbose
CLI flag) enables more detailed logging from theServe
function itself, such as file watcher activity and recompilation commands.
- Setting
// Example usage within main.go
// Assuming cli is a *nova.CLI instance
cli.Action = func(cliCtx *nova.Context) error {
router := setupMyApplicationRouter() // Your function to get the configured router
slog.Info("Application starting...") // Uses slog, configured by nova.Serve
return nova.Serve(cliCtx, router)
}
14. Full Example
This example demonstrates routing, middleware, programmatic HTML generation, static file serving, data binding with validation, and server management via nova.Serve
and nova.CLI
.
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"time"
"github.com/xlc-dev/nova/nova"
)
//go:embed static/*
var embeddedStaticFiles embed.FS
// Middleware
func requestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Use slog for structured logging, which nova.Serve can configure
slog.Info("Request received", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr)
next.ServeHTTP(w, r)
slog.Info("Request completed", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
})
}
func simpleAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Auth-Token") != "secret-token" {
// Use ResponseContext for consistent error responses
rc := nova.ResponseContext{W: w, R: r}
rc.JSONError(http.StatusUnauthorized, "Unauthorized: Missing or invalid X-Auth-Token")
return
}
ctx := context.WithValue(r.Context(), "user", "AuthenticatedUser")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// HTML Page Components (Example)
func pageLayout(config nova.DocumentConfig, bodyContent ...nova.HTMLElement) nova.HTMLElement {
defaultConfig := nova.DocumentConfig{
Lang: "en",
Title: "Nova App",
Charset: "UTF-8",
Viewport: "width=device-width, initial-scale=1.0",
HeadExtras: []nova.HTMLElement{
nova.StyleSheet("/assets/css/style.css"), // Path to static CSS
nova.Script("/assets/js/main.js").Attr("defer", "true"),
},
}
// Override defaults with provided config
if config.Title != "" {
defaultConfig.Title = config.Title
}
if config.Lang != "" {
defaultConfig.Lang = config.Lang
}
// Append, don't overwrite, HeadExtras
defaultConfig.HeadExtras = append(defaultConfig.HeadExtras, config.HeadExtras...)
allBodyContent := []nova.HTMLElement{
nova.Header(
nova.Nav(nova.A("/", nova.Text("Home")), nova.Text(" | "), nova.A("/contact", nova.Text("Contact"))).Class("main-nav"),
).Class("site-header"),
}
allBodyContent = append(allBodyContent, bodyContent...)
allBodyContent = append(allBodyContent,
nova.Footer(nova.P().Text(fmt.Sprintf("© %d Nova Example Inc.", time.Now().Year()))).Class("site-footer"),
)
return nova.Document(defaultConfig, allBodyContent...)
}
// Handlers
func handleHomepage(ctx *nova.ResponseContext) error {
slog.Info("Handling homepage request")
content := nova.Main(
nova.H1().Text("Welcome to the Nova Framework Showcase!"),
nova.P().Text("This page is dynamically rendered using Nova's programmatic HTML engine."),
nova.Img("/assets/images/banner.png", "Nova Banner").Style("max-width:100%; height:auto;"),
)
return ctx.HTML(http.StatusOK, pageLayout(nova.DocumentConfig{Title: "Homepage"}, content))
}
func handleContactPage(ctx *nova.ResponseContext) error {
slog.Info("Handling contact page request")
contactForm := nova.Form(
nova.H2().Text("Contact Us"),
nova.P(
nova.Label(nova.Text("Your Name: ")).Attr("for", "name"),
nova.TextInput("name").ID("name").Attr("required", "true"),
),
nova.P(
nova.Label(nova.Text("Your Email: ")).Attr("for", "email"),
nova.EmailInput("email").ID("email").Attr("required", "true"),
),
nova.P(
nova.Label(nova.Text("Message: ")).Attr("for", "message"),
nova.Textarea().ID("message").Attr("name", "message").Attr("rows", "5").Attr("required", "true"),
),
nova.SubmitButton("Send Message"),
).Attr("method", "POST").Attr("action", "/contact-submit")
content := nova.Main(contactForm)
return ctx.HTML(http.StatusOK, pageLayout(nova.DocumentConfig{Title: "Contact Us"}, content))
}
type ContactFormInput struct {
Name string `json:"name" minlength:"2" error:"Name must be at least 2 characters."`
Email string `json:"email" format:"email"`
Message string `json:"message" minlength:"10" error:"Message is too short."`
}
func handleContactSubmit(ctx *nova.ResponseContext) error {
var input ContactFormInput
if err := ctx.BindValidated(&input); err != nil {
slog.Warn("Contact form validation failed", "errors", err.Error())
// For a real app, you'd re-render the form with errors.
// Here, we'll just return a JSON error for simplicity.
return ctx.JSONError(http.StatusBadRequest, "Validation failed: "+err.Error())
}
slog.Info("Contact form submitted", "name", input.Name, "email", input.Email)
thankYouMessage := nova.Main(
nova.H1().Text("Thank You!"),
nova.P().Text("Your message has been received. We will get back to you shortly."),
nova.A("/", nova.Text("Return to Homepage")),
)
return ctx.HTML(http.StatusOK, pageLayout(nova.DocumentConfig{Title: "Message Sent"}, thankYouMessage))
}
func handleApiGetData(ctx *nova.ResponseContext) error {
user, _ := ctx.Request().Context().Value("user").(string)
slog.Info("API: GetData requested", "authenticated_user", user)
data := map[string]any{
"message": "This is protected data from the API.",
"timestamp": time.Now().Format(time.RFC3339),
"currentUser": user,
}
return ctx.JSON(http.StatusOK, data)
}
func main() {
// Router Setup
router := nova.NewRouter()
// Global Middleware
router.Use(requestLoggingMiddleware)
// Serve static files from embedded 'static' directory under '/assets' URL path,
// e.g., /assets/css/style.css
staticFilesRoot, err := fs.Sub(embeddedStaticFiles, "static")
if err != nil {
log.Fatalf("Failed to create sub FS for static files: %v", err)
}
router.Static("/assets", staticFilesRoot)
// Public Routes
router.GetFunc("/", handleHomepage)
router.GetFunc("/contact", handleContactPage)
router.PostFunc("/contact-submit", handleContactSubmit)
// API Routes with Authentication
apiGroup := router.Group("/api/v1")
apiGroup.Use(simpleAuthMiddleware)
apiGroup.GetFunc("/data", handleApiGetData)
// CLI Setup
cliApp, err := nova.NewCLI(&nova.CLI{
Name: "NovaFullApp",
Version: "1.0.0",
Description: "A full example application using the Nova framework.",
Action: func(cliCtx *nova.Context) error {
// This action is called when 'myfullapp serve' (or just 'myfullapp') is run.
// nova.Serve will use cliCtx for port, host, watch, log settings.
slog.Info("Starting Nova application server...", "version", cliCtx.App.Version)
return nova.Serve(cliCtx, router)
},
// Add more commands here if needed
})
if err != nil {
log.Fatalf("Failed to initialize CLI: %v", err)
}
// Run the CLI application
if err := cliApp.Run(os.Args); err != nil {
slog.Error("Application exited with error", "error", err)
os.Exit(1)
}
}