Featured image of post How I made this blog site

How I made this blog site

title

Source code can be found in blog repo.

Basic Setup

For this blog site, I used this hugo theme stack by Jimmy Cai. Plus, it has great documentation on how to customize the theme.

Theme Installation

I installed the theme via git submodule:

git submodule add https://github.com/CaiJimmy/hugo-theme-stack.git themes/hugo-theme-stack

and configured hugo to use the theme via hugo.yaml:

theme: hugo-theme-stack

Splitting Config

You don’t have to split the config. But I figured, since the default config is too long, why not split it up. (read more at Hugo docs)

config
 |- _default
     |- config.yaml
     |- menu.yaml
     |- params.yaml

Social Icons

Social Icons can be configured at menu.yaml. And for icons not included in the theme, go to tablericons and save the svg at:

assets
 |- icons
     |- <new_icon>.svg

Avatar & Favicon

To change the avatar, place your avatar image at:

assets
 |- img
     |- seyLu.png

Then configure at params.yaml:

sidebar:
  subtitle: pachi pachi pachi pachi
  avatar:
    enabled: true
    local: true
    src: img/seyLu.png

I am too lazy, so I used the same image for the favicon. To generate favicons that work on most devices, go to realfavicongenerator, and place the generated files at:

static
 |- ...

then configure at params.yaml:

favicon: /favicon.ico

Add in a custom.html:

layouts
 |- partials
     |- head
         |- custom.html

and throw in the generated html stuff there:

<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#ffc40d" />
<meta name="theme-color" content="#ff0000" />

Github Comments

For comments, I went with giscus, which uses Github discussions to store comments.

Github Discussions is disabled by default, so make sure to enable it first in your repo settings. Github Discussions

You can then find the giscus config on params.yaml:

comments:
  enabled: true
  provider: giscus

  giscus:
    repo: seyLu/blog
    repoID: <repo_id>
    category: General
    categoryID: <category_id>
    mapping: pathName
    reactionsEnabled: 1
    emitMetadata: 0
    lightTheme: light
    darkTheme: transparent_dark

And to get the repoID and categoryID, I used the Github CLI with this GraphQL query:

gh api graphql -f query='
{
  repository(owner: "seyLu", name: "blog") {
    id # RepositoryID
    name
    discussionCategories(first: 10) {
      nodes {
        id # CategoryID
        name
      }
    }
  }
}'

Spicing Things up

After a bit of configuration, now it’s time to make the blog mine.

Custom Font

I really wanted to try out the new Geist font by Vercel. It was just released a couple weeks ago as of me writing this blog, and I’ve heard it’s very similar to Inter font but better.

For the code font, I went with JetBrains Mono – I mean there’s really no competition.

To customize the font, download the fonts, then put them in:

static
 |- fonts
     |- geist
         |- GeistVariableVF.ttf
         |- GeistVariableVF.woff2
     |- jetbrains_mono
         |- JetBrainsMono.ttf

I went with variable fonts because it is easier to setup and maintain. But since it’s relatively new, it might have some compatibility issues with older browsers – but this is my blog, so who cares about that.

To use the fonts, first create a custom scss file:

assets
 |- scss
     |- custom.scss

then, add this mixin to simplify loading the fonts:

@mixin font($font-family, $font-file) {
  @font-face {
    font-family: $font-family;
    src: local(''), url($font-file + '.otf') format('otf'),
      url($font-file + '.woff2') format('woff2'),
      url($font-file + '.ttf') format('truetype');
    font-dispay: swap;
  }
}

@include font('Geist', '/fonts/geist/GeistVariableVF');
@include font('JetBrains Mono', '/fonts/jetbrains_mono/JetBrainsMono');

then copy the the part where the font is loaded in the theme and configure the custom font to be loaded first:

:root {
  --sys-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Droid Sans',
    'Helvetica Neue';
  --zh-font-family: 'PingFang SC', 'Hiragino Sans GB', 'Droid Sans Fallback',
    'Microsoft YaHei';

  --base-font-family: 'Geist', 'Lato', var(--sys-font-family),
    var(--zh-font-family), sans-serif;
  --code-font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, 'Courier New',
    var(--zh-font-family), monospace;
}

Vercel Theme

Since I’ve used Geist font, might as well fully commit and follow the color scheme of Vercel.

Style Tweaks

And to match the Vercel Theme, I replaced the box-shadow with outline and reduced the border-radius. I also went and changed the styles of TOC. (view source code)

As for the horizontal scroll bar in code blocks, I found the default to be awfully close to the code so I went and moved it down a bit:

.article-content .chroma .lntable > tbody > tr > td:last-child > pre {
  padding-bottom: calc(var(--card-padding) / 2);
}

.highlight {
  padding-bottom: calc(var(--card-padding) / 2) !important;
}

Another subtle change I’ve made, is the grayscale effect on images and videos on darkmode:

@function native-grayscale($grayscale) {
  @return #{'grayscale(#{$grayscale})'};
}

@function native-opacity($opacity) {
  @return #{'opacity(#{$opacity})'};
}

[data-scheme='dark'] {
  img,
  video {
    --image-grayscale: 20%;
    --image-opacity: 90%;
    filter: native-grayscale(var(--image-grayscale))
      native-opacity(var(--image-opacity));
  }
}

This was more work than necessary, simply for the fact that grayscale & opacity in scss is different from css. Thus, requiring this workaround to tell scss to use the native css functions instead.

With the default theme being dark mode:

colorScheme:
  toggle: true
  default: dark

I also had to configure giscus to default to dark mode. To do this, just copy giscus.html from theme to the root dir:

layouts
 |- partials
     |- comments
         |- providers
             |- giscus.html

and change data-theme to default to dark mode:

{{- with .Site.Params.comments.giscus -}}
  <script
    ...
    data-theme="{{- default `dark` .darkTheme -}}"

The SPA Experience

By this point, I’ve already tried HTMX on two other ongoing projects. I’ve had the HTMX experience and genuinely enjoyed using it, so I figured, why not.

Though, I did faced some issues with boosted elements and swapping. I had to use the HTMX extension, idiomorph, and everything somehow worked as expected.

Plop in htmx and idiomorph as a js dep:

static
 |- js
     |- htmx.min.js
     |- idiomorph-ext.min.js

And no need to defer, just plug it in:

<script src="/js/htmx.min.js"></script>
<script src="/js/idiomorph-ext.min.js"></script>

Boosting Should Be Nerfed

The way boosting works is that, normal elements get the superpower to do ajax calls (links, forms, etc.). And it is inherited, meaning if the parent element is boosted, all the children elements also gets boosted.

To take advantage of this behaviour, copy the base layout from theme to root:

layouts
 |- _default
     |- baseof.html

And turning a Hugo-generated static site into a SPA-like experience is as simple as:

<body hx-ext="morph">
  <div
    hx-boost="true"
    hx-swap="morph:innerHTML"

But we’re still not done. We still need to reinitialize components on HTMX swap. First, copy main.ts from theme:

assets
 |- ts
     |- main.ts

then initiliaze the Stack component after HTMX swap:

window.addEventListener('htmx:afterSwap', () => {
    setTimeout(() => {
        console.clear();
        window.scrollTo(0, 0);
        Stack.init();
        window.Stack = Stack;
        window.createElement = createElement;
    }, 0);
});

There’s also the issue with the code block copy button, where it keeps adding more buttons the more HTMX swap happens. So we also have to address that and remove previously initialized copy button:

highlights.forEach((highlight) => {
    highlight.querySelectorAll('button').forEach((button) => {
        button.remove();
    });
});

Fixing The Search Widget

An issue raised by boosting is that it expects other js scripts to be already loaded when needed. The previous implementation, did not need to load the js required for the search widget to work on load, not unless you go to the search page itself.

To fix this, copy the search widget in theme:

layouts
 |- partials
     |- widget
         |- search.html

then copy the loading of scripts from the search page in theme to the search widget in root:

<script>
  window.searchResultTitleTemplate = "{{ T `search.resultTitle` }}"
</script> {{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}}
{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}}
<script
  type="text/javascript"
  src="{{ $searchScript.RelPermalink }}"
  defer
></script>

We also need to reinitalize the search component on HTMX swap. Copy the search.tsx from theme:

assets
 |- ts
     |- search.tsx

You don’t have to do this, but I encapsulated the search component initialization, the same way as main.ts:

let StackSearch = {
    init: () => {
        const searchForm = document.querySelector(
                '.search-form'
            ) as HTMLFormElement,
            searchInput = searchForm.querySelector('input') as HTMLInputElement,
            searchResultList = document.querySelector(
                '.search-result--list'
            ) as HTMLDivElement,
            searchResultTitle = document.querySelector(
                '.search-result--title'
            ) as HTMLHeadingElement;

        new Search({
            form: searchForm,
            input: searchInput,
            list: searchResultList,
            resultTitle: searchResultTitle,
            resultTitleTemplate: window.searchResultTitleTemplate,
        });
    },
};

then used that to reiniatialize the search component:

