< Back to posts

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.

Simple does not have to mean barebones. This framework is designed to handle some key pain points:


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:

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:

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: