---
title: "Refreshing the blog — again"
date: 2026-04-15
slug: refreshing-the-blog
authors:
- lunik
description: How and why I migrated my personal website from MkDocs + raw HTML to a Docusaurus + Vite monorepo
related_posts:
  - init
tags:
- blog
- website
- docusaurus
- vite
- npm
- nodejs
- monorepo
- gitlab-ci
- devops
- sysops
- it
---

<!--
# CHANGELOG

-->

![cover](/blog/img/posts/2026-04-15-refreshing-the-blog/cover.png)

A while ago I wrote [a blog post about how I created this blog][init-post]. Back then it was [Pelican][pelican-website], a [Python][python-website] static site generator. Then I migrated it to [MkDocs][mkdocs-website] — also [Python][python-website], a bit more modern, better support for my bilingual setup. Now here we are again. Third time.

This is the story of why I did it and how it went.

<!-- truncate -->

:::info
If you are reading this on the blog, it means the migration is done. Congratulations, you survived the downtime.
:::

## The old setup

Let me describe what I had before so you understand why I wanted to change it.

### The portfolio

A raw [HTML][html-website]/[CSS][css-website]/[JavaScript][javascript-website] website. No build step. No bundler. Just files on a disk that I uploaded to [S3][aws-s3-website].

It worked. But it was painful to maintain. All the [CSS][css-website] was in one 600-line file mixing colors, layout and components. There was no clear separation between data and presentation. The [JavaScript][javascript-website] was procedural, with `eval()` calls to map string property names to DOM attributes. Dependencies were pulled from CDNs with hardcoded version numbers in `<script>` tags.

Every time I needed to touch something I had to rediscover how it was put together.

### The blog

[MkDocs][mkdocs-website] is a perfectly fine static site generator. I have nothing bad to say about it. But it was a separate project, with its own [Python][python-website] environment, its own [GitLab CI][gitlab-ci-website] pipeline, and its own S3 deployment. Two projects, two pipelines, two deployment processes.

And [MkDocs][mkdocs-website] is not really designed for a blog. I was using the [MkDocs Material Blog plugin][mkdocs-material-website] which is great but adds a layer on top of something that wasn't built for that use case from the start.

### The CI situation

Two `.gitlab-ci.yml` files. Two separate cache strategies. Two separate deploy jobs. When I wanted to make a change that touched both the portfolio and the blog — like updating the footer links — I had to coordinate two separate pipelines and two separate deployments. It was annoying.

## Why Docusaurus and Vite

### Docusaurus

[Docusaurus][docusaurus-website] is a [React][react-website]-based static site generator made by [Meta][meta-website], primarily designed for documentation. But it has a blog mode that is quite good. It supports:

- Bilingual content out of the box with its i18n system
- Reading time estimation
- Author profiles
- RSS/Atom feeds
- Tag pages

It's built on top of [Node.js][nodejs-website] and [npm][npm-website], which means I'm back in the ecosystem I hate but at least it's the same ecosystem as the portfolio build tooling. Consistency has a value.

I considered [Astro][astro-website] and [11ty][11ty-website]. [Astro][astro-website] is interesting but it felt like more configuration for the same result. [11ty][11ty-website] is elegant in its simplicity but the [Docusaurus][docusaurus-website] i18n support is hard to beat without writing it yourself.

### Vite

[Vite][vite-website] is the obvious choice for the portfolio. It's fast, it handles multi-page apps well, and it has first-class support for [ES modules][esmodules-mdn]. The migration from raw [HTML][html-website] + procedural [JavaScript][javascript-website] to a proper [Vite][vite-website] multi-page app gave me the structure I needed without forcing me to adopt a component framework.

## The monorepo structure

The decision to merge the two projects into one repository was the most important one. Everything else follows from it.

```
my_website_v2/
├── package.json          # npm workspaces root
├── packages/
│   ├── website/          # Vite portfolio
│   └── blog/             # Docusaurus blog
└── dist/                 # assembled output
```

The root `package.json` declares both packages as [npm workspaces][npm-workspaces-docs] and orchestrates the build:

```json
{
  "scripts": {
    "build": "npm run build:website && npm run build:blog && npm run assemble",
    "assemble": "mkdir -p dist && cp -r packages/website/dist/. dist/ && mkdir -p dist/blog && cp -r packages/blog/build/. dist/blog/"
  }
}
```

The portfolio builds into `dist/`, the blog builds into `dist/blog/`. The URL structure is preserved: `/` for the portfolio, `/blog/` for the blog. One `rclone sync` command on the CI to push everything to S3.

## Migrating the portfolio

### CSS refactor

The first thing I did was extract the [Gruvbox][gruvbox-website] color palette into [CSS custom properties][css-custom-properties-mdn]:

