Smetana is an HTML and CSS generator for Go, designed for server-side webpage rendering.
- Simple component-like API
- Built-in support for all HTML5 tags, and easily extensible for web components
- Strongly-typed CSS units and colors
- Robust with 100% test coverage
- Zero dependencies outside the Go standard library
$ go get github.com/oetherington/smetanaImport 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) DomNodebuilds an<a>tag with the given URL.func BaseHref(href string) DomNodebuilds a<base>tag with the given URL.func Charset(value string) DomNodebuilds 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) DomNodebuilds 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 withrel="stylesheet"and the given URL attribute.func LinkStylesheetMedia(href string, media string)builds a<link>tag withrel="stylesheet"and the given URL and media attributes.func ScriptSrc(src string) DomNodebuilds 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:
KeywordsDescriptionAuthorViewport(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) EquivNodebuilds a<meta>tag with "http-equiv" set toequivand "content" set tocontent.func Refresh(value uint) DomNodebuilds a<meta>tag with "http-equiv" set to "refresh"` and "content" set to the given integer value.func XUaCompatible(value string) EquivNodebuilds a<meta>tag with "http-equiv" set to "x-ua-compatible" and "content" set tovalue.
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 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)},
}),
)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.
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:
SitemapLocationUrltakes only a URL.SitemapLocationModtakes a URL and a last modified date.NewSitemapLocationtakes 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.
Smetana is free software under the MIT license.
Copyright © 2023 Ollie Etherington.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.