Skip to content

Published

Obligatory Blog Update #1

My website has been long overdue for a renovation, and it finally happened over a year later!

After rewriting the website in Next.js, adapting to the cutting-edge App Router, styling the site with Tailwind CSS, and stitching together countless other open source projects, I'm finally satisfied with how it's turned out.

While some of the changes are obvious, many of them are less so — and in this post, I will be discussing some of challenges in development that stood out to me.

Dark mode hydration

This site uses a theme hydration script to avoid theme flicker on initial page load, with something like this in a raw <script> tag:

<script>
  if (
    localStorage.theme === 'dark' ||
    (!('theme' in localStorage) &&
      window.matchMedia('(prefers-color-scheme: dark)').matches)
  ) {
    document.documentElement.classList.add('dark')
    document
      .querySelector('meta[name="theme-color"]')
      .setAttribute('content', '#1e1e2e')
  } else {
    document.documentElement.classList.remove('dark')
  }
</script>

This is quite hacky by React standards, but I have found no alternative that solves the issue. You can find similar <script> tags on websites like Tailwind CSS (archive) and Josh W. Comeau's blog (archive) (both of which also use Next.js).

Font optimization

In addition to the font optimization provided by Next.js, I've also done some custom optimization for the Phosphor icons that my website uses.

First, why is this necessary? My site uses different icons depending on the theme (most notably, the dark mode toggle button), and swapping different SVG icons in JavaScript is slow. So, instead of displaying all icons with <svg> elements and switching them in JavaScript, switchable icons are displayed with the font-family and content CSS attributes and switched with a .dark selector. Something like:

.icon-dark-mode-toggle {
  --font-family: 'Phosphor Regular';
  --content: '\ed3f';
}

.dark .icon-dark-mode-toggle {
  --font-family: 'Phosphor Fill';
  --content: '\ebfe';
}

.icon-dark-mode-toggle::before {
  font-family: var(--font-family);
  content: var(--content);
}

The issue with this approach is that these font files are quite large: the regular variant of Phosphor v2.0.0 alone sits at 351.3kB.

To get around this issue, I used an arcane technique known as font subsetting to filter out all of the glyphs I didn't need, bringing the file size down to 1.16kB and allowing me to use the "fill" variant at 904B (yes, Bytes). Doing that with fontTools looks something like:

pyftsubset "Phosphor.ttf" --unicodes="U+eb53,U+ebc4,U+ec48,U+ed3f" --output-file="Phosphor.subset.ttf"
pyftsubset "Phosphor-Fill.ttf" --unicodes="U+eb53,U+ebc3,U+ec48,U+ebfe" --output-file="Phosphor-Fill.subset.ttf"

Automation

I didn't want to type the whole command every time I wanted to generate a font subset, especially since it's easy to make mistakes. I also didn't want to write something like content: '\ebfe' every time I wanted to use a particular font glyph. So I wrote a JSON file:

{
  "regular": {
    "githubLogo": "eb53",
    "linkedinLogo": "ebc4",
    "paperPlaneTilt": "ec48",
    "sun": "ed3f"
  },
  "fill": {
    "githubLogo": "eb53",
    "linkedinLogo": "ebc3",
    "moon": "ebfe",
    "paperPlaneTilt": "ec48"
  }
}

Then I used it in a Bash script that runs pyftsubset to generate the font subsets, and in an ad hoc Tailwind plugin to generate content-* utility classes. I plan on making the Bash script reproducible with Nix in the future (though this is low on my list of priorities).

Blog back-end

Like the old site, this site uses GraphQL for querying posts and MDX for composition.

However, unlike the old site, I opted to write a minimalistic GraphQL client that acts as a thin wrapper for GraphQL.js (with static type checking courtesy of GraphQL Code Generator). No caching, no batching — just build-time GraphQL queries with zero impact on runtime performance.

To integrate my thin GraphQL client with GraphQL codegen, I used currying and wrote some monstrous type annotations to propagate the types:

import type { ExecutionResult, GraphQLArgs, Source } from 'graphql'
import type { Simplify } from 'type-fest'

type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }
type NeverObject = { [key: string]: never }

interface FetchQLArgs
  extends Omit<
    GraphQLArgs,
    | 'contextValue'
    | 'fieldResolver'
    | 'rootValue'
    | 'schema'
    | 'source'
    | 'typeResolver'
    | 'variableValues'
  > {
  variables?: GraphQLArgs['variableValues']
}