```css
:root {
  --gb-bg:      #1d2021;
  --gb-bg1:     #282828;
  --gb-fg:      #ebdbb2;
  --gb-cyan:    #689d6a;
  --gb-yellow:  #d79921;
  /* ... */
}
```

Then replaced every hardcoded color literal in the component stylesheets with these variables. No visual change, but now the theme is in one place.

### JavaScript modules

The main challenge was the `eval()` pattern. The original code used string property names to set dynamic CSS variables:

```javascript
// Before
config.forEach(function(item) {
  Object.keys(item).forEach(function(key) {
    eval("element." + key + " = item[key]");
  });
});
```

I replaced this with an explicit property map:

```javascript
// After
const PROPERTY_MAP = {
  textContent: (el, v) => { el.textContent = v },
  href: (el, v) => { el.href = v },
  style: (el, v) => { el.setAttribute('style', v) },
};
```

Not as clever, but it works without `eval()` and it's readable.

### UAParser.js

The portfolio uses [UAParser.js][ua-parser-website] to display the visitor's browser and OS on the terminal homepage. Before it was loaded from a CDN:

```html
<script src="https://cdn.jsdelivr.net/npm/ua-parser-js@1/src/ua-parser.min.js"></script>
```

Now it's a proper [npm][npm-website] dependency imported as an ES module:

```javascript
import { UAParser } from 'ua-parser-js'
```

## Migrating the blog content

### The migration script

I wrote a one-shot [Node.js][nodejs-website] script to migrate all the [MkDocs][mkdocs-website] posts to [Docusaurus][docusaurus-website] format. The main things it had to handle:

**Frontmatter**: [MkDocs][mkdocs-website] Material uses `categories` and `tags` as separate fields, [Docusaurus][docusaurus-website] only has `tags`. The script merges them and deduplicates. It also removes the `readtime` field since [Docusaurus][docusaurus-website] computes reading time automatically.

**Admonitions**: [MkDocs][mkdocs-website] uses `!!! warning "Title"` syntax, [Docusaurus][docusaurus-website] uses `:::warning[Title]`. A regex handles the conversion:

```javascript
content = content.replace(
  /^!!!\s+(\w+)(?:\s+"([^"]*)")?\n\n?((?:(?:    |\t)[^\n]*\n?)+)/gm,
  (_, type, title, body) => {
    const dedented = body.replace(/^(    |\t)/gm, '')
    const header = title ? `:::${type}[${title}]` : `:::${type}`
    return `${header}\n${dedented.trim()}\n:::\n`
  }
)
```

**Directory structure**: [MkDocs][mkdocs-website] posts are in `docs/en/posts/slug/index.md` with no date in the path. [Docusaurus][docusaurus-website] wants `blog/YYYY-MM-DD-slug/index.md`. The script reads the `date:` field from frontmatter and prefixes the directory name.

**Truncation markers**: [MkDocs][mkdocs-website] uses `<!-- more -->` for the post excerpt cutoff. [Docusaurus][docusaurus-website] uses `<!-- truncate -->`. A simple find and replace.

### Bilingual content

The EN posts go in `packages/blog/blog/`. The FR posts go in `packages/blog/i18n/fr/docusaurus-plugin-content-blog/`. Images are stored with the EN post and FR posts reference them with the same relative path — [Docusaurus][docusaurus-website] resolves them correctly at build time.

## The fun problems

### Node 25 and the localStorage error

The [Docusaurus][docusaurus-website] SSG build crashed on [Node.js][nodejs-website] 25 with:

```
Cannot initialize local storage without a --localstorage-file path.
```

[Node.js][nodejs-website] 25 ships the [Web Storage API][webstorage-mdn] natively, but unlike browsers, it requires an explicit file path to persist data. [Docusaurus][docusaurus-website] uses `localStorage` internally during SSG for theme persistence and had no idea this API now needed initialization.

The fix is a [Node.js][nodejs-website] CLI flag:

```shell
NODE_OPTIONS='--localstorage-file=/tmp/docusaurus-ls' docusaurus build
```

It's in the `package.json` build script and in the [GitLab CI][gitlab-ci-website] `variables` block.

### Non-breaking spaces

HTML collapses consecutive regular spaces into one. Everyone knows that. Fewer people remember it when writing strings that will be injected as `innerHTML`.

The terminal homepage has two commands whose output relies on column alignment: `locate` (links padded to a common width before the `#` comment) and `ls -l /bin` (file sizes and dates right-aligned). The original source used U+00A0 non-breaking spaces for that padding, not regular spaces. In HTML, `&nbsp;` or its Unicode equivalent is the only whitespace character that does not collapse.

