#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) + "\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) }