Oct 10, 2023

How I made this blog

A somewhat elaborate history about what I'm trying to achieve with this blog and the reasoning behind the technology choices that come with it

Astro and Vite logos together
Astro and Vite logos together (available at the Astro blog)

Who are you, stranger?

I’m a Full Stack developer with a degree in Computer Engineering from Instituto Tecnológico de Aeronáutica (ITA), located in Brazil. I like talking about software in general and explore brand new technologies coming out, trying to squeeze the most out of them. And I like compilers. And build tools. Webpack is ugly though.

So, I was wondering about writing a blog and thought “why not?“. BUT, I din’t want to go the Medium/dev.to path and just write plain blog posts, I kinda wanted to do something different, a little coding maybe, which explains my next point.

Why are you doing this?

I want to write posts about coding and actually show things working, add some interactivity to users, if I actually got any. Personally, I don’t really care if someone is really reading it or not, I just want to build something nice that I can be sort of proud of, a little hobby.

I could use some blog template somewhere in the internet, but what’s the fun about it? I wanted to build it from scratch, starting from the operating system, and implement all the things I want included. Of course, I don’t wanna implement the SSR bits or do any systems programming in order to achieve that, it’s still just a freaking blog.

So, Astro, huh?

I was looking into Astro for some time with some interest. The ability to use many frameworks at once was definetely a catch to me. See, I want to do a practical display of many different technologies and Astro gives me a way to easilly do that and even integrate them (at least for web frameworks), what’s not to like about it?

So, I decided to give it a try and the experience was really good. The multi-framework thing is not the only thing Astro does for you. It can also do SSR and SSG out of the box without ever being confusing on wheter some code will run on client or server side (or even at build time), everything shipping JS only when it’s necessary.

It alsos encourages devs to reach directly to Web APIs in order to add low bundle cost interactivity to their websites. And it uses Vite, so, we have really fast feedback loops.

In order to show some of these things, I built the same component in Vue, Svelte and React, then I wrapped them into a bunch of Astro components that coordinate everything. The framework component is simply a counter with an increment and a decrement button, everything else is just Typescript directly manipulating the DOM.

0
<script setup lang="ts">
import { ref, onMounted, watchEffect } from 'vue';
import { createDomLogger } from '@/utils/dom';

const count = ref(0);

onMounted(() => {
  const logger = createDomLogger('.component-logs');
  logger.log('Vue SimpleCounter is interactive');
  watchEffect(() => logger.log(`Vue count is ${count.value}`));
});
</script>
<template>
  <div class="my-10 flex items-center gap-5 justify-center">
    <button
      class="btn"
      :disabled="count <= 0"
      @click="--count"
    >-</button>
    <div>
      {{ count }}
    </div>
    <button
      class="btn"
      @click="++count"
    >+</button>
  </div>
</template>
<style scoped lang="scss">
.btn {
  @apply
    dark:bg-red-950
    bg-red-600
    text-white
    flex
    items-center
    disabled:opacity-30
    justify-center
    text-2xl
    h-10
    aspect-square
    rounded-full
    select-none;
}
</style>
0
<script lang="ts">
import { onMount } from 'svelte';
import { createDomLogger, type DomLogger } from '@/utils/dom';

let count = 0;
let logger: DomLogger;

onMount(() => {
  logger = createDomLogger('.component-logs');
  logger.log('Svelte SimpleCounter is interactive');
});

$: logger?.log(`Svelte count is ${count}`);
</script>
<div class="my-10 flex items-center gap-5 justify-center">
  <button
    class="btn"
    disabled={count <= 0}
    on:click={() => --count}
  >-</button>
  <div>
    {count}
  </div>
  <button
    class="btn"
    on:click={() => ++count}
  >+</button>
</div>
<style lang="postcss">
.btn {
  @apply
    dark:bg-red-950
    bg-red-600
    text-white
    disabled:opacity-30
    flex
    items-center
    justify-center
    text-2xl
    h-10
    aspect-square
    rounded-full
    select-none;
}
</style>
0
import { useState, useCallback, useEffect, useRef } from 'react';
import { createDomLogger, type DomLogger } from '@/utils/dom';

const btnClasses = `
  dark:bg-red-950
  bg-red-600
  text-white
  disabled:opacity-30
  flex
  items-center
  justify-center
  text-2xl
  h-10
  aspect-square
  rounded-full
  select-none
`;

