This page, and more broadly all of my website, are (for the most part) just composed of static HTML and CSS.
What's interesting is that said HTML is fully generated via Typst's experimental HTML export feature.
In the process of building this website, I found that although experimental, the feature is already very powerful (though not fully ergonomic yet), and a bigger obstacle to my development was simply knowing what to do with the few features available. This document should make it clear that the issue with Typst's HTML output currently is not what is possible, but what is convenient.
This page is intended:
Via some dark magic, I shall embed in this document not just the output but also the source code that generates it, helping towards the goal of this being a usable showcase and tutorial.
You can find the full source code of this page here.
Some stuff just works out of the box.
setting the title of the page
/* _src/index.typ @ l. 7 */
#set document(title: "Typ2HTML")
Putting text in bold, italics, inline code
/* _src/index.typ @ l. 55 */
*bold*, _italics_, `inline code`
Making titles
/* _src/index.typ @ l. 47 */
== Getting started
Adding hyperrefs
/* _src/index.typ @ l. 62 */
#link("https://example.com")[hyperrefs]
Bullet lists
/* _src/index.typ @ ll. 68-71 */
- just
- like
- normally
Enumerations
/* _src/index.typ @ ll. 76-79 */
1. work
2. as
3. well
Before we get into more technical stuff, a small note on the build process, because I feel that this is one of the least well-rounded parts of using Typst for HTML currently. For the record, I use the following directory layout:
Makefile
list:page
_src/
|-- index.typ
|-- xhtml.typ
|-- css.typ
\-- ...
_assets/
|-- global.css
\-- link.svg
_highlight/
|-- highlight.min.js
|-- highlight-typst.js
\-- ...
And I have my justfile
as such:
# justfile
compile-raw SRC TGT:
typst compile --features html --format html --root . {{SRC}} {{TGT}}
watch-raw SRC TGT:
typst watch --features html --format html --root . --no-serve {{SRC}} {{TGT}}
compile BASE:
just compile-raw _src/{{BASE}}.typ {{BASE}}.html
watch BASE:
just watch-raw _src/{{BASE}}.typ {{BASE}}.html
compile-all:
cat list:page | parallel --lb just compile {}
watch-all:
cat list:page | parallel --lb just watch {}
This builds index.html
in the root directory.
To watch changes live, I open index.html
in a browser and run just watch index
in the background.
For now Typst only provides the function elem
, where to build for example a<div class="test">inner</div>
, you write#html.elem("div", attrs: (class: "test"), { [inner] })
.
I find that it is slighly more convenient to have the following in xhtml.typ
:
/* _src/lib/xhtml.typ @ ll. 2-5 */
#let htmlfunc(label) = (..attrs) => {
let keyvals = attrs.named().pairs().filter(p => p.at(1) != none).to-dict()
html.elem(label, attrs: keyvals, ..attrs.pos())
}
and then instanciate it for each element as such:
/* _src/lib/xhtml.typ @ ll. 9-11 */
#let div = htmlfunc("div")
#let a = htmlfunc("a")
#let img = htmlfunc("img")
This means that now for the same<div class="test">inner</div>
, we can writexhtml.div(class: "test", { [inner] })
This document imports xhtml.typ
, in which more functions of the same shape are defined. Thus from now on you can assume that whenever you see a function xhtml.foo
, it creates a <foo>...</foo>
html element.
As a concrete example, here is an image that is also a hyperref:
/* _src/index.typ @ ll. 140-142 */
#xhtml.a(href: "https://www.example.com", {
xhtml.img(src: "_assets/link.svg", width: "50px")
})
Now we get to more advanced styling options. There are at least 4 standard ways of doing this in HTML:
<link rel="stylesheet" ...>
declaration;.html
file inside a <style>...</style>
block;style=...
;<script>
block can dynamically set some styling options.All of these methods can be replicated in Typst.
You can import a pre-written CSS that dictates the style of headers and the color palette that you see in this document as such:
/* _src/common.typ @ ll. 4-5 */
// Concretely this expands to <link rel="stylesheet" href="_assets/global.css">
#css.include-file("_assets/global.css")
/* _assets/global.css @ ll. 32-57 */
html {
background-color: var(--black);
}
body {
margin: 40px auto;
/* TODO: get something working if on mobile */
max-width: 1200px;
line-height: 1.5;
font-size: 18px;
font-weight: 350;
color: var(--white);
background: var(--dk-gray0);
padding: 1cm 5mm 10cm 5mm;
}
h1,h2,h3,h4 {
line-weight: 1.2;
color: var(--lt-green);
}
h1 { font-size: 33px }
h2 { font-size: 31px }
h3 { font-size: 29px }
h4 { font-size: 27px }
a { color: var(--dk-aqua) }
a:visited { color: var(--lt-purple) }
(see global.css
if you're curious where the colors are defined)
HTML is not limited to a single global <style>
declaration, so this can be used also to set more properties after the fact. For example let us define my-style
as such:
/* _src/index.typ @ ll. 175-181 */
#let my-style = (
color: "var(--black)",
background: "var(--dk-blue)",
border-radius: 3pt,
display: "inline-block",
padding: 5pt,
)
Then we can for example bind it to the .on-the-fly
class by:
/* _src/index.typ @ ll. 188-191 */
#css.elem(".on-the-fly", my-style)
#xhtml.div(class: "on-the-fly", {
[Black on blue (on the fly)]
})
Additionally, HTML elements accept a style
parameter in which you can put CSS.
/* _src/index.typ @ ll. 200-202 */
#xhtml.div(class: "inlined", style: css.raw-style(my-style), {
[Black on blue (inlined)]
})
As for much more complex styling, you can always resort to calling external JS code. In this document, this is the method I use to provide syntax highlighting for code snippets through highlight.js
:
/* _src/common.typ @ ll. 9-23 */
// Unpacked the archive from https://highlightjs.org/download to _highlight/
#xhtml.script(src: "_highlight/highlight.min.js")
#css.include-file("_highlight/styles/base16/gruvbox-dark-soft.css")
// Copied from https://www.npmjs.com/package/@myriaddreamin/highlighter-typst
// the "cjs, js bundled, wasm bundled" script
#xhtml.script(src: "_highlight/highlight-typst.js")
// As soon as the scripts have loaded, highlight all code blocks.
#xhtml.script("
const run = window.$typst$parserModule.then(() => {
hljs.registerLanguage('typst', window.hljsTypst({}))
hljs.highlightAll();
});
")
Here I offer some very common functions that help set the layout. I've found that often the appearance is easy to set as just raw CSS, but the layout (centered / horizontal / vertical / grid / …) is cumbersome. Here are functions that should help! See struct.typ
for the definition of these functions. The goal is that these functions should provide as close an interface to the real Typst version as possible.
/* _src/index.typ @ l. 226 */
#import struct: text, box, table, align
You can control the style of text just like you would in Typst:
/* _src/index.typ @ l. 240 */
#text(fill: "var(--dk-red)", size: 50pt)[*Some text*]
Some text
(reminder: you can do color definitions like this)
struct.box
tries to mimic to some extent the behavior of Typst's box
. It supports automatic or fixed width and height, rounded corners, background color.
/* _src/index.typ @ ll. 252-257 */
#box(width: 60%, inset: 10pt, outset: 2mm, radius: 10pt, fill: "var(--dk-purple)", {
text(fill: "var(--black)")[
A box \
with round corners
]
})
You can even specify corners, margins, and stroke as dictionaries the same way you would do for a regular Typst box
.
/* _src/index.typ @ ll. 265-274 */
#box(fill: "var(--dk-gray3)",
radius: (bottom-left: 1cm, bottom: 3mm),
stroke: 3pt,
)[
#box(fill: "var(--dk-aqua)",
inset: (x: 5mm, y: 2mm), outset: (x: 1cm, y: 2mm),
radius: (top-left: 5mm, bottom-right: 0, rest: 2mm),
stroke: (top: green, left: 3pt, right: (paint: red, thickness: 4pt)),
)[Inner]
]
Partially mimicking Typst's table
function, we have struct.table
. Note that this version does not have a stroke, it's only a grid layout.
/* _src/index.typ @ ll. 285-290 */
#let cell = box(fill: "var(--dk-gray1)", height: "100px")[A cell]
#table(
columns: 3, gutter: "15px", {
for elt in range(7).map(_ => cell) { elt }
},
)
If you look at the generated HTML for the code above, you will see that the inline style for cell
is repeated as many times as there are cells. Here I propose a mechanic to cut down on this repetition.
All functions defined in struct.typ
offer another behavior when passed the parameter inline: false
. Whereas box(...)
will construct a box, box(inline: false, class: "box-name", ...)
will instead return a box-builder
function that will lazily declare the required CSS at most once.
/* _src/index.typ @ ll. 307-316 */
#let orange-box = box(
inline: false, class: "orange-box",
fill: "var(--dk-orange)", radius: 5pt, width: "fit-content", outset: 5pt,
)
#orange-box[These]
#orange-box[boxes]
#orange-box[share]
#orange-box[the]
#orange-box[same]
#orange-box[style.]
and now the corresponding CSS is not duplicated, resulting in a smaller HTML output! In fact, the CSS style is included only if necessary, and at most once.
You can still overwrite on-the-fly some elements:
/* _src/index.typ @ ll. 324-325 */
#orange-box(inset: 10pt)[Add margins to existing style.]
#orange-box(radius: 0pt)[Remove the rouded corners.]
This mimics Typst's align
function.
/* _src/index.typ @ ll. 334-353 */
#let gray-box = box(
inline: false, class: "cell",
fill: "var(--dk-gray1)", height: 3cm, outset: 3pt, inset: 5pt,
)
#let my-aligned-box(alignment, inner) = {
gray-box({
align(alignment)[#inner]
})
}
#table(columns: 3, gutter: 3mm, {
my-aligned-box(top + left)[Top left]
my-aligned-box(top)[Top]
my-aligned-box(top + right)[Top right]
my-aligned-box(left)[Left]
my-aligned-box(center)[Center]
my-aligned-box(right)[Right]
my-aligned-box(left + bottom)[Bottom left]
my-aligned-box(bottom)[Bottom]
my-aligned-box(right + bottom)[Bottom right]
})
Thanks to a type-based translation from Typst to CSS, you can actually use any of Typst's length types wherever the CSS expects a length. In addition, you can also directly use whatever string is valid CSS for a length, e.g. in pixels which is not a valid Typst unit of length.
/* _src/index.typ @ ll. 365-379 */
#let as-len(l) = if type(l) == array { l.at(0) } else { l }
#let as-repr(l) = if type(l) == array { l.at(1) } else { raw(repr(l)) }
#let lengths = (
100%, 25%, 100pt, (5cm, `5cm`), (50% + 1cm, `50% + 1cm`),
50%, (50% - 1cm, `50% - 1cm`), 50% + 3em, (50% + 3em + 1cm, `50% + 3em + 1cm`),
"300px", "calc(50% + 200px)",
)
#box(fill: "var(--dk-gray2)", width: 100%,
align(left, {
for l in lengths {
orange-box(width: as-len(l))[#as-repr(l)]
struct.box-linebreak // A regular linebreak wouldn't work here, unfortunately.
}
})
)
100%
25%
100pt
5cm
50% + 1cm
50%
50% - 1cm
50% + 3em
50% + 3em + 1cm
"300px"
"calc(50% + 200px)"
In the same way, there are multiple methods to define colors
rgb
, cmyk
, luma
, and more/* _src/index.typ @ ll. 394-410 */
#let as-color(c) = if type(c) == array { c.at(0) } else { c }
#let as-repr(c) = if type(c) == array { c.at(1) } else { raw(repr(c)) }
#let colors = (
(aqua, `aqua`), (rgb(10, 50, 200), `rgb(10, 50, 200)`),
(rgb(80%, 50%, 5%), `rgb(80%, 50%, 5%)`), rgb("#aaaaff"),
(luma(50%), `luma(50%)`), (color.hsv(60deg, 50%, 30%), `color.hsv(60deg, 50%, 30%)`),
(red.negate(), `red.negate()`),
(red.darken(50%), `red.darken(50%)`), (blue.transparentize(80%), `blue.transparentize(80%)`),
"#fa1419", "blue", "YellowGreen", "var(--dk-orange)", "rgb(200, 30, 10)",
"rgba(200, 30, 10, 0.5)", "hsl(110, 80%, 30%)",
)
#let cbox = box(inline: false, class: "cbox", inset: 1mm, outset: 1mm, radius: 1mm)
#box(fill: "var(--black)", width: 100%,
for c in colors {
cbox(fill: as-color(c))[#as-repr(c)]
}
)
aqua
rgb(10, 50, 200)
rgb(80%, 50%, 5%)
rgb("#aaaaff")
luma(50%)
color.hsv(60deg, 50%, 30%)
red.negate()
red.darken(50%)
blue.transparentize(80%)
"#fa1419"
"blue"
"YellowGreen"
"var(--dk-orange)"
"rgb(200, 30, 10)"
"rgba(200, 30, 10, 0.5)"
"hsl(110, 80%, 30%)"
When you bind a style to a class, you can manually insert :hover
properties:
/* _src/index.typ @ ll. 421-434 */
#let gray-box = box(inline: false, class: "highlightable",
fill: "var(--dk-gray2)", inset: 5mm,
)
#css.elems((
".highlightable": (
transition: "0.3s",
),
".highlightable:hover": struct.box-style((:),
fill: "var(--dk-red)",
radius: (top-left: 5mm),
)
))
#gray-box[Hover over me]
This is
a test
This is
another test.
I will keep updating this page occasionally if I find an interesting trick, or simply to implement more features. I have plans for:
Don't hesitate to browse the source code in more detail, either here or on the repo.
If you have suggestions you can open an issue or pull request.