grace/typst_web_magic
Typst SSG powering grace.pink and words.gay
git clone https://git.gae.moe/grace/typst_web_magic
e5196c6
main
#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 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"><channel>\n"
xml += tag("title", feed.title) + tag("link", config.url) + tag("description", config.description)
for entry in entries.sorted(key: date-key).rev() {
xml += "<item>\n" + tag("title", entry.title) + tag("link", entry.url)
xml += "<guid isPermaLink=\"true\">" + escape(entry.url) + "</guid>\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 += "</item>\n"
}
asset(feed.path, xml + "</channel></rss>\n")
}
#let sitemap(entries) = {
let urls = entries.filter(entry => entry.path != "404.html").map(entry => {
"<url>" + tag("loc", entry.url) + "</url>\n"
})
asset("sitemap.xml",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
+ urls.join() + "</urlset>\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)
}
1# import "pdf.typ" : pdf-layout , pdf-header , code-theme 2# import "metadata.typ" : page , post , authors , author-text , format-date , date-key , normalize-page 3# import "blog.typ" : post-header , post-card 4# import "color/rose-pine.typ" : rose-pine 5# import "color/rose-pine-moon.typ" : rose-pine-moon 6# import "color/rose-pine-dawn.typ" : rose-pine-dawn 7 8# let plain ( value ) = { 9if value == none { return "" } 10if type ( value ) == str { return value } 11if type ( value ) != content { return str ( value ) } 12if value == [ ] { return " " } 13if value . func () == parbreak or value . func () == linebreak { return " \n " } 14if value . has ( "text" ) { return value . text } 15if value . has ( "children" ) { return value . children . map ( plain ). join () } 16if value . has ( "body" ) { return plain ( value . body ) } 17if value . has ( "child" ) { return plain ( value . child ) } 18"" 19} 20 21# let escape ( value ) = ( plain ( value ) 22. replace ( "&" , "&" ) 23. replace ( "<" , "<" ) 24. replace ( ">" , ">" ) 25. replace ( " \" " , """ ) 26. replace ( "'" , "'" )) 27 28# let tag ( name , value ) = "<" + name + ">" + escape ( value ) + "</" + name + "> \n " 29 30# let site-context = state ( "typst_web_magic-context" , none ) 31 32# let image ( file , source : none , alt : "" , width : auto ) = [ 33# metadata (( kind : "post-image" , path : file , alt : alt )) 34# context { 35if target () == "html" { 36html . img ( class : "post-image" , src : "/assets/" + file , alt : alt , loading : "lazy" , decoding : "async" ) 37} else { 38let resolved = if source != none { source } else { 39let ctx = site-context . get () 40assert ( ctx != none , message : "Outside a site, pass source: path(...) to image" ) 41let asset = ctx . assets . find ( item => item . path == "assets/" + file ) 42assert ( asset != none , message : "Image not found in assets: " + file ) 43asset . source 44} 45std . image ( resolved , alt : alt , width : width ) 46} 47} 48] 49 50# let audio ( file ) = context { 51if target () == "html" { 52html . audio ( 53controls : true , 54src : "/assets/" + file , 55) 56} 57} 58 59# let header ( ctx ) = context { 60if ctx . page . collection == "blog" { 61post-header ( ctx ) 62} else if target () == "html" { 63html . elem ( "header" , attrs : ( class : "page-header" )) [ 64# html . h1 ( ctx . page . title ) 65# if ctx . page . date != none { 66html . p ( ctx . page . date . display ( "[month repr:long] [day], [year]" )) 67} 68] 69} else { 70pdf-header ( ctx ) 71} 72} 73 74# let html-layout ( ctx , body ) = html . main (( ctx . site . theme . header )( ctx ) + body ) 75 76# let preview-layout ( ctx ) = [ 77# set std . page ( width : 600pt , height : 315pt , margin : 30pt ) 78# set text ( size : 32pt ) 79# ctx . page . title 80# parbreak () 81# text ( size : 16pt , ctx . site . title ) 82] 83 84# let default-theme = ( 85html : html-layout , 86: pdf-layout , 87preview : preview-layout , 88header : header , 89) 90 91# let ( title : "" , description : none , author : (), date : none , theme : (:), body ) = { 92let theme = default-theme + theme 93let entry = normalize-page ( page ( title : title , description : description , author : author , date : date ), (:)) 94let ctx = ( site : entry + ( theme : theme ), page : entry , pages : ( entry ,)) 95set document ( title : title , description : description , author : entry . author , date : date ) 96( theme . )( ctx , body ) 97} 98 99# let listing ( collection : none , render : none ) = context { 100let ctx = site-context . get () 101let entries = ctx . pages 102. filter ( entry => collection == none or entry . collection == collection ) 103. sorted ( key : date-key ) 104. rev () 105 106for entry in entries { 107if render != none { 108render ( ctx , entry ) 109} else if target () == "html" { 110html . elem ( "article" , attrs : ( class : "listing-item" )) [ 111# html . h2 ( html . a ( href : entry . href , entry . title )) 112# if entry . description != none { html . p ( entry . description ) } 113] 114} else { 115list . item ( link ( entry . url , entry . title )) 116} 117} 118} 119 120# let post-index ( collection : "blog" , render : post-card ) = [ 121# context { 122if target () == "html" { 123html . div ( class : "post-index" , listing ( collection : collection , render : render )) 124} else { 125listing ( collection : collection , render : render ) 126} 127} 128] 129 130# let prepare-page ( config , item ) = { 131let entry = normalize-page ( item . entry , ( 132title : config . title , 133description : config . description , 134author : config . author , 135: config . , 136preview : config . preview , 137)) 138let path = if entry . path == none { item . path } else { entry . path } 139assert ( 140path . ends-with ( ".html" ) and not path . starts-with ( "/" ) and not path . split ( "/" ). contains ( ".." ), 141message : "Page paths must be relative .html paths" , 142) 143 144let href = "/" + if path == "index.html" { "" } else if path . ends-with ( "/index.html" ) { 145path . slice ( 0 , - 10 ) 146} else { path } 147let stem = path . slice ( 0 , - 5 ) 148( 149.. entry , 150body : item . body , 151path : path , 152href : href , 153url : config . url + href , 154pdf-path : if entry . { stem + ".pdf" }, 155preview-path : if entry . preview { "assets/previews/" + stem + ".png" }, 156) 157} 158 159# let html-document ( ctx , body , feeds ) = { 160let config = ctx . site 161let entry = ctx . page 162html . html ( lang : config . lang ) [ 163# html . head [ 164# html . meta ( charset : "utf-8" ) 165# html . meta ( name : "viewport" , content : "width=device-width, initial-scale=1" ) 166# html . title ( plain ( entry . title )) 167# html . meta ( name : "description" , content : plain ( entry . description )) 168# html . link ( rel : "canonical" , href : entry . url ) 169# for ( property , value ) in ( 170"og:title" : plain ( entry . title ), 171"og:description" : plain ( entry . description ), 172"og:url" : entry . url , 173"og:type" : if entry . collection == none { "website" } else { "article" }, 174) { 175html . elem ( "meta" , attrs : ( property : property , content : value )) 176} 177# if entry . preview { 178html . elem ( "meta" , attrs : ( 179property : "og:image" , 180content : config . url + "/" + entry . preview-path , 181)) 182html . meta ( name : "twitter:card" , content : "summary_large_image" ) 183} 184# for stylesheet in config . css { html . link ( rel : "stylesheet" , href : stylesheet ) } 185# for feed in feeds { 186html . link ( rel : "alternate" , type : "application/rss+xml" , title : feed . title , href : "/" + feed . path ) 187} 188# ( config . head )( ctx ) 189] 190# html . body (( config . theme . html )( ctx , body )) 191] 192} 193 194# let rss ( config , entries , feed ) = { 195let xml = "<?xml version= \" 1.0 \" encoding= \" UTF-8 \" ?> \n <rss version= \" 2.0 \" ><channel> \n " 196xml += tag ( "title" , feed . title ) + tag ( "link" , config . url ) + tag ( "description" , config . description ) 197 198for entry in entries . sorted ( key : date-key ). rev () { 199xml += "<item> \n " + tag ( "title" , entry . title ) + tag ( "link" , entry . url ) 200xml += "<guid isPermaLink= \" true \" >" + escape ( entry . url ) + "</guid> \n " 201xml += tag ( "description" , entry . description ) 202if entry . date != none { 203let date = entry . date . display ( "[weekday repr:short], [day padding:zero] [month repr:short] [year]" ) 204xml += tag ( "pubDate" , date + " 00:00:00 +0000" ) 205} 206xml += "</item> \n " 207} 208asset ( feed . path , xml + "</channel></rss> \n " ) 209} 210 211# let sitemap ( entries ) = { 212let urls = entries . filter ( entry => entry . path != "404.html" ). map ( entry => { 213"<url>" + tag ( "loc" , entry . url ) + "</url> \n " 214}) 215asset ( "sitemap.xml" , 216"<?xml version= \" 1.0 \" encoding= \" UTF-8 \" ?> \n " 217+ "<urlset xmlns= \" http://www.sitemaps.org/schemas/sitemap/0.9 \" > \n " 218+ urls . join () + "</urlset> \n " , 219) 220} 221 222# let site ( 223title : "" , 224url : "" , 225description : none , 226author : (), 227lang : "en" , 228css : auto , 229head : ctx => [] , 230: false , 231preview : false , 232theme : (:), 233pages : (), 234assets : (), 235feeds : (), 236) = { 237let default-css = "assets/typst_web_magic.css" 238if css == auto { css = ( "/" + default-css ,) } 239if css . contains ( "/" + default-css ) and not assets . any ( item => item . path == default-css ) { 240assets . push (( path : default-css , source : path ( "default.css" ))) 241} 242let config = ( 243title : title , 244url : url . trim ( at : end , "/" ), 245description : description , 246author : author , 247by : authors ( author ), 248lang : lang , 249css : css , 250head : head , 251: , 252preview : preview , 253theme : default-theme + theme , 254) 255let entries = pages . map ( item => prepare-page ( config , item )) 256let outputs = assets . map ( item => item . path ) + feeds . map ( feed => feed . path ) + ( "sitemap.xml" ,) 257for entry in entries { 258outputs += ( entry . path , entry . pdf-path , entry . preview-path ). filter ( path => path != none ) 259} 260assert ( outputs . len () == outputs . dedup (). len (), message : "Duplicate output paths" ) 261 262for entry in entries { 263let ctx = ( site : config , page : entry , pages : entries , assets : assets ) 264let content = [ 265# site-context . update ( ctx ) 266# entry . body 267] 268let metadata = ( 269title : entry . title , 270description : entry . description , 271author : entry . author , 272date : entry . date , 273) 274 275document ( entry . path , .. metadata , html-document ( ctx , content , feeds )) 276if entry . { 277document ( entry . pdf-path , .. metadata , ( config . theme . )( ctx , content )) 278} 279if entry . preview { 280document ( entry . preview-path , .. metadata , ( config . theme . preview )( ctx )) 281} 282} 283 284for item in assets { asset ( item . path , read ( item . source , encoding : none )) } 285for feed in feeds { 286let render = feed . at ( "render" , default : rss ) 287render ( config , entries . filter ( entry => entry . collection == feed . collection ), feed ) 288} 289sitemap ( entries ) 290}