window.addEventListener('load', () => {
    setTimeout(() => {
        StackSearch.init();
    }, 0);
});

window.addEventListener('htmx:afterSwap', () => {
    setTimeout(() => {
        StackSearch.init();
    }, 0);
});

We want the dynamically created element to be boosted. But on every time that this element is recreated (on page load & HTMX swap), it loses its HTMX properties. So we’ll have to reapply that:

 private async doSearch(keywords: string[]) {
    const startTime = performance.now();
    const results = await this.searchKeywords(keywords);
    this.clear();

    if (results) {
        for (const item of results) {
            this.list.append(Search.render(item));
        }

+       htmx.process(this.list);

...

public static render(item: pageData) {
    return (
+       <article hx-boost="true">

By this point, if you go to the console tab, there will be a lot of errors. The search component expects that you’re already using the search feature – even though, what we want to do is to load the script, that’s it.

To silence these errors, we just need to wrap a couple statements, to execute if the data they’re expecting exists:

private async searchKeywords(keywords: string[]) {
    const rawData = await this.getData();

+   if (rawData) { ... }

...

private async doSearch(keywords: string[]) {
        const startTime = performance.now();

        const results = await this.searchKeywords(keywords);
        this.clear();

+       if (results) { ... }

...

public async getData() {
        if (!this.data) {
            /// Not fetched yet
            const jsonURL = this.form.dataset.json;

+           if (jsonURL) { ... }

Boosting reloads the whole page, which we don’t really want, especially if we add transitions. We don’t want to apply the transition to the whole page, just the parts that are changing.

Thankfully, this is possible in HTMX via hx-indicator attribute. For example, the left sidebar doesn’t really change but the main content and the right sidebar does change.

When HTMX is doing its magic, it adds classes depending on the operation it’s currently doing. And we can take advantage of this and apply css transitions:

.htmx-request #right,
.htmx-request #main {
  opacity: 0;
  transition: opacity 200ms ease-in;
}

.htmx-swapping #right,
.htmx-swapping #main {
  opacity: 0;
}

.htmx-settling #right,
.htmx-settling #main {
  opacity: 1;
  transition: opacity 200ms ease-in;
}

As you’ve noticed, we’re targetting the right sidebar and the main content.

Now we just need to add the HTMX attributes for the main content:

layouts
 |- _default
     |- baseof.html

<main
  hx-swap="morph:innerHTML swap:10ms settle:200ms"
  hx-indicator="#main"
  id="main"

We’re adding a bit of delay on swap events, just enough time for the page animtation transition to kick in.

Similarly, do the same thing to the right sidebar (copied from theme):

layouts
 |- partials
     |- sidebar
         |- right.html

<aside
  hx-swap="morph:innerHTML swap:10ms settle:200ms"
  hx-indicator="#right"
  id="right"

There’s also the left sidebar, which contains the menu used for the main navigation. If we leave it as is, every link clicked, the whole page will reload. So we also have to address that:

layouts
 |- partials
     |- sidebar
         |- left.html

<ol
  hx-swap="morph:innerHTML swap:10ms settle:200ms"
  hx-indicator="#main"
  ...
>
  ...
  <li id="dark-mode-toggle" hx-disable>...</li>
</ol>

And for elements where it doesn’t make sense to be boosted, like the dark mode toggle element, we just add an hx-disable attribute.

Obligatory Progress Bar

This is a classic for a SPA-like experience. We don’t need to overcomplicate things, just something simple and that works.

First, add the progress bar element:

layouts
 |- _default
     |- baseof.html

<body hx-ext="morph">
  <div id="progress-bar"></div>

Add some styles:

assets
 |- scss
     |- custom.scss

#progress-bar {
  background-color: #3498db;
  width: 0;
  height: 0.25rem;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  transition: width 0.3s ease-out;
}

And some scripts to expand or increase the width of the progress bar:

assets
 |- ts
     |- main.ts

document.body.addEventListener('htmx:configRequest', function (event) {
    // Show the progress bar before the request is made
    document.getElementById('progress-bar').style.width = '0%';
});

document.body.addEventListener('htmx:afterSwap', function (event) {
    // Hide the progress bar after the request is complete
    document.getElementById('progress-bar').style.width = '100%';

    setTimeout(function () {
        document.getElementById('progress-bar').style.opacity = '0';
    }, 300);
});

Hmmm. I think that’s about it. I mean, if I forgot something, feel free to raise an issue, start a discussion, or comment down below. GTG I need to sleep.

By MJ Sabit
Built with Hugo
Theme Stack designed by Jimmy