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.
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) { ... }
Page Navigation & Transition
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.