Skip to content

Commit 60212bf

Browse files
committed
Add build script for new site
1 parent 1625bfd commit 60212bf

File tree

1 file changed

+358
-0
lines changed

1 file changed

+358
-0
lines changed

build.scala

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
//> using dep org.http4s::http4s-ember-server::0.23.33
2+
//> using dep org.typelevel::laika-preview::1.3.2
3+
//> using dep com.monovore::decline-effect::2.6.0
4+
//> using dep org.graalvm.js:js:25.0.2
5+
//> using dep org.webjars.npm:katex:0.16.28
6+
//> using dep pink.cozydev::protosearch-laika:0.0-fdae301-SNAPSHOT
7+
//> using repository https://central.sonatype.com/repository/maven-snapshots
8+
//> using option -deprecation
9+
10+
import cats.effect.*
11+
import cats.syntax.all.*
12+
import com.monovore.decline.Opts
13+
import com.monovore.decline.effect.CommandIOApp
14+
15+
// Welcome to the typelevel.org build script!
16+
// This script builds the site and can serve it locally for previewing.
17+
//
18+
// Main -- Entry point
19+
// LaikaBuild -- Laika build, markdown in html out
20+
// LaikaCustomizations -- Custom directives
21+
22+
object Main extends CommandIOApp("build", "builds the site") {
23+
import com.comcast.ip4s.*
24+
import fs2.io.file.{Files, Path}
25+
import laika.io.model.FilePath
26+
import laika.preview.{ServerBuilder, ServerConfig}
27+
import org.http4s.server.Server
28+
29+
enum Subcommand {
30+
case Serve(port: Port)
31+
case Build(output: Path)
32+
}
33+
34+
val portOpt = Opts
35+
.option[Int]("port", "bind to this port")
36+
.mapValidated(Port.fromInt(_).toValidNel("Invalid port"))
37+
.withDefault(port"8000")
38+
39+
val destinationOpt = Opts
40+
.option[String]("out", "site output directory")
41+
.map(Path(_))
42+
.withDefault(Path("target"))
43+
44+
val opts = Opts
45+
.subcommand("serve", "serve the site")(portOpt.map(Subcommand.Serve(_)))
46+
.orElse(destinationOpt.map(Subcommand.Build(_)))
47+
48+
def main = opts.map {
49+
case Subcommand.Build(destination) =>
50+
Files[IO].deleteRecursively(destination).voidError *>
51+
LaikaBuild.build(FilePath.fromFS2Path(destination)).as(ExitCode.Success)
52+
53+
case Subcommand.Serve(port) =>
54+
val serverConfig = ServerConfig.defaults
55+
.withPort(port)
56+
.withBinaryRenderers(LaikaBuild.binaryRenderers)
57+
val server = ServerBuilder[IO](LaikaBuild.parser, LaikaBuild.input)
58+
.withConfig(serverConfig)
59+
.build
60+
server.evalTap(logServer(_)).useForever
61+
}
62+
63+
def logServer(server: Server) =
64+
IO.println(s"Serving site at ${server.baseUri}")
65+
}
66+
67+
object LaikaBuild {
68+
import java.net.{URI, URL}
69+
import laika.api.*
70+
import laika.api.format.*
71+
import laika.ast.*
72+
import laika.config.*
73+
import laika.format.*
74+
import laika.io.config.*
75+
import laika.io.model.*
76+
import laika.io.syntax.*
77+
import laika.parse.code.languages.ScalaSyntax
78+
import laika.theme.*
79+
import pink.cozydev.protosearch.analysis.{IndexFormat, IndexRendererConfig}
80+
import pink.cozydev.protosearch.ui.SearchUI
81+
82+
def input = {
83+
val securityPolicy = new URI(
84+
"https://raw.githubusercontent.com/typelevel/.github/refs/heads/main/SECURITY.md"
85+
).toURL()
86+
87+
InputTree[IO]
88+
.addDirectory("src")
89+
.addInputStream(
90+
IO.blocking(securityPolicy.openStream()),
91+
Path.Root / "security.md"
92+
)
93+
.addClassResource[this.type](
94+
"laika/helium/css/code.css",
95+
Path.Root / "css" / "code.css"
96+
)
97+
}
98+
99+
def theme = {
100+
val provider = new ThemeProvider {
101+
def build[F[_]: Async] =
102+
ThemeBuilder[F]("typelevel.org")
103+
.addRenderOverrides(LaikaCustomizations.overrides)
104+
.build
105+
}
106+
107+
provider.extendWith(SearchUI.standalone)
108+
}
109+
110+
def parser = MarkupParser
111+
.of(Markdown)
112+
.using(
113+
Markdown.GitHubFlavor,
114+
SyntaxHighlighting.withSyntaxBinding("scala", ScalaSyntax.Scala3),
115+
LaikaCustomizations.Directives,
116+
LaikaCustomizations.RssExtensions
117+
)
118+
.withConfigValue(LinkValidation.Global(excluded = Seq(Path.Root / "blog" / "feed.rss")))
119+
.withConfigValue(LaikaKeys.siteBaseURL, "https://typelevel.org/")
120+
.parallel[IO]
121+
.withTheme(theme)
122+
.build
123+
124+
val binaryRenderers = List(
125+
IndexRendererConfig(true),
126+
BinaryRendererConfig(
127+
"rss",
128+
LaikaCustomizations.Rss,
129+
artifact = Artifact(
130+
basePath = Path.Root / "blog" / "feed",
131+
suffix = "rss"
132+
),
133+
false,
134+
false
135+
)
136+
)
137+
138+
def build(destination: FilePath) = parser.use { parser =>
139+
val html = Renderer
140+
.of(HTML)
141+
.withConfig(parser.config)
142+
.parallel[IO]
143+
.withTheme(theme)
144+
.build
145+
val rss = Renderer
146+
.of(LaikaCustomizations.Rss)
147+
.withConfig(parser.config)
148+
.parallel[IO]
149+
.build
150+
val index =
151+
Renderer.of(IndexFormat).withConfig(parser.config).parallel[IO].build
152+
153+
(html, rss, index).tupled.use { (html, rss, index) =>
154+
parser.fromInput(input).parse.flatMap { tree =>
155+
html.from(tree).toDirectory(destination).render *>
156+
rss.from(tree).toFile(destination / "blog" / "feed.rss").render *>
157+
index
158+
.from(tree)
159+
.toFile(destination / "search" / "searchIndex.idx")
160+
.render
161+
}
162+
}
163+
}
164+
}
165+
166+
object LaikaCustomizations {
167+
import cats.data.NonEmptySet
168+
import java.time.OffsetDateTime
169+
import java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME
170+
import laika.config.*
171+
import laika.api.bundle.*
172+
import laika.api.config.*
173+
import laika.api.format.*
174+
import laika.ast.*
175+
import laika.format.*
176+
import laika.theme.*
177+
178+
def addAnchorLinks(fmt: TagFormatter, h: Header) = {
179+
val link = h.options.id.map { id =>
180+
SpanLink
181+
.internal(RelativePath.CurrentDocument(id))(
182+
Literal("", Styles("fas", "fa-link", "fa-sm"))
183+
)
184+
.withOptions(
185+
Styles("anchor-link")
186+
)
187+
}
188+
val linkedContent = link.toList ++ h.content
189+
fmt.newLine + fmt.element(
190+
"h" + h.level.toString,
191+
h.withContent(linkedContent)
192+
)
193+
}
194+
195+
val overrides = HTML.Overrides { case (fmt, h: Header) =>
196+
addAnchorLinks(fmt, h)
197+
}
198+
199+
object RssExtensions extends ExtensionBundle {
200+
def description = "RSS-specific extensions"
201+
202+
override def extendPathTranslator =
203+
ctx => ExtendedTranslator(ctx.baseTranslator)
204+
205+
private final class ExtendedTranslator(delegate: PathTranslator)
206+
extends PathTranslator {
207+
export delegate.{translate, getAttributes}
208+
209+
def forReferencePath(path: Path) =
210+
ExtendedTranslator(delegate.forReferencePath(path))
211+
212+
override def translate(target: Target) = target match {
213+
case InternalTarget.Resolved(absolutePath, relativePath, formats) =>
214+
delegate.translate(
215+
InternalTarget.Resolved(
216+
absolutePath,
217+
relativePath,
218+
TargetFormats.Selected("html") // force HTML links in RSS feed
219+
)
220+
)
221+
case target =>
222+
delegate.translate(target)
223+
}
224+
}
225+
}
226+
227+
object Directives extends DirectiveRegistry {
228+
229+
val templateDirectives = Seq(
230+
// custom Laika template directive for listing blog posts
231+
TemplateDirectives.eval("forBlogPosts") {
232+
import TemplateDirectives.dsl.*
233+
234+
(cursor, parsedBody, source).mapN { (c, b, s) =>
235+
def contentScope(value: ConfigValue) =
236+
TemplateScope(TemplateSpanSequence(b), value, s)
237+
238+
val posts = c.parent.allDocuments.flatMap { d =>
239+
d.config.get[OffsetDateTime]("date").toList.tupleLeft(d)
240+
}
241+
242+
posts
243+
.sortBy(_._2)(using summon[Ordering[OffsetDateTime]].reverse)
244+
.traverse { (d, _) =>
245+
d.config.get[ConfigValue]("").map(contentScope(_))
246+
}
247+
.leftMap(_.message)
248+
.map(TemplateSpanSequence(_))
249+
}
250+
}
251+
)
252+
253+
val linkDirectives = Seq.empty
254+
val spanDirectives = Seq(
255+
SpanDirectives.create("math") {
256+
import SpanDirectives.dsl.*
257+
rawBody.map { body =>
258+
RawContent(
259+
NonEmptySet.of("html", "rss"),
260+
KaTeX(body, false)
261+
)
262+
}
263+
}
264+
)
265+
val blockDirectives = Seq(
266+
BlockDirectives.create("math") {
267+
import BlockDirectives.dsl.*
268+
rawBody.map { body =>
269+
RawContent(
270+
NonEmptySet.of("html", "rss"),
271+
KaTeX(body, true),
272+
Styles("bulma-has-text-centered")
273+
)
274+
}
275+
}
276+
)
277+
}
278+
279+
object Rss
280+
extends TwoPhaseRenderFormat[TagFormatter, BinaryPostProcessor.Builder] {
281+
282+
def interimFormat = new {
283+
def fileSuffix = "rss"
284+
285+
val defaultRenderer = {
286+
case (fmt, Title(_, _)) =>
287+
"" // don't render title b/c it is in the RSS metadata
288+
case (fmt, elem) => HTML.defaultRenderer(fmt, elem)
289+
}
290+
291+
export HTML.formatterFactory
292+
}
293+
294+
def prepareTree(tree: DocumentTreeRoot) = Right(tree)
295+
296+
def postProcessor: BinaryPostProcessor.Builder = new {
297+
def build[F[_]: Async](config: Config, theme: Theme[F]) =
298+
Resource.pure { (result, output, config) =>
299+
val posts = result.allDocuments
300+
.flatMap { d =>
301+
d.config.get[OffsetDateTime]("date").toList.tupleLeft(d)
302+
}
303+
.sortBy(_._2)(using summon[Ordering[OffsetDateTime]].reverse)
304+
305+
output.resource.use { os =>
306+
Async[F].blocking {
307+
val pw = new java.io.PrintWriter(os)
308+
pw.print("""|<?xml version="1.0" encoding="UTF-8" ?>
309+
|<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
310+
|<channel>
311+
|<title>Typelevel Blog</title>
312+
|<link>https://typelevel.org/blog/</link>
313+
|<description>The Typelevel Blog RSS Feed</description>
314+
|""".stripMargin)
315+
316+
posts
317+
.takeWhile(_._2.isAfter(OffsetDateTime.now().minusYears(1)))
318+
.foreach { (doc, _) =>
319+
pw.print(doc.content)
320+
}
321+
322+
pw.print("""|</channel>
323+
|</rss>
324+
|""".stripMargin)
325+
pw.flush()
326+
}
327+
}
328+
}
329+
}
330+
}
331+
}
332+
333+
object KaTeX {
334+
import org.graalvm.polyglot.*
335+
import scala.jdk.CollectionConverters.*
336+
337+
private def loadKaTeX(): String = {
338+
val resourcePath = "/META-INF/resources/webjars/katex/0.16.28/dist/katex.js"
339+
val inputStream = getClass.getResourceAsStream(resourcePath)
340+
new String(inputStream.readAllBytes())
341+
}
342+
343+
private lazy val katex = {
344+
val ctx = Context
345+
.newBuilder("js")
346+
.allowAllAccess(true)
347+
.build()
348+
ctx.eval("js", loadKaTeX())
349+
ctx.getBindings("js").getMember("katex")
350+
}
351+
352+
def apply(latex: String, displayMode: Boolean = false): String =
353+
synchronized {
354+
val options = Map("throwOnError" -> true, "strict" -> true, "displayMode" -> displayMode)
355+
katex.invokeMember("renderToString", latex, options.asJava).asString
356+
}
357+
358+
}

0 commit comments

Comments
 (0)