feat(jbm): support for katex and tables

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-01-02 03:51:38 +01:00
parent 0cd2bc14de
commit c8cb3c4aff
7 changed files with 888 additions and 27 deletions

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title></title>
<style>
@font-face {
font-family: "Inter";
src: url("/_android_res/font/inter_regular.ttf");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/_android_res/font/inter_bold.ttf");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Fragment Mono";
src: url("/_android_res/font/fragmentmono_regular.ttf");
font-weight: 400;
font-style: normal;
}
body, html {
font-family: "Inter", sans-serif;
margin: 0;
padding: 0;
}
pre, code {
font-family: "Fragment Mono", monospace;
}
</style>
<script src="/_android_assets/embedded/katex.min.js"></script>
<link rel="stylesheet" href="/_android_assets/embedded/katex.min.css">
</head>
<body>
<div id="katex"></div>
<script>
window.addEventListener("load", () => {
katex.render(Bridge.getSource(), document.querySelector("#katex"), {
throwOnError: false,
maxExpand: 50
})
document.body.style.color = Bridge.getForegroundColour()
})
</script>
</body>
</html>

View File

@ -0,0 +1,206 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title></title>
<style>
@font-face {
font-family: "Inter";
src: url("/_android_res/font/inter_regular.ttf");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Inter Display";
src: url("/_android_res/font/inter_display_semibold.ttf");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/_android_res/font/inter_bold.ttf");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Fragment Mono";
src: url("/_android_res/font/fragmentmono_regular.ttf");
font-weight: 400;
font-style: normal;
}
body, html {
font-family: "Inter", sans-serif;
}
/* No margins anywhere we don't want them */
body, html, div, table {
margin: 0;
padding: 0;
}
a:link, a:visited {
color: {primary};
text-decoration: none;
}
pre, code {
font-family: "Fragment Mono", monospace;
}
#markdown {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
max-width: 100vw;
overflow-x: hidden;
}
#markdown img {
max-width: 100%;
height: auto;
}
#markdown h2 {
font-size: 1.2em;
font-family: "Inter Display", sans-serif;
font-weight: 600;
}
#markdown p, #markdown li {
line-height: 1.5;
}
#markdown table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
#markdown table, #markdown th, #markdown td {
border: 1px solid #000;
}
#markdown th, #markdown td {
padding: 0.5em;
}
img.user-avatar {
width: calc(1.5em * 0.9);
height: calc(1.5em * 0.9);
vertical-align: middle;
border-radius: 9999px;
margin-right: 0.5em;
}
</style>
</head>
<body>
<script defer type="module">
import {micromark} from "/_android_assets/embedded/micromark.bundle.js"
import {gfmHtml, gfm} from "/_android_assets/embedded/micromark-gfm.bundle.js"
window.addEventListener("load", () => {
document.body.style.color = Bridge.getForegroundColour()
const dynStyles = `
#markdown a {
color: ${Bridge.getPrimaryColour()};
}
#markdown table {
border-color: ${Bridge.getForegroundColour()};
}
#markdown th, #markdown td {
border-color: ${Bridge.getForegroundColour()};
}
a.mention {
color: ${Bridge.getPrimaryColour()};
background-color: ${Bridge.getMentionBackgroundColour()};
padding: 0.25em;
}
`
const style = document.createElement("style")
style.innerHTML = dynStyles
document.head.appendChild(style)
const htmlSource = micromark(Bridge.getSource(), {
extensions: [gfm()],
htmlExtensions: [gfmHtml()]
})
const divWithMarkdown = document.createElement("div")
divWithMarkdown.id = "markdown"
divWithMarkdown.innerHTML = htmlSource
// Remove all image tags, as they are not supported
divWithMarkdown.querySelectorAll("img").forEach(img => img.remove())
// For all <a> tags, rewrite any that include the private _android_assets and _android_res
// directories
divWithMarkdown.querySelectorAll("a").forEach(a => {
if (a.href.includes("_android_assets") || a.href.includes("_android_res")) {
a.href = "#"
}
})
const ULID_REGEXP = /[0-9A-HJKMNP-TV-Z]{26}/g
// Custom emote regex, a ULID wrapped in colons
const CUSTOM_EMOTE_REGEXP = new RegExp(`:${ULID_REGEXP.source}:`, "g")
// User mention regex, a ULID wrapped in angle brackets with a @ before the ULID
const USER_MENTION_REGEXP = new RegExp(`<@${ULID_REGEXP.source}>`, "g")
// Channel mention regex, a ULID wrapped in angle brackets with a # before the ULID
const CHANNEL_MENTION_REGEXP = new RegExp(`<#${ULID_REGEXP.source}>`, "g")
// For anything that contains text, replace the custom emote and user/channel mentions
// First, replace the custom emotes
divWithMarkdown.querySelectorAll("p, li, h1, h2, h3, h4, h5, h6, th, td").forEach(element => {
element.innerHTML = element.innerHTML
.replace(CUSTOM_EMOTE_REGEXP, match => {
const ulid = match.slice(1, -1)
const img = document.createElement("img")
img.src = Bridge.getCustomEmoteUrl(ulid)
img.style.verticalAlign = "middle"
img.style.width = "1.5em"
img.style.height = "1.5em"
// Screen readers must ignore the image as we cannot reliably provide a name
img.alt = ""
img.setAttribute("aria-hidden", "true")
return img.outerHTML
})
})
// Next we replace the user mentions. Micromark thinks they are emails so we search
// for <a> tags that lead to @<ULID>
divWithMarkdown.querySelectorAll("a").forEach(a => {
if (a.href.startsWith("mailto:")) {
const remainingIsUlid = a.href.slice(7).match(ULID_REGEXP)
if (remainingIsUlid) {
const ulid = remainingIsUlid[0]
// First we empty the <a> tag
a.href = "#"
a.innerHTML = ""
// Then we add an image with the user's avatar
const img = document.createElement("img")
img.src = Bridge.userAvatar(ulid)
img.classList.add("user-avatar")
a.appendChild(img)
// Finally we put the user's name
const textNode = document.createTextNode(Bridge.resolveUserMention(ulid))
a.appendChild(textNode)
// We also add a class to the <a> tag so we can style it
a.classList.add("mention")
}
}
})
document.body.appendChild(divWithMarkdown)
})
</script>
</body>
</html>