export default function SimpleCounter() {
  const [count, setCount] = useState(0);
  const logger = useRef<DomLogger>();

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);

  useEffect(() => {
    logger.current = createDomLogger('.component-logs');
    logger.current.log('React SimpleCounter is interactive');
  }, []);

  useEffect(() => {
    logger.current?.log(`React count is ${count}`);
  }, [count]);

  return <>
    <div className="my-10 flex items-center gap-5 justify-center">
      <button
        className={btnClasses}
        disabled={count <= 0}
        onClick={decrement}
      >-</button>
      <div>
        {count}
      </div>
      <button
        className={btnClasses}
        onClick={increment}
      >+</button>
    </div>
  </>;
}

Notice how those components play nice together and, most importantly, how their bundles are loaded only when they’re visible on the screen (check your Network tab if you want to be sure), which was achieved by simply adding a client:visible directive.

But not just Astro

Of course one can build an entire application from just Astro, but that’s not the idea here. I want to use every single shiny thing left by the ecosystem. Then, here I’ll list the tools I used and how I used them. Some tools are inherent to Astro, like Vite, Remark and Rehype, some are choices, like Highlight.js and TailwindCSS. For all tools that fall into this second category, I’ll try my best to explain why I choose them.

But here’s a catch: installing dependencies should be a last resort to achieve things, since each one of them can pose a security risk and you need to figure out how to use all of them. So, the only tools listed here are the ones that provide features I really cannot implement by myself.

Maybe I’m reinventing the wheel sometimes, but I think it’s better to tailor made a wheel that attends all of my needs than buying a full blown automaker just because I need a simple wheel, which is the case for most npm packages.

pnpm

pnpm is a disk efficient package manager which I didn’t have tried at the time of writing this post and wanted to try it out too and it was simply wonderful. I needed a few more pnpm add’s to get some things to work, but it’s a small price to pay for pnpm’s global package store and its strictness i.e if I use an import statement, I know it’s gonna work since it’s on my package.json.

TailwindCSS

If you read some source code in the section above, you probably noticed that I’m using TailwindCSS, because utility classes make my life better, it was incredibly easy to setup and integrates really well with the light/dark toggle.

Now that we’re getting tailwindy, there are different ways we can use it. We could, for instance, just add a bunch of classes on everything we want to style (and it’s fine), but I prefered to tweak it a little bit, so I went without @astrojs/tailwind base styles

// astro.config.mjs

import tailwind from "@astrojs/tailwind";

export default defineConfig({
  integrations: [tailwind({ applyBaseStyles: false })],
});

then I configured the background and font colors on tailwind.config.cjs and added a bunch of styles inside @layer base on my main.scss, which is imported by my base layout. Then I started to write this very post and feel the need to create another file with some custom configuration simply for the posts content.

SCSS

Now, this can feel not ideal for some people and Tailwind even discourages the use of preprocessors, but I really like SCSS for its nesting and the ability to define build-time variables. I admit this can be more of a personal taste than anything else, but I think there’s no problem with that since this is just a personal blog.

Maybe I can achieve everything SCSS does with PostCSS, which is installed in Vite by default and provides the API used by Tailwind, but I simply know SCSS more and its setup was even easier than Tailwind’s.

MDX

Markdown is great for its simple syntax that enables users to focus more on the content instead of worrying about HTML structure, even though you can use HTML inside a markdown text. Since I’m writting a blog, which is content heavy by default, markdown is almost mandatory.

But since we cannot use components inside markdown, writting interactive pages with it starts to be harder than using Astro components. This is where MDX comes in - as a superset of Markdown that allows usage of components, we have the best of both words for authoring our posts. Besides, adding it was really easy, I just needed to run a simple pnpm astro add mdx and I was good to go.

Highlight.js

This was probably the best part of this implementation. Per the docs, Astro comes with two built-in syntax highlighters: Prism and Shiki. It happened that both of them weren’t good enough to me for toggling light and dark mode of the Atom One theme, which is a personal favorite, especially the dark one.

So, I stumbled uppon Highlight.js, which has a simple and easy API with great customization capability, since it just uses CSS classes. With SCSS, implementing the dark mode toggler was really simple:

@import 'highlight.js/styles/atom-one-light';

:root.dark {
  @import 'highlight.js/styles/atom-one-dark';
}

Now, two things are missing:

  • Remark’s doesn’t put the markup generated by highlight.js into the HTML generated from markdown code
  • Those styles don’t apply at all to my code snippets

So, we need to address those, the easy way? Of course not, the hard way, which is much more interesting and will interact with both Remark’s and Vite’s plugin APIs.

Remark

