Ollie Etherington 

Programmer | Tallinn/London


==== Building HTML with Smetana ====


1. Introduction

2. Installation

3. Importing

4. Typical usage

5. Building HTML

6. CSS and StyleSheets

7. Sitemaps


> Introduction

This short article is a simple guide for getting started with Smetana.

Smetana is an HTML and CSS generator for Go, designed for server-side webpage rendering. It features a simple component-like API, support for all HTML5 tags and strongly-typed styling primitives.

> Installation

$ go get github.com/oetherington/smetana

> Importing

Import the library with:

import (
	s "github.com/oetherington/smetana"
)

Aliasing to s (or even .) is optional but advised.

> Typical usage

Here is an example of typical usage with separate styles for light and dark mode, making use of the Smetana context:

smetana := NewSmetanaWithPalettes(Palettes{
	"light": {
		"bg": Hex("#eee"),
		"fg": Hex("#222"),
	},
	"dark": {
		"bg": Hex("#363636"),
		"fg": Hex("#ddd"),
	},
})

font := smetana.Styles.AddFont("OpenSans", "/OpenSans.woff2")
container := smetana.Styles.AddAnonClass(CssProps{
	{"font-family", font},
	{"padding",     EM(2)},
	{"background",  PaletteValue("bg")},
	{"color",       PaletteValue("fg")},
})

css := smetana.RenderStyles()
lightCss = css["light"]
darkCss = css["dark"]

node := Html(
	Head(
		Title("My HTML Document"),
		LinkHref("stylesheet", "/styles/index.css"),
	),
	Body(
		container,
		H1("Hello world")
		P("foobar"),
	),
)

html := RenderHtml(node)

> Building HTML

To build an HTML tag we simply need to call the function with the name of that tag. For instance, this:

html := Html(
	Head(
		Title("My HTML Document"),
		Charset("UTF-8"),
		LinkHref("stylesheet", "/styles/index.css"),
	),
	Body(
		Div(
			ClassName("container"),
			H1("Hello world"),
		),
	),
)

can be rendered with RenderHtml(html) to produce the following HTML string:

<!DOCTYPE html>
<html>
	<head>
		<title>My HTML Document</title>
		<meta charset="UTF-8">
		<link rel="stylesheet" href="/styles/index.css">
	</head>
	<body>
		<div class="container">
			<h1>Hello world</h1>
		</div>
	</body>
</html>

Note that the actual output will be automatically minified.

You may notice in the example above that we used Charset and LinkHref which aren't the names of HTML tags. We could have instead directly used created a <meta> DOM node (or the MetaNode helper), but a number of special helpers are also included to avoid boilerplate code for the most common use cases.

We also used ClassName to apply a CSS class.

By default, all of the DOM nodes take a variadic list of arguments which are passed onto the NewDomNode function. This function accepts a variety of different types of arguments to make generating your HTML as ergonomic as possible. See the NewDomNode documentation for the full list.

> Special-case helpers

Several frequently used tags have extra helper functions for their most common use-cases:

> Meta tags

It is simple to create a <meta> tag using NewDomNode, but in most cases we only want to set the "name" and "content" attributes, so there's a helper function to do just that: func Meta(name string, content string) MetaNode.

There are several higher-level helpers that automatically set the "name" property and so only take a single "value" string:

as well as the Charset function mentioned above.

Smetana also supports natively "http-equiv" meta tags:

> Fragment nodes

Sometimes we want to combine multiple nodes at the same level of a document to treat them as a single unit. In some cases it may be acceptable to wrap them in another node such as a div or span but this is often undesirable as it alters the generated markup.

In these cases, the children nodes can instead be wrapped in a FragmentNode to treat them as a single entity but without adding an extra layer to the generated markup.

node := Fragment(
	Div(
		H(1, "Foo"),
		P("Bar"),
	),
	Span("Hello world"),
);

> Transforming arrays

It's common to need to apply some operation to an array of data to turn it into an array of DOM nodes. For this purpose, Smetana has a utility function called Xform that functions similarly to map in Haskell or Javascript.

titles := []string{"Foo", "Bar", "Baz"}
node := Div(
	Xform(titles, func (title string) Node {
		return H1(title)
	}),
)

> Text nodes

Raw text inside of a tag is implemented by the TextNode struct. You should rarely need to use TextNode explicitly as Span("Hello world") is automatically converted into Span(Text("Hello world")), but it's mentioned here for completeness.

> Creating custom nodes

To create your own Node types, you simply need to implement the interface:

type Node interface {
	ToHtml(b *Builder)
}

Builder contains a strings.Builder called Buf which you can write your HTML into. For instance:

type CustomNode struct {
	Value string
}

func (node CustomNode) ToHtml(b *Builder) {
	b.Buf.WriteString(node.Value)
}

> CSS and StyleSheets

Smetana also supports generating CSS stylesheets along with your HTML.

Create a stylesheet with styles := NewStyleSheet(). This can later be compiled into a CSS string with RenderCss(styles, Palette{}).

You can then add classes to the stylesheet with

container := styles.AddClass("container", CssProps{
	{"cursor", "pointer"},
})

container is now a class name that can be passed directly into a DOM node:

Div(
	container,
	"Hello world",
)

which will render to

<div class="container">Hello world</div>

and

.container{cursor:pointer}