View File

@ -0,0 +1,159 @@
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebViewAssetLoader
import chat.revolt.activities.InviteActivity
import chat.revolt.api.REVOLT_APP
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ResourceLocations
import chat.revolt.markdown.jbm.LocalJBMarkdownTreeState
import chat.revolt.markdown.jbm.MentionResolver
import chat.revolt.markdown.jbm.asHexString
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun FallbackRenderer(content: String, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val mdState = LocalJBMarkdownTreeState.current
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
val assetLoader = WebViewAssetLoader.Builder()
.setDomain(Uri.parse(REVOLT_APP).host!!)
.addPathHandler(
"/_android_assets/",
WebViewAssetLoader.AssetsPathHandler(context)
)
.addPathHandler(
"/_android_res/",
WebViewAssetLoader.ResourcesPathHandler(context)
)
.build()
webChromeClient = object : WebChromeClient() {}
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
return request?.let { assetLoader.shouldInterceptRequest(it.url) }
}
override fun shouldOverrideUrlLoading(
view: WebView?,
webResourceRequest: WebResourceRequest
): Boolean {
// Capture clicks on invite links
if (webResourceRequest.url.host == "rvlt.gg" ||
(
webResourceRequest.url.host?.endsWith("revolt.chat") == true && webResourceRequest.url.path?.startsWith(
"/invite"
) == true
)
) {
val intent = Intent(
context,
InviteActivity::class.java
).setAction(Intent.ACTION_VIEW)
intent.data = webResourceRequest.url
context.startActivity(intent)
return true
}
// Otherwise, open the link in the browser using androidx.browser
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(colors.background.toArgb())
.build()
)
.build()
customTab.launchUrl(context, webResourceRequest.url)
// Prevent the WebView from navigating to the URL
return true
}
}
settings.apply {
javaScriptEnabled = true
setSupportZoom(false)
setSupportMultipleWindows(false)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
cacheMode = WebSettings.LOAD_NO_CACHE
}
addJavascriptInterface(
object {
@JavascriptInterface
fun getSource(): String {
return content
}
@JavascriptInterface
fun getForegroundColour(): String {
return colors.onBackground.asHexString()
}
@JavascriptInterface
fun getPrimaryColour(): String {
return colors.primary.asHexString()
}
@JavascriptInterface
fun getMentionBackgroundColour(): String {
return colors.primary.copy(alpha = 0.2f).asHexString()
}
@JavascriptInterface
fun getCustomEmoteUrl(emoteId: String): String {
return "$REVOLT_FILES/emojis/$emoteId/original"
}
@JavascriptInterface
fun resolveUserMention(userId: String): String {
return MentionResolver.resolveUser(userId, mdState.currentServer)
}
@JavascriptInterface
fun userAvatar(userId: String): String {
return ResourceLocations.userAvatarUrl(RevoltAPI.userCache[userId])
}
@JavascriptInterface
fun resolveChannelMention(channelId: String): String {
return MentionResolver.resolveChannel(channelId)
}
},
"Bridge"
)
setBackgroundColor(android.graphics.Color.TRANSPARENT)
loadUrl(
"$REVOLT_APP/_android_assets/markdown/markdown.html"
)
}
}
)
}