async function fetchql<TData>(
  query: string | Source,
  args: FetchQLArgs,
): Promise<ExecutionResult<TData>> {
  /* omitted */
}

export function graphql(query: string | Source) {
  return <QueryType, VariablesType>(
    args: VariablesType extends { [key: string]: unknown }
      ? VariablesType extends NeverObject
        ? Simplify<Omit<FetchQLArgs, 'variables'>>
        : Simplify<
            Omit<FetchQLArgs, 'variables'> & {
              variables: Exact<VariablesType>
            }
          >
      : FetchQLArgs,
  ) =>
    fetchql(query, args) as QueryType extends { __typename?: 'Query' }
      ? Promise<
          Simplify<
            Omit<ExecutionResult<QueryType>, 'data'> & {
              data: QueryType
            }
          >
        >
      : ReturnType<typeof fetchql>
}

This would be used like so:

import type { GetPostQuery, GetPostQueryVariables } from './page.generated'

const getPost = graphql(`
  query GetPost($slug: String!) {
    post(slug: $slug) {
      title
      datePublished
      image
      coverImage
      tags
      relativePathComponent
    }
  }
`)

type PageProps = {
  params: {
    post: string
  }
}

export default async function PostPage({ params: { post } }: PageProps) {
  const {
    data: {
      post: {
        title,
        datePublished,
        image,
        coverImage,
        tags,
        relativePathComponent,
      },
    },
  } = await getPost<GetPostQuery, GetPostQueryVariables>({
    variables: { slug: post },
  })
}

This has a couple of positive effects on developer experience and code correctness:

  • The requested query fields are all type checked, so you can't accidentally use a field you didn't request, or use a field you did request in an incorrect way, such as passing an object to a function which requires a number.
  • Query variables are also type checked, and non-nullable variables (via !) will be noisily required.
  • All of this is enforced at build time by the TypeScript compiler, and encouraged at development time by the TypeScript language server.

Feed

RSS feed generation is fairly straightforward thanks to feed!

However, something that ended up being non-trivial was including the entire post content for all of my posts, which involves manually rendering a React Server Component to a string. ReactDOM.renderToStaticMarkup (and similar functions) didn't work, but scouring the Next.js source code led me to renderToString which I then shamelessly used for my feed generation (it's only meant for internal use, so I don't recommend this).

Then, I faced an issue where I wanted to render different components in the feed than usual — in particular, <img> and <a> instead of <Image> and <Link>. Since I was using webpack imports to get the MDX post as a React component, I used a webpack resource query to conditionally use an alternate providerImportSource used exclusively for feed generation.

So, if a post would normally use:

export function useMDXComponents(components) {
  return {
    a: (props) => <Link {...} />,
    img: (props) => <Image {...} />,
    Image: (props) => <Image {...} />,
    ...components,
  }
}

...importing with import(`@/posts/${...}?feed`) would instead use:

export function useMDXComponents(components) {
  return {
    a: (props) => <a {...} />,
    img: (props) => <img {...} />,
    Image: (props) => <img {...} />,
    ...components,
  }
}

...with some additional code for path translation, so /img/... would become https://loqusion.dev/img/..., etc.

Resume

Finally, we get to the fun part: the resume PDF.

I've always hated traditional WYSIWYG editors that don't separate style from content, making getting the style right involve tediously navigating menus for each individual segment of content and occasionally forcing you to do it again when editing content breaks the style.

Luckily, there's a way to compose PDFs that doesn't suck: Typst. In short, it features an intuitive markup language that enables you to write content in a way similar to markdown and declare styles with a simple, composable API.

I have my resume written up in a separate repository from my website. Whenever I create a new resume release, a GitHub Action sends a repository_dispatch event to my website repository that triggers another GitHub Action to copy the PDF to the public subdirectory, committing the result and triggering a new deployment.

My resume also uses external resources like icons and fonts, which are fetched automatically as a build step with Typix. (psssst... I wrote it!)

When Typst supports HTML export, I'll use it to generate my resume page. Until then, I'm resorting to copy-and-paste.


I'm not just going to publish this post and go on another year-long hiatus. I have plenty of post ideas in the making, and I want to make this a regular thing now that I've developed the infrastructure to make it happen.

If you're interested and would like to see more, I have an RSS feed and a JSON feed which you can subscribe to.


  • Update