#import "pdf.typ": pdf-layout, pdf-header, code-theme
#import "metadata.typ": page, post, authors, author-text, format-date, date-key, normalize-page
#import "blog.typ": post-header, post-card
#import "color/rose-pine.typ": rose-pine
#import "color/rose-pine-moon.typ": rose-pine-moon
#import "color/rose-pine-dawn.typ": rose-pine-dawn
#let plain(value) = {
if value == none { return "" }
if type(value) == str { return value }
if type(value) != content { return str(value) }
if value == [ ] { return " " }
if value.func() == parbreak or value.func() == linebreak { return "\n" }
if value.has("text") { return value.text }
if value.has("children") { return value.children.map(plain).join() }
if value.has("body") { return plain(value.body) }
if value.has("child") { return plain(value.child) }
""
}
#let escape(value) = (plain(value)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'"))
#let tag(name, value) = "<" + name + ">" + escape(value) + "" + name + ">\n"
#let site-context = state("typst_web_magic-context", none)
#let image(file, source: none, alt: "", width: auto) = [
#metadata((kind: "post-image", path: file, alt: alt))
#context {
if target() == "html" {
html.img(class: "post-image", src: "/assets/" + file, alt: alt, loading: "lazy", decoding: "async")
} else {
let resolved = if source != none { source } else {
let ctx = site-context.get()
assert(ctx != none, message: "Outside a site, pass source: path(...) to image")
let asset = ctx.assets.find(item => item.path == "assets/" + file)
assert(asset != none, message: "Image not found in assets: " + file)
asset.source
}
std.image(resolved, alt: alt, width: width)
}
}
]
#let audio(file) = context {
if target() == "html" {
html.audio(
controls: true,
src: "/assets/" + file,
)
}
}
#let header(ctx) = context {
if ctx.page.collection == "blog" {
post-header(ctx)
} else if target() == "html" {
html.elem("header", attrs: (class: "page-header"))[
#html.h1(ctx.page.title)
#if ctx.page.date != none {
html.p(ctx.page.date.display("[month repr:long] [day], [year]"))
}
]
} else {
pdf-header(ctx)
}
}
#let html-layout(ctx, body) = html.main((ctx.site.theme.header)(ctx) + body)
#let preview-layout(ctx) = [
#set std.page(width: 600pt, height: 315pt, margin: 30pt)
#set text(size: 32pt)
#ctx.page.title
#parbreak()
#text(size: 16pt, ctx.site.title)
]
#let default-theme = (
html: html-layout,
pdf: pdf-layout,
preview: preview-layout,
header: header,
)
#let pdf(title: "", description: none, author: (), date: none, theme: (:), body) = {
let theme = default-theme + theme
let entry = normalize-page(page(title: title, description: description, author: author, date: date), (:))
let ctx = (site: entry + (theme: theme), page: entry, pages: (entry,))
set document(title: title, description: description, author: entry.author, date: date)
(theme.pdf)(ctx, body)
}
#let listing(collection: none, render: none) = context {
let ctx = site-context.get()
let entries = ctx.pages
.filter(entry => collection == none or entry.collection == collection)
.sorted(key: date-key)
.rev()
for entry in entries {
if render != none {
render(ctx, entry)
} else if target() == "html" {
html.elem("article", attrs: (class: "listing-item"))[
#html.h2(html.a(href: entry.href, entry.title))
#if entry.description != none { html.p(entry.description) }
]
} else {
list.item(link(entry.url, entry.title))
}
}
}
#let post-index(collection: "blog", render: post-card) = [
#context {
if target() == "html" {
html.div(class: "post-index", listing(collection: collection, render: render))
} else {
listing(collection: collection, render: render)
}
}
]
#let prepare-page(config, item) = {
let entry = normalize-page(item.entry, (
title: config.title,
description: config.description,
author: config.author,
pdf: config.pdf,
preview: config.preview,
))
let path = if entry.path == none { item.path } else { entry.path }
assert(
path.ends-with(".html") and not path.starts-with("/") and not path.split("/").contains(".."),
message: "Page paths must be relative .html paths",
)
let href = "/" + if path == "index.html" { "" } else if path.ends-with("/index.html") {
path.slice(0, -10)
} else { path }
let stem = path.slice(0, -5)
(
..entry,
body: item.body,
path: path,
href: href,
url: config.url + href,
pdf-path: if entry.pdf { stem + ".pdf" },
preview-path: if entry.preview { "assets/previews/" + stem + ".png" },
)
}
#let html-document(ctx, body, feeds) = {
let config = ctx.site
let entry = ctx.page
html.html(lang: config.lang)[
#html.head[
#html.meta(charset: "utf-8")
#html.meta(name: "viewport", content: "width=device-width, initial-scale=1")
#html.title(plain(entry.title))
#html.meta(name: "description", content: plain(entry.description))
#html.link(rel: "canonical", href: entry.url)
#for (property, value) in (
"og:title": plain(entry.title),
"og:description": plain(entry.description),
"og:url": entry.url,
"og:type": if entry.collection == none { "website" } else { "article" },
) {
html.elem("meta", attrs: (property: property, content: value))
}
#if entry.preview {
html.elem("meta", attrs: (
property: "og:image",
content: config.url + "/" + entry.preview-path,
))
html.meta(name: "twitter:card", content: "summary_large_image")
}
#for stylesheet in config.css { html.link(rel: "stylesheet", href: stylesheet) }
#for feed in feeds {
html.link(rel: "alternate", type: "application/rss+xml", title: feed.title, href: "/" + feed.path)
}
#(config.head)(ctx)
]
#html.body((config.theme.html)(ctx, body))
]
}
#let rss(config, entries, feed) = {
let xml = "\n\n"
xml += tag("title", feed.title) + tag("link", config.url) + tag("description", config.description)
for entry in entries.sorted(key: date-key).rev() {
xml += "- \n" + tag("title", entry.title) + tag("link", entry.url)
xml += "" + escape(entry.url) + "\n"
xml += tag("description", entry.description)
if entry.date != none {
let date = entry.date.display("[weekday repr:short], [day padding:zero] [month repr:short] [year]")
xml += tag("pubDate", date + " 00:00:00 +0000")
}
xml += "
\n"
}
asset(feed.path, xml + "\n")
}
#let sitemap(entries) = {
let urls = entries.filter(entry => entry.path != "404.html").map(entry => {
"" + tag("loc", entry.url) + "\n"
})
asset("sitemap.xml",
"\n"
+ "\n"
+ urls.join() + "\n",
)
}
#let site(
title: "",
url: "",
description: none,
author: (),
lang: "en",
css: auto,
head: ctx => [],
pdf: false,
preview: false,
theme: (:),
pages: (),
assets: (),
feeds: (),
) = {
let default-css = "assets/typst_web_magic.css"
if css == auto { css = ("/" + default-css,) }
if css.contains("/" + default-css) and not assets.any(item => item.path == default-css) {
assets.push((path: default-css, source: path("default.css")))
}
let config = (
title: title,
url: url.trim(at: end, "/"),
description: description,
author: author,
by: authors(author),
lang: lang,
css: css,
head: head,
pdf: pdf,
preview: preview,
theme: default-theme + theme,
)
let entries = pages.map(item => prepare-page(config, item))
let outputs = assets.map(item => item.path) + feeds.map(feed => feed.path) + ("sitemap.xml",)
for entry in entries {
outputs += (entry.path, entry.pdf-path, entry.preview-path).filter(path => path != none)
}
assert(outputs.len() == outputs.dedup().len(), message: "Duplicate output paths")
for entry in entries {
let ctx = (site: config, page: entry, pages: entries, assets: assets)
let content = [
#site-context.update(ctx)
#entry.body
]
let metadata = (
title: entry.title,
description: entry.description,
author: entry.author,
date: entry.date,
)
document(entry.path, ..metadata, html-document(ctx, content, feeds))
if entry.pdf {
document(entry.pdf-path, ..metadata, (config.theme.pdf)(ctx, content))
}
if entry.preview {
document(entry.preview-path, ..metadata, (config.theme.preview)(ctx))
}
}
for item in assets { asset(item.path, read(item.source, encoding: none)) }
for feed in feeds {
let render = feed.at("render", default: rss)
render(config, entries.filter(entry => entry.collection == feed.collection), feed)
}
sitemap(entries)
}