During the migration, every text editor, diff tool, and copy-paste involved in moving those strings treated U+00A0 as an ordinary space. They all came out as U+0020. The columns disappeared silently — no build error, no test failure, just a cosmetic regression that you only notice when you look at the page.

The fix is surgical: find the original source, extract the raw bytes, restore them. A one-liner in Python to grep the original file and count `\xa0` occurrences confirmed it, then a direct byte-level substitution put them back.

Lesson: if column alignment in HTML matters, use a `<pre>` or `white-space: pre` and keep the string in a separate file where a linter cannot touch it.

### webpack 5.106 and webpackbar

[Docusaurus][docusaurus-website] 3 uses [webpack][webpack-website] 5 internally. [webpackbar][webpackbar-website] — the progress bar plugin — was updated to 6.0.1, which passed options (`name`, `color`, `reporters`) to [webpack][webpack-website]'s `ProgressPlugin`. But [webpack][webpack-website] 5.100+ removed those options from `ProgressPlugin`.

The result: the blog build crashed at startup with an obscure plugin options error.

The fix was to pin [webpack][webpack-website] to `5.95.0` as a direct `devDependency` in `packages/blog/package.json`. npm workspace resolution picks it up and uses it instead of resolving the latest 5.x version.

```json
{
  "devDependencies": {
    "webpack": "5.95.0"
  }
}
```

Not great, but it works until [Docusaurus][docusaurus-website] ships a fix upstream.

## The CI pipeline

The new `.gitlab-ci.yml` is three stages: `install`, `build`, `deploy`.

```
install → build → pages (develop branch)
                → publish (master branch)
```

The `pages` job copies `dist/` to `public/` for [GitLab Pages][gitlab-pages-website] on the `develop` branch. The `publish` job runs `rclone sync` to push `dist/` to S3 on the `master` branch.

Before this, I had two pipelines with two separate S3 deploy jobs, one of which used an exclusion pattern to avoid overwriting the blog that was deployed from the other pipeline. Now it's a single `rclone sync` with no exclusions:

```shell
rclone sync --checksum --delete-during --quiet \
  --s3-provider AWS \
  --s3-access-key-id "$AWS_ACCESS_KEY_ID" \
  --s3-secret-access-key "$AWS_SECRET_ACCESS_KEY" \
  --s3-region eu-west-3 \
  ./dist :s3:website-lunik.tiwabbit.fr
```

`--delete-during` removes files from the bucket that no longer exist in `dist/` as the sync runs. `--checksum` uses checksums rather than modification times to detect changes — more reliable for files that get reassembled from the same source. Clean.

## Conclusion

Three iterations of this blog. [Pelican][pelican-website], then [MkDocs][mkdocs-website], now [Docusaurus][docusaurus-website]. Each time the tooling gets better and the maintenance overhead gets lower.

The monorepo was the right call. Having the portfolio and the blog in the same repository, with the same build pipeline, is genuinely less annoying than maintaining two separate projects. I should have done it earlier.

The source is at [gitlab.com/Lunik/my_website_v2][repo-gitlab].

<!-- links -->

[init-post]: /blog/init
[pelican-website]: https://getpelican.com
[mkdocs-website]: https://www.mkdocs.org
[mkdocs-material-website]: https://squidfunk.github.io/mkdocs-material/plugins/blog/
[docusaurus-website]: https://docusaurus.io
[vite-website]: https://vitejs.dev
[react-website]: https://react.dev
[meta-website]: https://opensource.fb.com
[astro-website]: https://astro.build
[11ty-website]: https://www.11ty.dev
[nodejs-website]: https://nodejs.org
[npm-website]: https://www.npmjs.com
[npm-workspaces-docs]: https://docs.npmjs.com/cli/using-npm/workspaces
[python-website]: https://www.python.org
[html-website]: https://developer.mozilla.org/en-US/docs/Web/HTML
[css-website]: https://developer.mozilla.org/en-US/docs/Web/CSS
[javascript-website]: https://developer.mozilla.org/en-US/docs/Web/JavaScript
[esmodules-mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
[css-custom-properties-mdn]: https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties
[webstorage-mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
[ua-parser-website]: https://github.com/faisalman/ua-parser-js
[gruvbox-website]: https://github.com/morhetz/gruvbox
[webpack-website]: https://webpack.js.org
[webpackbar-website]: https://github.com/unjs/webpackbar
[aws-s3-website]: https://aws.amazon.com/s3/
[gitlab-ci-website]: https://docs.gitlab.com/ee/ci/
[gitlab-pages-website]: https://docs.gitlab.com/ee/user/project/pages/
[repo-gitlab]: https://gitlab.com/Lunik/my_website_v2