If you don't require class names to be stable between builds then you can generate a random class name with addAnonClass:

container := styles.addAnonClass(CssProps{
	{"cursor", "pointer"},
})

CssProps is an array of items each of type CssProp, which is a struct containing 2 fields: Key which is the name of the CSS property as a string, and Value which can be any CSS value (see the documentation and source for WriteCssValue for details). Note that the style shown in the documentation without field names (ie; {"cursor", "pointer"} instead of {Key: "cursor", Value: "pointer"}) will cause a lint error from go vet, but is often still preferable when writing large amounts of styles. This error can be silenced by instead using go vet -composites=false, but note that this is a compromise and, if possible, should be limited to as little code as possible rather than to your entire code base. Alternatively, you can use golangci-lint with // nolint comments (see examples/main.go).

To use arbitrary CSS selectors you can instead use AddBlock:

styles.AddBlock("body", CssProps{{"background", "red"}})
styles.AddBlock(".container > div", CssProps{{"display", "flex"}})

NewStyleSheet is also a variadic function which can take an arbitrary number of StyleSheetElements (the building blocks that make up a stylesheet). This can be useful for cleanly adding global styles without using AddBlock:

styles := NewStyleSheet(
	StylesCss(`
		body {
			padding: 3em;
		}
		p {
			font-family: sans-serif;
		}
	`),
	StylesBlock("div", CssProps{
		{"border-radius", PX(5)},
	}),
)

> Using palettes

Stylesheets can be parameterized by using Palettes. This can be used, for example, to use a single StyleSheet to generate separate CSS files for a light mode and a dark mode. For instance:

styles := NewStyleSheet(StylesBlock("body", CssProps{
	{"background", PaletteValue("bg")},
	{"color",      PaletteValue("fg")},
}))
darkStyles := RenderCss(styles, Palette{
	"bg": Hex("#000"),
	"fg": Hex("#fff"),
})
lightStyles := RenderCss(styles, Palette{
	"bg": Hex("#fff"),
	"fg": Hex("#000"),
})

The values in a Palette are not limited to Colors, but can actually be any valid CSS value, such as Units, numbers, or strings.

> Using colors

Instead of entering CSS color strings by hand, Smetana provides several helper types and function to make color handling easier and more programmatic. For instance, we can add an RGB background color property with:

CssProps{{"background", Rgb(255, 255, 0)}}

which will compile to background: #FFFF00 in CSS.

Aside from RGB, there are also helpers for RGBA, HSL and HSLA.

The Hex function will create an RGB color from a 4-digit or 7-digit CSS hex color string, such as #ab4 or #FF00FF.

For easier manipulation, all colors have ToHsla() and ToRgba() methods.

Colors can also be lightened or darkened by a certain amount with the Lighten and Darken functions:

red := Rgb(255, 0, 0)
darkerRed := Darken(red, 0.4)
lighterRed := Lighten(red, 0.4)

> Adding units

Helpers are also provided to strongly type CSS units. For example,

CssProps{{"margin", PX(10)}}

will compile to margin: 10px;

The following unit functions are provided: PX, EM, REM, CM, MM, IN, PT, PC, EX, CH, VW, VH, VMin, VMax and Perc (for percentages).

> Applying classes to HTML

Class names can be passed directly to any DOM node by being typed as a ClassName or Classes type. When passing multiple of these to the same node they will be combined together with the ClassNames function:

ClassNames("foo", "bar")

compiles to "foo bar".

ClassNames takes a variadic list of arguments that may be strings (as above) to combine together, or instances of the Classes type to conditionally apply classnames:

ClassNames("foo", "bar", Classes{"baz": false, "bop": true, "boz": 2 > 1})

compiles to "foo bar bop boz";

> Custom fonts

Smetana can also generate @font-face directives to load custom fonts like so:

styles := NewStyleSheet()
font := styles.AddFont("OpenSans", "OpenSans.ttf", "OpenSans.woff2")
class := styles.AddClass(CssProps{
	{"font-family", font},
})

The AddFont function takes the name of the font family as the first argument (which is also returned for convenience), followed by a variadic list of URLs to the font sources. The type of each source is detected from its extension which should be one of "ttf", "woff", "woff2" or "otf".

> Inserting raw CSS

For more complex or obscure features it may be desirabe to add some manually written CSS. This can be done with the AddCss function:

styles := NewStyleSheet()
styles.AddCss("@media only screen and (max-width:600px) {body{width:100%;}}")

> Sitemaps

Smetana can also generate XML sitemaps.

Simply construct an array of SitemapLocation structs using the provided constructors then call RenderSitemap to get an XML string:

sitemap := Sitemap{
	SitemapLocationUrl("https://duckduckgo.com"),
	SitemapLocationMod("https://lobste.rs", time.Now()),
	NewSitemapLocation(
		"https://news.ycombinator.com",
		time.Now(),
		ChangeFreqAlways,
		0.9,
	),
}
resultXml := RenderSitemap(sitemap)

The constructors are as follows:

The URL is a string, the modified date is a time.Time, the change frequency is one of ChangeFreqNone, ChangeFreqAlways, ChangeFreqHourly, ChangeFreqDaily, ChangeFreqWeekly, ChangeFreqMonthly, ChangeFreqYearly or ChangeFreqNever, and the priority is a float64 between 0 and 1 inclusive.