grace/typst_web_magic

Typst SSG powering grace.pink and words.gay

git clone https://git.gae.moe/grace/typst_web_magic

Grace Yoderadd audio and just edit commande5196c6

main
9.0 KiB290 linesraw

#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("&", "&amp;") .replace("<", "&lt;") .replace(">", "&gt;") .replace("\"", "&quot;") .replace("'", "&apos;"))

#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) = {
9  if value == none { return "" }
10  if type(value) == str { return value }
11  if type(value) != content { return str(value) }
12  if value == [ ] { return " " }
13  if value.func() == parbreak or value.func() == linebreak { return "\n" }
14  if value.has("text") { return value.text }
15  if value.has("children") { return value.children.map(plain).join() }
16  if value.has("body") { return plain(value.body) }
17  if value.has("child") { return plain(value.child) }
18  ""
19}
20
21#let escape(value) = (plain(value)
22  .replace("&", "&amp;")
23  .replace("<", "&lt;")
24  .replace(">", "&gt;")
25  .replace("\"", "&quot;")
26  .replace("'", "&apos;"))
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 {
35    if target() == "html" {
36      html.img(class: "post-image", src: "/assets/" + file, alt: alt, loading: "lazy", decoding: "async")
37    } else {
38      let resolved = if source != none { source } else {
39        let ctx = site-context.get()
40        assert(ctx != none, message: "Outside a site, pass source: path(...) to image")
41        let asset = ctx.assets.find(item => item.path == "assets/" + file)
42        assert(asset != none, message: "Image not found in assets: " + file)
43        asset.source
44      }
45      std.image(resolved, alt: alt, width: width)
46    }
47  }
48]
49
50#let audio(file) = context {
51  if target() == "html" {
52    html.audio(
53      controls: true,
54      src: "/assets/" + file,
55    )
56  }
57}
58
59#let header(ctx) = context {
60  if ctx.page.collection == "blog" {
61    post-header(ctx)
62  } else if target() == "html" {
63    html.elem("header", attrs: (class: "page-header"))[
64      #html.h1(ctx.page.title)
65      #if ctx.page.date != none {
66        html.p(ctx.page.date.display("[month repr:long] [day], [year]"))
67      }
68    ]
69  } else {
70    pdf-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 = (
85  html: html-layout,
86  pdf: pdf-layout,
87  preview: preview-layout,
88  header: header,
89)
90
91#let pdf(title: "", description: none, author: (), date: none, theme: (:), body) = {
92  let theme = default-theme + theme
93  let entry = normalize-page(page(title: title, description: description, author: author, date: date), (:))
94  let ctx = (site: entry + (theme: theme), page: entry, pages: (entry,))
95  set document(title: title, description: description, author: entry.author, date: date)
96  (theme.pdf)(ctx, body)
97}
98
99#let listing(collection: none, render: none) = context {
100  let ctx = site-context.get()
101  let entries = ctx.pages
102    .filter(entry => collection == none or entry.collection == collection)
103    .sorted(key: date-key)
104    .rev()
105
106  for entry in entries {
107    if render != none {
108      render(ctx, entry)
109    } else if target() == "html" {
110      html.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 {
115      list.item(link(entry.url, entry.title))
116    }
117  }
118}
119
120#let post-index(collection: "blog", render: post-card) = [
121  #context {
122    if target() == "html" {
123      html.div(class: "post-index", listing(collection: collection, render: render))
124    } else {
125      listing(collection: collection, render: render)
126    }
127  }
128]
129
130#let prepare-page(config, item) = {
131  let entry = normalize-page(item.entry, (
132    title: config.title,
133    description: config.description,
134    author: config.author,
135    pdf: config.pdf,
136    preview: config.preview,
137  ))
138  let path = if entry.path == none { item.path } else { entry.path }
139  assert(
140    path.ends-with(".html") and not path.starts-with("/") and not path.split("/").contains(".."),
141    message: "Page paths must be relative .html paths",
142  )
143
144  let href = "/" + if path == "index.html" { "" } else if path.ends-with("/index.html") {
145    path.slice(0, -10)
146  } else { path }
147  let stem = path.slice(0, -5)
148  (
149    ..entry,
150    body: item.body,
151    path: path,
152    href: href,
153    url: config.url + href,
154    pdf-path: if entry.pdf { stem + ".pdf" },
155    preview-path: if entry.preview { "assets/previews/" + stem + ".png" },
156  )
157}
158
159#let html-document(ctx, body, feeds) = {
160  let config = ctx.site
161  let entry = ctx.page
162  html.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      ) {
175        html.elem("meta", attrs: (property: property, content: value))
176      }
177      #if entry.preview {
178        html.elem("meta", attrs: (
179          property: "og:image",
180          content: config.url + "/" + entry.preview-path,
181        ))
182        html.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 {
186        html.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) = {
195  let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"><channel>\n"
196  xml += tag("title", feed.title) + tag("link", config.url) + tag("description", config.description)
197
198  for entry in entries.sorted(key: date-key).rev() {
199    xml += "<item>\n" + tag("title", entry.title) + tag("link", entry.url)
200    xml += "<guid isPermaLink=\"true\">" + escape(entry.url) + "</guid>\n"
201    xml += tag("description", entry.description)
202    if entry.date != none {
203      let date = entry.date.display("[weekday repr:short], [day padding:zero] [month repr:short] [year]")
204      xml += tag("pubDate", date + " 00:00:00 +0000")
205    }
206    xml += "</item>\n"
207  }
208  asset(feed.path, xml + "</channel></rss>\n")
209}
210
211#let sitemap(entries) = {
212  let urls = entries.filter(entry => entry.path != "404.html").map(entry => {
213    "<url>" + tag("loc", entry.url) + "</url>\n"
214  })
215  asset("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(
223  title: "",
224  url: "",
225  description: none,
226  author: (),
227  lang: "en",
228  css: auto,
229  head: ctx => [],
230  pdf: false,
231  preview: false,
232  theme: (:),
233  pages: (),
234  assets: (),
235  feeds: (),
236) = {
237  let default-css = "assets/typst_web_magic.css"
238  if css == auto { css = ("/" + default-css,) }
239  if css.contains("/" + default-css) and not assets.any(item => item.path == default-css) {
240    assets.push((path: default-css, source: path("default.css")))
241  }
242  let config = (
243    title: title,
244    url: url.trim(at: end, "/"),
245    description: description,
246    author: author,
247    by: authors(author),
248    lang: lang,
249    css: css,
250    head: head,
251    pdf: pdf,
252    preview: preview,
253    theme: default-theme + theme,
254  )
255  let entries = pages.map(item => prepare-page(config, item))
256  let outputs = assets.map(item => item.path) + feeds.map(feed => feed.path) + ("sitemap.xml",)
257  for entry in entries {
258    outputs += (entry.path, entry.pdf-path, entry.preview-path).filter(path => path != none)
259  }
260  assert(outputs.len() == outputs.dedup().len(), message: "Duplicate output paths")
261
262  for entry in entries {
263    let ctx = (site: config, page: entry, pages: entries, assets: assets)
264    let content = [
265      #site-context.update(ctx)
266      #entry.body
267    ]
268    let metadata = (
269      title: entry.title,
270      description: entry.description,
271      author: entry.author,
272      date: entry.date,
273    )
274
275    document(entry.path, ..metadata, html-document(ctx, content, feeds))
276    if entry.pdf {
277      document(entry.pdf-path, ..metadata, (config.theme.pdf)(ctx, content))
278    }
279    if entry.preview {
280      document(entry.preview-path, ..metadata, (config.theme.preview)(ctx))
281    }
282  }
283
284  for item in assets { asset(item.path, read(item.source, encoding: none)) }
285  for feed in feeds {
286    let render = feed.at("render", default: rss)
287    render(config, entries.filter(entry => entry.collection == feed.collection), feed)
288  }
289  sitemap(entries)
290}