View File

@ -1,5 +1,6 @@
package chat.revolt.markdown.jbm
import FallbackRenderer
import android.content.Intent
import android.content.res.Configuration
import android.util.Log
@ -22,6 +23,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -89,7 +91,6 @@ import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.launch
import logcat.logcat
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
@ -205,18 +206,11 @@ private fun annotateText(
)
)
val member = state.currentServer?.let { serverId ->
RevoltAPI.members.getMember(serverId, userId)
}
val mentionDisplay = member?.nickname
?: RevoltAPI.userCache[userId]?.username
?: "<@$userId>"
appendInlineContent(JBMAnnotations.JBMBackgroundRoundingStart.tag)
append(" ")
appendInlineContent(JBMAnnotations.UserAvatar.tag, userId)
append(" ")
append(mentionDisplay)
appendInlineContent(JBMAnnotations.JBMBackgroundRoundingEnd.tag)
append(MentionResolver.resolveUser(userId, state.currentServer))
append(" ")
pop()
pop()
@ -243,13 +237,7 @@ private fun annotateText(
)
)
val channel = RevoltAPI.channelCache[channelId]
val mentionDisplay = channel?.name?.let { name -> "#$name" }
?: "<#$channelId>"
appendInlineContent(JBMAnnotations.JBMBackgroundRoundingStart.tag)
append(mentionDisplay)
appendInlineContent(JBMAnnotations.JBMBackgroundRoundingEnd.tag)
append(MentionResolver.resolveChannel(channelId))
pop()
pop()
@ -950,12 +938,17 @@ private fun JBMBlock(node: ASTNode, modifier: Modifier, nestingCounter: Int = 0)
MarkdownElementTypes.HTML_BLOCK,
MarkdownElementTypes.LINK_DEFINITION,
MarkdownTokenTypes.WHITE_SPACE -> {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
fontSize = LocalTextStyle.current.fontSize * state.fontSizeMultiplier
)
) {
JBMText(node, modifier)
// If the only child is a BLOCK_MATH we render it instead
if (node.children.size == 1 && node.children[0].type == GFMElementTypes.BLOCK_MATH) {
JBMBlock(node.children[0], modifier)
} else {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
fontSize = LocalTextStyle.current.fontSize * state.fontSizeMultiplier
)
) {
JBMText(node, modifier)
}
}
}
@ -1074,6 +1067,41 @@ private fun JBMBlock(node: ASTNode, modifier: Modifier, nestingCounter: Int = 0)
}
}
GFMElementTypes.BLOCK_MATH -> {
// No use using Katex in embedded because we don't want to
// create WebViews when embedded
if (!LocalJBMarkdownTreeState.current.embedded) {
val mathContent =
try {
node.getTextInNode(state.sourceText).toString().removeSurrounding("$$")
} catch (e: Exception) {
""
}
KatexRenderer(mathContent, modifier)
}
}
GFMElementTypes.TABLE -> {
// Dito BLOCK_MATH
if (!LocalJBMarkdownTreeState.current.embedded) {
val tableContent = try {
node.getTextInNode(state.sourceText).toString()
} catch (e: Exception) {
""
}
FallbackRenderer(tableContent, modifier)
}
}
MarkdownTokenTypes.HORIZONTAL_RULE -> {
HorizontalDivider(
color = colorScheme.onSurface,
thickness = 1.dp,
modifier = modifier.padding(vertical = 8.dp)
)
}
else -> {
Text(
text = buildAnnotatedString {

View File

@ -0,0 +1,134 @@
package chat.revolt.markdown.jbm
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebViewAssetLoader
import chat.revolt.activities.InviteActivity
import chat.revolt.api.REVOLT_APP
internal fun Color.asHexString(): String {
val argb = toArgb()
val alpha = (argb shr 24 and 0xff) / 255.0f
val red = argb shr 16 and 0xff
val green = argb shr 8 and 0xff
val blue = argb and 0xff
return String.format("#%02x%02x%02x%02x", red, green, blue, (alpha * 255).toInt())
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun KatexRenderer(content: String, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
val assetLoader = WebViewAssetLoader.Builder()
.setDomain(Uri.parse(REVOLT_APP).host!!)
.addPathHandler(
"/_android_assets/",
WebViewAssetLoader.AssetsPathHandler(context)
)
.addPathHandler(
"/_android_res/",
WebViewAssetLoader.ResourcesPathHandler(context)
)
.build()
webChromeClient = object : WebChromeClient() {}
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
return request?.let { assetLoader.shouldInterceptRequest(it.url) }
}
override fun shouldOverrideUrlLoading(
view: WebView?,
webResourceRequest: WebResourceRequest
): Boolean {
// Capture clicks on invite links
if (webResourceRequest.url.host == "rvlt.gg" ||
(
webResourceRequest.url.host?.endsWith("revolt.chat") == true && webResourceRequest.url.path?.startsWith(
"/invite"
) == true
)
) {
val intent = Intent(
context,
InviteActivity::class.java
).setAction(Intent.ACTION_VIEW)
intent.data = webResourceRequest.url
context.startActivity(intent)
return true
}
// Otherwise, open the link in the browser using androidx.browser
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(colors.background.toArgb())
.build()
)
.build()
customTab.launchUrl(context, webResourceRequest.url)
// Prevent the WebView from navigating to the URL
return true
}
}
settings.apply {
javaScriptEnabled = true
setSupportZoom(false)
setSupportMultipleWindows(false)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
cacheMode = WebSettings.LOAD_NO_CACHE
}
addJavascriptInterface(
object {
@JavascriptInterface
fun getSource(): String {
return content
}
@JavascriptInterface
fun getForegroundColour(): String {
return colors.onBackground.asHexString()
}
},
"Bridge"
)
setBackgroundColor(android.graphics.Color.TRANSPARENT)
loadUrl(
"$REVOLT_APP/_android_assets/katex/katex.html"
)
}
}
)
}

