feat: make og images an emitter to properly await image generation (#1826)
* checkpoint * make emitters async generators * fix * custom font spec * replace spinner, use disk cache for fonts * use readline instead * make og images look nice
This commit is contained in:
		@@ -1,26 +1,43 @@
 | 
			
		||||
import { Spinner } from "cli-spinner"
 | 
			
		||||
import readline from "readline"
 | 
			
		||||
 | 
			
		||||
export class QuartzLogger {
 | 
			
		||||
  verbose: boolean
 | 
			
		||||
  spinner: Spinner | undefined
 | 
			
		||||
  private spinnerInterval: NodeJS.Timeout | undefined
 | 
			
		||||
  private spinnerText: string = ""
 | 
			
		||||
  private spinnerIndex: number = 0
 | 
			
		||||
  private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
 | 
			
		||||
 | 
			
		||||
  constructor(verbose: boolean) {
 | 
			
		||||
    this.verbose = verbose
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  start(text: string) {
 | 
			
		||||
    this.spinnerText = text
 | 
			
		||||
    if (this.verbose) {
 | 
			
		||||
      console.log(text)
 | 
			
		||||
    } else {
 | 
			
		||||
      this.spinner = new Spinner(`%s ${text}`)
 | 
			
		||||
      this.spinner.setSpinnerString(18)
 | 
			
		||||
      this.spinner.start()
 | 
			
		||||
      this.spinnerIndex = 0
 | 
			
		||||
      this.spinnerInterval = setInterval(() => {
 | 
			
		||||
        readline.clearLine(process.stdout, 0)
 | 
			
		||||
        readline.cursorTo(process.stdout, 0)
 | 
			
		||||
        process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
 | 
			
		||||
        this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
 | 
			
		||||
      }, 100)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  updateText(text: string) {
 | 
			
		||||
    this.spinnerText = text
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  end(text?: string) {
 | 
			
		||||
    if (!this.verbose) {
 | 
			
		||||
      this.spinner!.stop(true)
 | 
			
		||||
    if (!this.verbose && this.spinnerInterval) {
 | 
			
		||||
      clearInterval(this.spinnerInterval)
 | 
			
		||||
      this.spinnerInterval = undefined
 | 
			
		||||
      readline.clearLine(process.stdout, 0)
 | 
			
		||||
      readline.cursorTo(process.stdout, 0)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (text) {
 | 
			
		||||
      console.log(text)
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +1,55 @@
 | 
			
		||||
import { promises as fs } from "fs"
 | 
			
		||||
import { FontWeight, SatoriOptions } from "satori/wasm"
 | 
			
		||||
import { GlobalConfiguration } from "../cfg"
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
import { JSXInternal } from "preact/src/jsx"
 | 
			
		||||
import { ThemeKey } from "./theme"
 | 
			
		||||
import { FontSpecification, ThemeKey } from "./theme"
 | 
			
		||||
import path from "path"
 | 
			
		||||
import { QUARTZ } from "./path"
 | 
			
		||||
import { formatDate } from "../components/Date"
 | 
			
		||||
import { getDate } from "../components/Date"
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get an array of `FontOptions` (for satori) given google font names
 | 
			
		||||
 * @param headerFontName name of google font used for header
 | 
			
		||||
 * @param bodyFontName name of google font used for body
 | 
			
		||||
 * @returns FontOptions for header and body
 | 
			
		||||
 */
 | 
			
		||||
export async function getSatoriFont(headerFontName: string, bodyFontName: string) {
 | 
			
		||||
  const headerWeight = 700 as FontWeight
 | 
			
		||||
  const bodyWeight = 400 as FontWeight
 | 
			
		||||
const defaultHeaderWeight = [700]
 | 
			
		||||
const defaultBodyWeight = [400]
 | 
			
		||||
export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
 | 
			
		||||
  // Get all weights for header and body fonts
 | 
			
		||||
  const headerWeights: FontWeight[] = (
 | 
			
		||||
    typeof headerFont === "string"
 | 
			
		||||
      ? defaultHeaderWeight
 | 
			
		||||
      : (headerFont.weights ?? defaultHeaderWeight)
 | 
			
		||||
  ) as FontWeight[]
 | 
			
		||||
  const bodyWeights: FontWeight[] = (
 | 
			
		||||
    typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight)
 | 
			
		||||
  ) as FontWeight[]
 | 
			
		||||
 | 
			
		||||
  // Fetch fonts
 | 
			
		||||
  const headerFont = await fetchTtf(headerFontName, headerWeight)
 | 
			
		||||
  const bodyFont = await fetchTtf(bodyFontName, bodyWeight)
 | 
			
		||||
  const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
 | 
			
		||||
  const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
 | 
			
		||||
 | 
			
		||||
  // Fetch fonts for all weights
 | 
			
		||||
  const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight))
 | 
			
		||||
  const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight))
 | 
			
		||||
 | 
			
		||||
  const [headerFontData, bodyFontData] = await Promise.all([
 | 
			
		||||
    Promise.all(headerFontPromises),
 | 
			
		||||
    Promise.all(bodyFontPromises),
 | 
			
		||||
  ])
 | 
			
		||||
 | 
			
		||||
  // Convert fonts to satori font format and return
 | 
			
		||||
  const fonts: SatoriOptions["fonts"] = [
 | 
			
		||||
    { name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" },
 | 
			
		||||
    { name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" },
 | 
			
		||||
    ...headerFontData.map((data, idx) => ({
 | 
			
		||||
      name: headerFontName,
 | 
			
		||||
      data,
 | 
			
		||||
      weight: headerWeights[idx],
 | 
			
		||||
      style: "normal" as const,
 | 
			
		||||
    })),
 | 
			
		||||
    ...bodyFontData.map((data, idx) => ({
 | 
			
		||||
      name: bodyFontName,
 | 
			
		||||
      data,
 | 
			
		||||
      weight: bodyWeights[idx],
 | 
			
		||||
      style: "normal" as const,
 | 
			
		||||
    })),
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  return fonts
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -32,32 +59,49 @@ export async function getSatoriFont(headerFontName: string, bodyFontName: string
 | 
			
		||||
 * @param weight what font weight to fetch font
 | 
			
		||||
 * @returns `.ttf` file of google font
 | 
			
		||||
 */
 | 
			
		||||
async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
 | 
			
		||||
export async function fetchTtf(
 | 
			
		||||
  fontName: string,
 | 
			
		||||
  weight: FontWeight,
 | 
			
		||||
): Promise<Buffer<ArrayBufferLike>> {
 | 
			
		||||
  const cacheKey = `${fontName.replaceAll(" ", "-")}-${weight}`
 | 
			
		||||
  const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts")
 | 
			
		||||
  const cachePath = path.join(cacheDir, cacheKey)
 | 
			
		||||
 | 
			
		||||
  // Check if font exists in cache
 | 
			
		||||
  try {
 | 
			
		||||
    // Get css file from google fonts
 | 
			
		||||
    const cssResponse = await fetch(
 | 
			
		||||
      `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
 | 
			
		||||
    )
 | 
			
		||||
    const css = await cssResponse.text()
 | 
			
		||||
 | 
			
		||||
    // Extract .ttf url from css file
 | 
			
		||||
    const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
 | 
			
		||||
    const match = urlRegex.exec(css)
 | 
			
		||||
 | 
			
		||||
    if (!match) {
 | 
			
		||||
      throw new Error("Could not fetch font")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Retrieve font data as ArrayBuffer
 | 
			
		||||
    const fontResponse = await fetch(match[1])
 | 
			
		||||
 | 
			
		||||
    // fontData is an ArrayBuffer containing the .ttf file data (get match[1] due to google fonts response format, always contains link twice, but second entry is the "raw" link)
 | 
			
		||||
    const fontData = await fontResponse.arrayBuffer()
 | 
			
		||||
 | 
			
		||||
    return fontData
 | 
			
		||||
    await fs.access(cachePath)
 | 
			
		||||
    return fs.readFile(cachePath)
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw new Error(`Error fetching font: ${error}`)
 | 
			
		||||
    // ignore errors and fetch font
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Get css file from google fonts
 | 
			
		||||
  const cssResponse = await fetch(
 | 
			
		||||
    `https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
 | 
			
		||||
  )
 | 
			
		||||
  const css = await cssResponse.text()
 | 
			
		||||
 | 
			
		||||
  // Extract .ttf url from css file
 | 
			
		||||
  const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
 | 
			
		||||
  const match = urlRegex.exec(css)
 | 
			
		||||
 | 
			
		||||
  if (!match) {
 | 
			
		||||
    throw new Error("Could not fetch font")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // fontData is an ArrayBuffer containing the .ttf file data
 | 
			
		||||
  const fontResponse = await fetch(match[1])
 | 
			
		||||
  const fontData = Buffer.from(await fontResponse.arrayBuffer())
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await fs.mkdir(cacheDir, { recursive: true })
 | 
			
		||||
    await fs.writeFile(cachePath, fontData)
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.warn(`Failed to cache font: ${error}`)
 | 
			
		||||
    // Continue even if caching fails
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return fontData
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type SocialImageOptions = {
 | 
			
		||||
@@ -108,22 +152,10 @@ export type ImageOptions = {
 | 
			
		||||
   * what description to use as body in image
 | 
			
		||||
   */
 | 
			
		||||
  description: string
 | 
			
		||||
  /**
 | 
			
		||||
   * what fileName to use when writing to disk
 | 
			
		||||
   */
 | 
			
		||||
  fileName: string
 | 
			
		||||
  /**
 | 
			
		||||
   * what directory to store image in
 | 
			
		||||
   */
 | 
			
		||||
  fileDir: string
 | 
			
		||||
  /**
 | 
			
		||||
   * what file extension to use (should be `webp` unless you also change sharp conversion)
 | 
			
		||||
   */
 | 
			
		||||
  fileExt: string
 | 
			
		||||
  /**
 | 
			
		||||
   * header + body font to be used when generating satori image (as promise to work around sync in component)
 | 
			
		||||
   */
 | 
			
		||||
  fontsPromise: Promise<SatoriOptions["fonts"]>
 | 
			
		||||
  fonts: SatoriOptions["fonts"]
 | 
			
		||||
  /**
 | 
			
		||||
   * `GlobalConfiguration` of quartz (used for theme/typography)
 | 
			
		||||
   */
 | 
			
		||||
@@ -141,68 +173,94 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
 | 
			
		||||
  title: string,
 | 
			
		||||
  description: string,
 | 
			
		||||
  fonts: SatoriOptions["fonts"],
 | 
			
		||||
  _fileData: QuartzPluginData,
 | 
			
		||||
  fileData: QuartzPluginData,
 | 
			
		||||
) => {
 | 
			
		||||
  const fontBreakPoint = 22
 | 
			
		||||
  const fontBreakPoint = 32
 | 
			
		||||
  const useSmallerFont = title.length > fontBreakPoint
 | 
			
		||||
  const iconPath = `https://${cfg.baseUrl}/static/icon.png`
 | 
			
		||||
 | 
			
		||||
  // Format date if available
 | 
			
		||||
  const rawDate = getDate(cfg, fileData)
 | 
			
		||||
  const date = rawDate ? formatDate(rawDate, cfg.locale) : null
 | 
			
		||||
 | 
			
		||||
  // Get tags if available
 | 
			
		||||
  const tags = fileData.frontmatter?.tags ?? []
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      style={{
 | 
			
		||||
        display: "flex",
 | 
			
		||||
        flexDirection: "column",
 | 
			
		||||
        justifyContent: "center",
 | 
			
		||||
        alignItems: "center",
 | 
			
		||||
        height: "100%",
 | 
			
		||||
        width: "100%",
 | 
			
		||||
        backgroundColor: cfg.theme.colors[colorScheme].light,
 | 
			
		||||
        gap: "2rem",
 | 
			
		||||
        padding: "1.5rem 5rem",
 | 
			
		||||
        padding: "2.5rem",
 | 
			
		||||
        fontFamily: fonts[1].name,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {/* Header Section */}
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
          width: "100%",
 | 
			
		||||
          flexDirection: "row",
 | 
			
		||||
          gap: "2.5rem",
 | 
			
		||||
          gap: "1rem",
 | 
			
		||||
          marginBottom: "0.5rem",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <img src={iconPath} width={135} height={135} />
 | 
			
		||||
        <img
 | 
			
		||||
          src={iconPath}
 | 
			
		||||
          width={56}
 | 
			
		||||
          height={56}
 | 
			
		||||
          style={{
 | 
			
		||||
            borderRadius: "50%",
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            color: cfg.theme.colors[colorScheme].dark,
 | 
			
		||||
            fontSize: useSmallerFont ? 70 : 82,
 | 
			
		||||
            fontFamily: fonts[0].name,
 | 
			
		||||
            maxWidth: "70%",
 | 
			
		||||
            overflow: "hidden",
 | 
			
		||||
            textOverflow: "ellipsis",
 | 
			
		||||
            fontSize: 32,
 | 
			
		||||
            color: cfg.theme.colors[colorScheme].gray,
 | 
			
		||||
            fontFamily: fonts[1].name,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <p
 | 
			
		||||
            style={{
 | 
			
		||||
              margin: 0,
 | 
			
		||||
              overflow: "hidden",
 | 
			
		||||
              textOverflow: "ellipsis",
 | 
			
		||||
              whiteSpace: "nowrap",
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {title}
 | 
			
		||||
          </p>
 | 
			
		||||
          {cfg.baseUrl}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Title Section */}
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          color: cfg.theme.colors[colorScheme].dark,
 | 
			
		||||
          fontSize: 44,
 | 
			
		||||
          fontFamily: fonts[1].name,
 | 
			
		||||
          maxWidth: "100%",
 | 
			
		||||
          maxHeight: "40%",
 | 
			
		||||
          overflow: "hidden",
 | 
			
		||||
          marginTop: "1rem",
 | 
			
		||||
          marginBottom: "1.5rem",
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <h1
 | 
			
		||||
          style={{
 | 
			
		||||
            margin: 0,
 | 
			
		||||
            fontSize: useSmallerFont ? 64 : 72,
 | 
			
		||||
            fontFamily: fonts[0].name,
 | 
			
		||||
            fontWeight: 700,
 | 
			
		||||
            color: cfg.theme.colors[colorScheme].dark,
 | 
			
		||||
            lineHeight: 1.2,
 | 
			
		||||
            display: "-webkit-box",
 | 
			
		||||
            WebkitBoxOrient: "vertical",
 | 
			
		||||
            WebkitLineClamp: 2,
 | 
			
		||||
            overflow: "hidden",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {title}
 | 
			
		||||
        </h1>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Description Section */}
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          flex: 1,
 | 
			
		||||
          fontSize: 36,
 | 
			
		||||
          color: cfg.theme.colors[colorScheme].darkgray,
 | 
			
		||||
          lineHeight: 1.4,
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <p
 | 
			
		||||
@@ -210,14 +268,80 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
 | 
			
		||||
            margin: 0,
 | 
			
		||||
            display: "-webkit-box",
 | 
			
		||||
            WebkitBoxOrient: "vertical",
 | 
			
		||||
            WebkitLineClamp: 3,
 | 
			
		||||
            WebkitLineClamp: 4,
 | 
			
		||||
            overflow: "hidden",
 | 
			
		||||
            textOverflow: "ellipsis",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {description}
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Footer with Metadata */}
 | 
			
		||||
      <div
 | 
			
		||||
        style={{
 | 
			
		||||
          display: "flex",
 | 
			
		||||
          alignItems: "center",
 | 
			
		||||
          justifyContent: "space-between",
 | 
			
		||||
          marginTop: "2rem",
 | 
			
		||||
          paddingTop: "2rem",
 | 
			
		||||
          borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`,
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {/* Left side - Date */}
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            alignItems: "center",
 | 
			
		||||
            color: cfg.theme.colors[colorScheme].gray,
 | 
			
		||||
            fontSize: 28,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {date && (
 | 
			
		||||
            <div style={{ display: "flex", alignItems: "center" }}>
 | 
			
		||||
              <svg
 | 
			
		||||
                style={{ marginRight: "0.5rem" }}
 | 
			
		||||
                width="28"
 | 
			
		||||
                height="28"
 | 
			
		||||
                viewBox="0 0 24 24"
 | 
			
		||||
                fill="none"
 | 
			
		||||
                stroke="currentColor"
 | 
			
		||||
              >
 | 
			
		||||
                <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
 | 
			
		||||
                <line x1="16" y1="2" x2="16" y2="6"></line>
 | 
			
		||||
                <line x1="8" y1="2" x2="8" y2="6"></line>
 | 
			
		||||
                <line x1="3" y1="10" x2="21" y2="10"></line>
 | 
			
		||||
              </svg>
 | 
			
		||||
              {date}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {/* Right side - Tags */}
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            gap: "0.5rem",
 | 
			
		||||
            flexWrap: "wrap",
 | 
			
		||||
            justifyContent: "flex-end",
 | 
			
		||||
            maxWidth: "60%",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {tags.slice(0, 3).map((tag: string) => (
 | 
			
		||||
            <div
 | 
			
		||||
              style={{
 | 
			
		||||
                display: "flex",
 | 
			
		||||
                padding: "0.5rem 1rem",
 | 
			
		||||
                backgroundColor: cfg.theme.colors[colorScheme].highlight,
 | 
			
		||||
                color: cfg.theme.colors[colorScheme].secondary,
 | 
			
		||||
                borderRadius: "10px",
 | 
			
		||||
                fontSize: 24,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              #{tag}
 | 
			
		||||
            </div>
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ export type RelativeURL = SlugLike<"relative">
 | 
			
		||||
export function isRelativeURL(s: string): s is RelativeURL {
 | 
			
		||||
  const validStart = /^\.{1,2}/.test(s)
 | 
			
		||||
  const validEnding = !endsWith(s, "index")
 | 
			
		||||
  return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "")
 | 
			
		||||
  return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getFullSlug(window: Window): FullSlug {
 | 
			
		||||
@@ -61,7 +61,7 @@ function sluggify(s: string): string {
 | 
			
		||||
 | 
			
		||||
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
 | 
			
		||||
  fp = stripSlashes(fp) as FilePath
 | 
			
		||||
  let ext = _getFileExtension(fp)
 | 
			
		||||
  let ext = getFileExtension(fp)
 | 
			
		||||
  const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
 | 
			
		||||
  if (excludeExt || [".md", ".html", undefined].includes(ext)) {
 | 
			
		||||
    ext = ""
 | 
			
		||||
@@ -272,10 +272,10 @@ function containsForbiddenCharacters(s: string): boolean {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function _hasFileExtension(s: string): boolean {
 | 
			
		||||
  return _getFileExtension(s) !== undefined
 | 
			
		||||
  return getFileExtension(s) !== undefined
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function _getFileExtension(s: string): string | undefined {
 | 
			
		||||
export function getFileExtension(s: string): string | undefined {
 | 
			
		||||
  return s.match(/\.[A-Za-z0-9]+$/)?.[0]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,7 @@ interface Colors {
 | 
			
		||||
  darkMode: ColorScheme
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type FontSpecification =
 | 
			
		||||
export type FontSpecification =
 | 
			
		||||
  | string
 | 
			
		||||
  | {
 | 
			
		||||
      name: string
 | 
			
		||||
@@ -90,6 +90,36 @@ export function googleFontHref(theme: Theme) {
 | 
			
		||||
  return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GoogleFontFile {
 | 
			
		||||
  url: string
 | 
			
		||||
  filename: string
 | 
			
		||||
  extension: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function processGoogleFonts(
 | 
			
		||||
  stylesheet: string,
 | 
			
		||||
  baseUrl: string,
 | 
			
		||||
): Promise<{
 | 
			
		||||
  processedStylesheet: string
 | 
			
		||||
  fontFiles: GoogleFontFile[]
 | 
			
		||||
}> {
 | 
			
		||||
  const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
 | 
			
		||||
  const fontFiles: GoogleFontFile[] = []
 | 
			
		||||
  let processedStylesheet = stylesheet
 | 
			
		||||
 | 
			
		||||
  let match
 | 
			
		||||
  while ((match = fontSourceRegex.exec(stylesheet)) !== null) {
 | 
			
		||||
    const url = match[1]
 | 
			
		||||
    const [filename, extension] = url.split("/").pop()!.split(".")
 | 
			
		||||
    const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`
 | 
			
		||||
 | 
			
		||||
    processedStylesheet = processedStylesheet.replace(url, staticUrl)
 | 
			
		||||
    fontFiles.push({ url, filename, extension })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { processedStylesheet, fontFiles }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function joinStyles(theme: Theme, ...stylesheet: string[]) {
 | 
			
		||||
  return `
 | 
			
		||||
${stylesheet.join("\n\n")}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user