Astro uses Remark under the hood to parse markdown and generate HTML from that. This tool has a nice plugin API that can be used to customize HTML generation. So, we can use that to render the markdown code blocks in an appropriate way, i.e. wrapped in <pre><code class="hljs"> and with the appropriate hljs classes.

We can do that by traversing Remark’s AST and replacing the code nodes with html ones containing our hljs generated markup.

import type { Html, Root, RootContent } from 'mdast';
import { getHighlightHtml } from '../../src/utils/highlight';

async function visit(node: Root | RootContent) {
  if (node.type === 'code') {
    const htmlNode = node as never as Html;

    htmlNode.type = 'html';

    const content = await getHighlightHtml(node.value, node.lang);

    htmlNode.value = content;
  }

  if (!('children' in node)) return;

  node.children.forEach(visit);
}

export function RemarkHighlight() {
  return visit;
}

getHighlightHtml implementation is as follows

import hljs from 'highlight.js/lib/common';

hljs.registerAliases(['vue', 'svelte'], { languageName: 'xml' });

hljs.registerLanguage('astro', () => ({
  contains: [
    {
      begin: '---',
      end: '---',
      ...hljs.getLanguage('typescript'),
    },
  ],
}));

const format = (code: string) => `<pre><code class="hljs">${code}</code></pre>`;

export async function getHighlightHtml(code: string, lang?: string | null) {
  if (!lang) {
    return format(hljs.highlightAuto(code).value);
  }

  if (!hljs.getLanguage(lang)) {
    console.warn(`\x1b[33m[Highlight.js] Couldn't find language: ${lang}\x1b[0m`);
    lang = 'plaintext';
  }

  return format(hljs.highlight(code, { language: lang }).value);
}

After doing this, we can use our Remark plugin on our astro.config.mjs by disabling Astro’s default syntax highlighting mechanism and passing our plugin to markdown.remarkPlugins.

import { defineConfig } from 'astro/config';
import vue from "@astrojs/vue";
import svelte from "@astrojs/svelte";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import { RemarkHighlight } from './plugins/remark/highlight';


export default defineConfig({
  integrations: [vue(), svelte(), react(), tailwind({ applyBaseStyles: false })],
  markdown: {
    syntaxHighlight: false,
    remarkPlugins: [RemarkHighlight]
  },
});

Vite

Remember that SCSS file used for toggling between Atom One light and dark? Now, we have to import this file everywhere there is any hljs generated code, or else all the snippets will be uncolored and ugly.

So, there’s one perfect valid solution for that, which is importing the SCSS file on the top level of the base Layout component thus adding those styles to every page on the blog. But, do we really need those styles everywhere? Of course no, only the pages that have any code snippet written in them have to import that file.

Fortunately, Astro is built on top of Vite, which has a pretty good plugin API that extends Rollup’s. So, we can transform our code to only import the SCSS file from the virtual modules generated from .md and .mdx files

import type { Plugin } from 'vite';

const r = String.raw;

export function ViteMdStyles(): Plugin {
  function newlineJoin(...lines: string[]) {
    return lines.join('\n');
  }

  function hasCodeSamples(id: string, code: string) {
    return (
      id.endsWith('.md') && code.includes(r`<pre><code class=\"hljs\">`)
    ) || (
      id.endsWith('.mdx') && code.includes(r`class: "hljs"`)
    );
  }

  return {
    name: 'local:md-styles',
    transform(code, id) {
      if (!/\.mdx?$/.test(id)) return;

      if (hasCodeSamples(id, code)) {
        code = newlineJoin(
          `import '/src/styles/highlight.scss';`,
          code
        );
      }

      return code;
    }
  }
}

Vercel

Having everything done, I needed to deploy it somewhere. So I picked Vercel as it’s genuinely easy to deploy there and they have a nice free tier, which is perfect for this tiny little blog. To set it up I just ran pnpm astro add vercel and when I want to deploy, it is just a simple vercel deploy- pure breeze.

Note: I’m not receiving any money from Vercel to promote their service, I’m just pointing out a personal preference. You could host your app on any other service, such as Amplify, DigitalOcean, Firebase, Fly.io, Linode, Netlify or any other provider you like.

Wrapping it up

In this article, I showed you without many details how I get this very blog to be working. Originally, I was thinking about diving deep into the source code and explain it piece by piece, but them realized this article would be just ridiculously enormous and boring. So I decided to summarize the main technological decisions and dive deep on the source code only for the most important features. If you wanna see a deep dive into some other parts of the code, let me know somehow.

Finally, thank you for reading this very first article in this blog, hope that its content was useful to you in some way or even helped you building your own application or taking your own tech decisions somewhere.