View File

@ -0,0 +1,32 @@
package chat.revolt.markdown.jbm
import chat.revolt.api.RevoltAPI
object MentionResolver {
/**
* Resolves a user mention to its fancy representation.
* Note that this uses the new format without a leading @ unless the user is not found.
*
* @param userId The user ID to resolve.
* @param serverId The server ID to resolve the user in.
* @return The resolved user mention.
*/
fun resolveUser(userId: String, serverId: String? = null): String {
val maybeMember = serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
return maybeMember?.nickname
?: RevoltAPI.userCache[userId]?.username
?: "<@$userId>"
}
/**
* Resolves a channel mention to its fancy representation.
*
* @param channelId The channel ID to resolve.
* @param serverId The server ID to resolve the channel in.
* @return The resolved channel mention.
*/
fun resolveChannel(channelId: String): String {
val channel = RevoltAPI.channelCache[channelId]
return channel?.name?.let { name -> "#$name" } ?: "<#$channelId>"
}
}

View File

@ -46,20 +46,268 @@ const deps = [
url: "https://cdn.jsdelivr.net/npm/katex@0.16.19/dist/katex.min.js",
},
{
file: "micromark.bundle.js",
url: "https://esm.sh/v135/micromark@4.0.1/es2022/micromark.bundle.mjs",
file: "fonts/KaTeX_AMS-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_AMS-Regular.ttf",
},
{
file: "fonts/KaTeX_AMS-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_AMS-Regular.woff",
},
{
file: "fonts/KaTeX_AMS-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_AMS-Regular.woff2",
},
{
file: "fonts/KaTeX_Caligraphic-Bold.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Caligraphic-Bold.ttf",
},
{
file: "fonts/KaTeX_Caligraphic-Bold.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Caligraphic-Bold.woff",
},
{
file: "fonts/KaTeX_Caligraphic-Bold.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Caligraphic-Bold.woff2",
},
{
file: "fonts/KaTeX_Caligraphic-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Caligraphic-Regular.ttf",
},
{
file: "fonts/KaTeX_Caligraphic-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Caligraphic-Regular.woff",
},
{
file: "fonts/KaTeX_Caligraphic-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Caligraphic-Regular.woff2",
},
{
file: "fonts/KaTeX_Fraktur-Bold.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Fraktur-Bold.ttf",
},
{
file: "fonts/KaTeX_Fraktur-Bold.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Fraktur-Bold.woff",
},
{
file: "fonts/KaTeX_Fraktur-Bold.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Fraktur-Bold.woff2",
},
{
file: "fonts/KaTeX_Fraktur-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Fraktur-Regular.ttf",
},
{
file: "fonts/KaTeX_Fraktur-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Fraktur-Regular.woff",
},
{
file: "fonts/KaTeX_Fraktur-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Fraktur-Regular.woff2",
},
{
file: "fonts/KaTeX_Main-Bold.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Bold.ttf",
},
{
file: "fonts/KaTeX_Main-Bold.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Bold.woff",
},
{
file: "fonts/KaTeX_Main-Bold.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Bold.woff2",
},
{
file: "fonts/KaTeX_Main-BoldItalic.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-BoldItalic.ttf",
},
{
file: "fonts/KaTeX_Main-BoldItalic.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-BoldItalic.woff",
},
{
file: "fonts/KaTeX_Main-BoldItalic.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-BoldItalic.woff2",
},
{
file: "fonts/KaTeX_Main-Italic.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Italic.ttf",
},
{
file: "fonts/KaTeX_Main-Italic.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Italic.woff",
},
{
file: "fonts/KaTeX_Main-Italic.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Italic.woff2",
},
{
file: "fonts/KaTeX_Main-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Regular.ttf",
},
{
file: "fonts/KaTeX_Main-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Regular.woff",
},
{
file: "fonts/KaTeX_Main-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Main-Regular.woff2",
},
{
file: "fonts/KaTeX_Math-BoldItalic.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Math-BoldItalic.ttf",
},
{
file: "fonts/KaTeX_Math-BoldItalic.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Math-BoldItalic.woff",
},
{
file: "fonts/KaTeX_Math-BoldItalic.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Math-BoldItalic.woff2",
},
{
file: "fonts/KaTeX_Math-Italic.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Math-Italic.ttf",
},
{
file: "fonts/KaTeX_Math-Italic.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Math-Italic.woff",
},
{
file: "fonts/KaTeX_Math-Italic.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Math-Italic.woff2",
},
{
file: "fonts/KaTeX_SansSerif-Bold.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Bold.ttf",
},
{
file: "fonts/KaTeX_SansSerif-Bold.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Bold.woff",
},
{
file: "fonts/KaTeX_SansSerif-Bold.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Bold.woff2",
},
{
file: "fonts/KaTeX_SansSerif-Italic.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Italic.ttf",
},
{
file: "fonts/KaTeX_SansSerif-Italic.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Italic.woff",
},
{
file: "fonts/KaTeX_SansSerif-Italic.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Italic.woff2",
},
{
file: "fonts/KaTeX_SansSerif-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Regular.ttf",
},
{
file: "fonts/KaTeX_SansSerif-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Regular.woff",
},
{
file: "fonts/KaTeX_SansSerif-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_SansSerif-Regular.woff2",
},
{
file: "fonts/KaTeX_Script-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Script-Regular.ttf",
},
{
file: "fonts/KaTeX_Script-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Script-Regular.woff",
},
{
file: "fonts/KaTeX_Script-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Script-Regular.woff2",
},
{
file: "fonts/KaTeX_Size1-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size1-Regular.ttf",
},
{
file: "fonts/KaTeX_Size1-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size1-Regular.woff",
},
{
file: "fonts/KaTeX_Size1-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size1-Regular.woff2",
},
{
file: "fonts/KaTeX_Size2-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size2-Regular.ttf",
},
{
file: "fonts/KaTeX_Size2-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size2-Regular.woff",
},
{
file: "fonts/KaTeX_Size2-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size2-Regular.woff2",
},
{
file: "fonts/KaTeX_Size3-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size3-Regular.ttf",
},
{
file: "fonts/KaTeX_Size3-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size3-Regular.woff",
},
{
file: "fonts/KaTeX_Size3-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size3-Regular.woff2",
},
{
file: "fonts/KaTeX_Size4-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size4-Regular.ttf",
},
{
file: "fonts/KaTeX_Size4-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size4-Regular.woff",
},
{
file: "fonts/KaTeX_Size4-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Size4-Regular.woff2",
},
{
file: "fonts/KaTeX_Typewriter-Regular.ttf",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Typewriter-Regular.ttf",
},
{
file: "fonts/KaTeX_Typewriter-Regular.woff",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Typewriter-Regular.woff",
},
{
file: "fonts/KaTeX_Typewriter-Regular.woff2",
url: "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/fonts/KaTeX_Typewriter-Regular.woff2",
},
{
file: "micromark.bundle.js",
url: "https://esm.sh/v135/micromark@3.2.0/es2022/micromark.bundle.mjs",
},
{
file: "micromark-gfm.bundle.js",
url: "https://esm.sh/v135/micromark-extension-gfm@3.0.0/es2022/micromark-extension-gfm.bundle.mjs",
}
]
console.log("Will download the following files:")
for (const dep of deps) {
console.log(`- ${dep.file} from ${dep.url}`)
}
console.log("Will download the above files.")
if (!confirm("Continue?")) {
console.log("Aborted.")
Deno.exit(0)
}
const fontsFolder = resolve(outputFolder, "fonts")
Deno.mkdirSync(fontsFolder, { recursive: true })
for (const dep of deps) {
const response = await fetch(dep.url)
const data = await response.arrayBuffer()