LaunchFast Logo LaunchFast

Font Optimization for Astro websites

Rishi Raj Jain
astro-font by LaunchFa.st

In ~1 month, astro-font has grown to 5.7K downloads 🤯

Checkpoint #1

It all began with me thinking about what is missing from the Astro ecosystem? After checking out some of the websites (including my own hybrid Astro website, launchfa.st), I found that an end to end font optimization library was missing 👇

Checkpoint #2

So, I actually set out to built an Astro package that’ll deliver the promise. I thought it’d be super easy to just use the scripts @vercel’s next/font does and ship it (and I did that very initially!). It worked great for static websites! BUT, enter SSR websites👇

Roadblock #1

next/font being coupled with the build process of Next has access to the output directories and the expected runtime configuration and thus it self-hosts fonts even in SSR-first websites. This space got more complex for Astro SSR websites as astro-font is an Astro component and not an Astro integration! Here’s what I did to solve that problem 👇

async function getOS(): Promise<typeof import('node:os') | undefined> {
  let os
  try {
    os = await import('node:os')
    return os
  } catch (e) {}
}

// Check if writing is permitted by the file system
async function ifFSOSWrites(dir: string): Promise<string | undefined> {
  try {
    const fs = await getFS()
    if (fs) {
      const testDir = join(dir, '.astro_font')
      if (!fs.existsSync(testDir)) fs.mkdirSync(testDir)
      fs.rmSync(testDir, { recursive: true, force: true })
      return dir
    }
  } catch (e) {}
}

Roadblock #2

Great! That worked and allowed me to determine if the SSR build had fonts shipped with it and thus allowing me to compute the fallback font at the runtime. BUT, some users wanted to use CDN URLs or were using fontsource fonts. There was no way to possibly know to what Vite resolved the internal fonts to. Hence, I built a runtime Google Fonts-like CSS parser 👇

// Custom script to parseGoogleCSS
function parseGoogleCSS(tmp: string) {
  let match
  const fontFaceMatches = []
  const fontFaceRegex = /@font-face\s*{([^}]+)}/g
  while ((match = fontFaceRegex.exec(tmp)) !== null) {
    const fontFaceRule = match[1]
    const fontFaceObject: any = {}
    fontFaceRule.split(';').forEach((property) => {
      if (property.includes('src: ')) {
        const formatPosition = property.indexOf('for')
        fontFaceObject['path'] = property
          .trim()
          .substring(9, formatPosition ? formatPosition - 5 : property.length - 1)
          .trim()
      }
      if (property.includes('-style: ')) {
        fontFaceObject['style'] = property.split(':').map((i) => i.trim())[1]
      }
      if (property.includes('-weight: ')) {
        fontFaceObject['weight'] = property.split(':').map((i) => i.trim())[1]
      }
      if (property.includes('unicode-range: ')) {
        if (!fontFaceObject['css']) {
          fontFaceObject['css'] = {}
        }
        fontFaceObject['css']['unicode-range'] = property.split(':').map((i) => i.trim())[1]
      }
    })
    fontFaceMatches.push(fontFaceObject)
  }
  return fontFaceMatches
}

Checkpoint #3

Seems complete, right? It now works with local fonts and fonts over CDN. But runtime fetch + compute is gonna cost us SSR time. To solve that, enter runtime font caching 👇

const [os, fs] = await Promise.all([getOS(), getFS()])
if (fs) {
    if (os) {
      writeAllowed = await Promise.all([ifFSOSWrites(os.tmpdir()), ifFSOSWrites('/tmp')])
      tmpDir = writeAllowed.find((i) => i !== undefined)
      cacheDir = fontCollection.cacheDir || tmpDir
      if (cacheDir) {
        // Create a json based on slugified path, style and weight
        const slugifyPath = (i: Source) => `${i.path}_${i.style}_${i.weight}`
        const slugifiedCollection = fontCollection.src.map(slugifyPath)
        const cachedFileName = simpleHash(slugifiedCollection.join('_')) + '.txt'
        cachedFilePath = join(cacheDir, cachedFileName)
        if (fs.existsSync(cachedFilePath)) {
          try {
            const tmpCachedFilePath = fs.readFileSync(cachedFilePath, 'utf8')
            return JSON.parse(tmpCachedFilePath)
          } catch (errorReadingCache) {}
        }
      }
    }
}

Checkpoint #4

Now? We’re just left with one thing to do. Allow per font per config preloads (and backward support to global preloads (not))!

// If the parent preload is set to be false, look for true only preload values
if (fontCollection.preload === false) {
    return fontCollection.src
        .filter((i) => i.preload === true)
        .map((i) => getRelativePath(getBasePath(fontCollection.basePath), i.path))
}

// If the parent preload is set to be true (or not defined), look for non-false values
return fontCollection.src
    .filter((i) => i.preload !== false)
    .map((i) => getRelativePath(getBasePath(fontCollection.basePath), i.path))

And we’re done, and it’s live in production for many Astro websites ✨

Learn More Create a Telegram Bot in Next.js App Router: A Step-by-Step Guide → Injecting Environment Variables Dynamically in Cloudflare Pages → Using Unplugin Icons in Next.js: A Step-by-Step Guide →