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.
$ go get github.com/oetherington/smetana
Import the library with:
import (
s "github.com/oetherington/smetana"
)
Aliasing to s
(or even .
) is optional but advised.
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)
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.
Several frequently used tags have extra helper functions for their most common use-cases:
func AHref(href string, args ...any) DomNode
builds an <a>
tag with
the given URL.func BaseHref(href string) DomNode
builds a <base>
tag with the given
URL.func Charset(value string) DomNode
builds a charset <meta>
node with the
given value. If the empty string is passed in then it defaults to "UTF-8".func H(level int, args ...any) DomNode
builds a header tag from <h1>
to
<h6>
with the given level.func LinkHref(rel string, href string)
builds a <link>
tag with the
given rel and URL attributes.func LinkStylesheet(href string)
builds a <link>
tag with
rel="stylesheet"
and the given URL attribute.func LinkStylesheetMedia(href string, media string)
builds a <link>
tag
with rel="stylesheet"
and the given URL and media attributes.func ScriptSrc(src string) DomNode
builds a <script>
tag with the given
src.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:
Keywords
Description
Author
Viewport
(pass the empty string for the default value of
"width=device-width, initial-scale=1.0")as well as the Charset
function mentioned above.
Smetana also supports natively "http-equiv" meta tags:
func Equiv(equiv string, content string) EquivNode
builds a <meta>
tag
with "http-equiv" set to equiv
and "content" set to content
.func Refresh(value uint) DomNode
builds a <meta>
tag with
"http-equiv" set to "refresh"` and "content" set to the given integer value.func XUaCompatible(value string) EquivNode
builds a <meta>
tag with
"http-equiv" set to "x-ua-compatible" and "content" set to value
.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"),
);
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)
}),
)
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.
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)
}
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 StyleSheetElement
s (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)},
}),
)
Stylesheets can be parameterized by using Palette
s. 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 Color
s, but can actually be
any valid CSS value, such as Unit
s, numbers, or strings.
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)
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).
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"
;
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".
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%;}}")
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:
SitemapLocationUrl
takes only a URL.SitemapLocationMod
takes a URL and a last modified date.NewSitemapLocation
takes a URL, a last modified date, a change frequency
and a priority.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.