Building a simple web framework
Over the past few months, I’ve been working on a project with one idea in mind: websites can be simple. I’ve been calling it “make a website today”, or just “today”, and most of the progress so far has been on a web framework that other things in the project can build on. This isn’t a formal review of the project’s motivations or implementations, just an explainer post to get my thoughts out there while I work on a stable release.
Project Goals
If websites can be simple, the framework should reflect that. The broad goal is to start simple and introduce complexity only where needed.
- Start with a static file server.
- Use Go templates to add dynamic content to pages.
- Encapsulate repeated behavior with template parts.
- Focus on the developer experience.
Simple does not have to mean barebones. This framework is designed to handle some key pain points:
- Template parts are easy to distribute and consume.
- Style and behavior may be encapsulated using the Shadow DOM.
- Server-side rendering is concurrent and efficient.
Making a Static Website
“Apps” are a server-side application that handles HTTP requests. The app.Static
function serves a directory tree at a root URL.
www
index.html
user.html
go.mod
go.sum
main.go
main.go:
package main
import (
"syscall"
tapp "git.earlybird.gay/today-app/app"
)
func main() {
// Create a new server-side application.
app := tapp.New()
// Serve www/* as the root of the website.
app.Static("/", "www")
// Listen on 0.0.0.0:3000 and serve HTTP requests.
app.ListenAndServe()
}
Template Pages
Dynamic content is created with Go templates, which can build markup for a page based on server-side data, like a database query. Today doesn’t expose the process of parsing the template directly. You just define a page, its HTML file, and what data to pass to it, and the template is handled for you. When you handle a page created this way, its file is removed from the static fileserver.
main.go:
import "git.earlybird.gay/today-engine/page"
// The file path is relative to the .go file that creates the page.
var UserPage = page.New("user", "www/user.html",
page.OnLoad(func(ctx context.Context, data render.Data) error {
data.Set("list_header", "My Test List")
data.Set("list_items", []string{"hello", "world"})
return nil
}),
)
func main() {
// Create a new server-side application.
app := tapp.New()
// Serve www/* as the root of the website.
app.Static("/", "www")
// Serve UserPage at /user. This removes www/user.html from the static server.
app.Handle("/user", UserPage)
// Listen on 0.0.0.0:3000 and serve HTTP requests.
app.ListenAndServe()
}
user.html:
<main>
<h1>{{ .list_header }}</h1>
<ul>
{{ range .list_items }}
<li>{{ . }}</li>
{{ end }}
</ul>
</main>
output:
<main>
<h1>My Test List</h1>
<ul>
<li>hello</li>
<li>world</li>
</ul>
</main>
Pages are an http.Handler
, so they can be used to respond to HTTP requests with the result of running the template. The root *http.Request
that was used to render the page is always available in data.Request()
.
Template Parts
When you want to reuse a template across pages or projects, you can make a Template Part with part.New
. Since they are Go objects, they can be distributed as part of a Go module. Parts work similarly to pages, with a few key differences:
- The markup for a part must be contained in
<template></template>
tags. - Parts are placed in an HTML document by using its name as an HTML element (
<my-part></my-part>
). - Data can be passed from the parent page or part into the
data
in OnLoad. - Markup can be passed from the parent page or part into
<slot>
s in the part.
This section gives some code examples; if you aren’t interested in details, skip to the next section.
Custom Elements
Create a custom element using part.New
, just like creating a new page. The HTML for a part must be a <template>
. Include the part as a dependency for a page using page.Includes
.
import (
"git.earlybird.gay/today-engine/page"
"git.earlybird.gay/today-engine/part"
)
var ClientView = part.New("client-view", "client-view.html")
var UserPage = page.New("user", "user.html",
page.Includes(ClientView),
)
client-view.html:
<template>
<h2>Client View</h2>
<p>Lorem ipsum...</p>
</template>
user.html:
<client-view></client-view>
output:
<client-view>
<h2>Client View</h2>
<p>Lorem ipsum</p>
</client-view>
These are compatible with the declarative shadow dom by adding the shadowrootmode attribute to the template. Shadow DOM parts:
- Are allowed to have
<style>
internal to the part - Do not allow external CSS/JS, and their CSS/JS does not apply externally
Loading and Passing Data
Template Parts can use template data in rendering, but you can also pass the data into the OnLoad function to do additional processing using “data attributes”, :attr
. These attributes can be raw strings, but if they start with a .
, they will get a value from the current template data instead.
import (
"git.earlybird.gay/today-engine/page"
"git.earlybird.gay/today-engine/part"
"myproject/client"
)
var ClientView = part.New("client-view", "client-view.html",
part.OnLoad(func(ctx context.Context, data render.Data) error {
clientName, _ := client.GetName(data.Get("client-id").(string))
data.Set("client_name", clientName)
return nil
})
)
// The file path is relative to the .go file that creates the page.
var UserPage = page.New("user", "user.html",
page.OnLoad(func(ctx context.Context, data render.Data) error {
data.Set("client_id", "abc123")
return nil
}),
// Include the client-view element.
page.Includes(ClientView),
)
client-view.html:
<template>
<h2>{{ .client_name }}</h2>
<p>Lorem ipsum...</p>
</template>
user.html:
<client-view :client-id=".client_id"></client-view>
output (if client.GetName("abc123")
is “Test Client”):
<client-view>
<h2>Test Client</h2>
<p>Lorem ipsum</p>
</client-view>
Slots
You can add replacable markup to a template part with the <slot>
element. Children of a custom element with the slot="..."
attribute will replace the default content in the slot.
my-message.html:
<template>
<h2>My Message</h2>
<slot name="message">
<p>Hello, world!</p>
</slot>
</template>
page.html:
<my-message>
<p slot="message">Hello, slots!</p>
</my-message>
output:
<my-message>
<h2>My Message</h2>
<p slot="message">Hello, slots!</p>
</my-message>
What’s next?
Feature development, code cleanup, testing, revision, and a LOT of documentation. Here are some things I’m really excited about:
- Suspense/deferred rendering for template parts that may delay the rendering of the page
- A development server that listens for filechanges and hot-reloads
- Tool to create portable builds (not feasible with default
go build
) - Building some websites with the framework, including
- improved Git hosting
- forums
- webring host