Compare commits

...

11 Commits

Author SHA1 Message Date
0a565a9a5e Publish Blog Post: Building a High Performance Blog from Scratch with Bun
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
2026-04-28 10:08:18 -07:00
b8027deea4 Formmating Changes 2026-04-28 10:03:20 -07:00
afdfdd76ab Fix external link icon not rendering (and style adjustments) 2026-04-21 17:18:46 -07:00
149489c8b8 Add health checks
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
2026-04-21 16:37:10 -07:00
38336301e9 Fix bug with external links and simplify analytics
All checks were successful
Build and Push Docker Image / build (push) Successful in 47s
2026-04-21 15:28:10 -07:00
1ea315543b Adjust routers, add redirect on www. subdomain
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
2026-04-16 04:15:09 -07:00
b8129f6aa5 Add analytics 2026-04-16 04:05:21 -07:00
af0434cffb First post boilerplate
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
2026-04-16 02:50:05 -07:00
6de2346ee5 Remove test posts
Some checks failed
Build and Push Docker Image / build (push) Failing after 1m1s
2026-04-16 02:45:36 -07:00
c29244d2b6 Add Bun plugin to stop hmr routes in development from crashing 2026-04-16 01:52:27 -07:00
f6275e4f58 Fix date and read time on Blog Posts 2026-04-16 01:06:26 -07:00
21 changed files with 467 additions and 518 deletions

View File

@@ -28,10 +28,29 @@ job "blog" {
port = "http"
tags = [
# ----- router for the naked domain -----
"traefik.enable=true",
"traefik.http.routers.domainredirect.tls=true",
"traefik.http.routers.domainredirect.entrypoints=websecure",
"traefik.http.routers.domainredirect.rule=Host(`${local.HOST}`)",
"traefik.http.routers.blog.rule=Host(`${local.HOST}`)",
"traefik.http.routers.blog.entrypoints=websecure",
"traefik.http.routers.blog.tls=true",
"traefik.http.routers.blog.service=blog-svc",
# ----- router for the www domain (new) -----
"traefik.http.routers.blog-www.rule=Host(`www.${local.HOST}`)",
"traefik.http.routers.blog-www.entrypoints=websecure",
"traefik.http.routers.blog-www.tls=true",
"traefik.http.routers.blog-www.middlewares=redirect-www-to-root",
# ----- middleware that does the 301 redirect -----
"traefik.http.middlewares.redirect-www-to-root.redirectregex.regex=^https?://www\\.${local.HOST}(.*)",
"traefik.http.middlewares.redirect-www-to-root.redirectregex.replacement=https://${local.HOST}$${1}",
"traefik.http.middlewares.redirect-www-to-root.redirectregex.permanent=true",
# ----- service and health check -----
"traefik.http.services.blog-svc.loadbalancer.healthcheck.path=/healthz",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.interval=5s",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.timeout=2s",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.hostname=${local.HOST}",
]
}

View File

@@ -102,9 +102,21 @@
"Name": "blog",
"Tags": [
"traefik.enable=true",
"traefik.http.routers.domainredirect.tls=true",
"traefik.http.routers.domainredirect.entrypoints=websecure",
"traefik.http.routers.domainredirect.rule=Host(`cbraaten.dev`)"
"traefik.http.routers.blog.rule=Host(`cbraaten.dev`)",
"traefik.http.routers.blog.entrypoints=websecure",
"traefik.http.routers.blog.tls=true",
"traefik.http.routers.blog.service=blog-svc",
"traefik.http.routers.blog-www.rule=Host(`www.cbraaten.dev`)",
"traefik.http.routers.blog-www.entrypoints=websecure",
"traefik.http.routers.blog-www.tls=true",
"traefik.http.routers.blog-www.middlewares=redirect-www-to-root",
"traefik.http.middlewares.redirect-www-to-root.redirectregex.regex=^https?://www\\.cbraaten.dev(.*)",
"traefik.http.middlewares.redirect-www-to-root.redirectregex.replacement=https://cbraaten.dev${1}",
"traefik.http.middlewares.redirect-www-to-root.redirectregex.permanent=true",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.path=/healthz",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.interval=5s",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.timeout=2s",
"traefik.http.services.blog-svc.loadbalancer.healthcheck.hostname=cbraaten.dev"
],
"CanaryTags": null,
"EnableTagOverride": false,

View File

@@ -0,0 +1,30 @@
import type { BunPlugin } from 'bun';
/**
* Plugin to prevent Bun's bundler from analyzing certain URL patterns.
* Marks URLs that should remain as runtime URLs as external.
*/
const runtimeExternalUrlsPlugin: BunPlugin = {
name: 'runtime-external-urls',
setup(build) {
// Intercept resolution of paths that start with /
build.onResolve({ filter: /^\// }, args => {
// Mark specific runtime URLs as external to prevent bundler analysis
const externalPatterns = [
'/profile-picture.webp',
// Add other runtime URLs here as needed
];
if (externalPatterns.some(pattern => args.path.includes(pattern))) {
return {
path: args.path,
external: true, // This tells the bundler NOT to bundle this
};
}
return undefined; // Let normal resolution continue
});
},
};
export default runtimeExternalUrlsPlugin;

View File

@@ -1,4 +1,9 @@
preload = ["./bun_plugins/onStartup-post-importer.ts"]
preload = [
"./bun_plugins/onStartup-post-importer.ts"
]
[serve.static]
plugins = ["./bun_plugins/onImport-markdown-loader.tsx"]
plugins = [
"./bun_plugins/onImport-markdown-loader.tsx",
"./bun_plugins/onRuntime-external-urls.ts"
]

View File

@@ -1,93 +0,0 @@
---
title: 2024 Year in Review
date: 2024-12-28
tags: [Career, Productivity]
excerpt: Reflecting on 2024 - the projects I built, lessons I learned, and goals for 2025. A year of growth, challenges, and accomplishments.
draft: false
---
# 2024 Year in Review!
As 2024 comes to a close, I wanted to take some time to reflect on the year. It's been a year of significant growth, both professionally and personally.
## Professional Highlights
### Projects
This year, I worked on several exciting projects:
**E-commerce Platform**: Led the frontend architecture for a new e-commerce platform serving 100k+ daily users. We achieved a 40% improvement in page load times through careful optimization.
**Design System**: Built a comprehensive design system from scratch, complete with documentation and Storybook. This is now used across 5 different products.
**Open Source**: Made my first significant open source contributions! Contributed to several Bun-related projects and created a few small utilities that others found useful.
### Skills Developed
- **Performance Optimization**: Learned a ton about web performance, Core Web Vitals, and how to actually make sites fast
- **System Design**: Started thinking more about architecture and scalability
- **TypeScript**: Became much more proficient with advanced TypeScript patterns
- **Testing**: Finally built a solid testing practice
## Personal Growth
### Writing
Started this blog! Writing has helped me:
- Clarify my thinking
- Learn more deeply
- Connect with others in the community
### Work-Life Balance
Made a conscious effort to improve work-life balance:
- Implemented a hard stop at 6 PM
- Started exercising regularly (3x per week)
- Picked up reading again (finished 12 books!)
## Challenges
Not everything was smooth sailing:
**Burnout**: Hit a rough patch in Q2 where I was working too much. Learned the importance of rest and boundaries.
**Imposter Syndrome**: Still struggles with this occasionally, but getting better at recognizing it and pushing through.
**Saying No**: Learned that saying no to some opportunities is necessary to say yes to the right ones.
## Lessons Learned
1. **Quality > Quantity**: Better to do fewer things well than many things poorly
2. **Ask for Help**: Nobody knows everything, and asking for help is a strength
3. **Document Everything**: Future you will thank present you
4. **Take Breaks**: Rest isn't laziness - it's necessary for sustained performance
5. **Community Matters**: Connecting with other developers has been invaluable
## Goals for 2025
Looking ahead to 2025:
### Professional
- Contribute to a major open source project
- Speak at a local meetup or conference
- Learn Rust (for real this time)
- Build and launch a side project
### Personal
- Write 24 blog posts (2 per month)
- Read 20 books
- Maintain exercise routine
- Learn to cook 10 new recipes
### Learning
- Deep dive into system design
- Master web performance
- Learn more about databases
- Get better at writing
## Thank You
Thanks to everyone who supported me this year - colleagues, friends, family, and the online tech community. Looking forward to 2025!
What were your highlights from 2024? What are you looking forward to in 2025?

View File

@@ -1,108 +0,0 @@
---
title: 5 TypeScript Tips I Wish I Knew Earlier
date: 2025-09-18
readingTime: 10 minutes
tags: [TypeScript, JavaScript, Productivity]
excerpt: Five practical TypeScript tips that will make your code more type-safe and your development experience smoother. From utility types to const assertions.
draft: false
---
# 5 TypeScript Tips I Wish I Knew Earlier
TypeScript is an amazing tool, but it takes time to learn all its features. Here are five tips that significantly improved my TypeScript development experience.
## 1. Use `satisfies` for Type Checking
The `satisfies` keyword (added in TS 4.9) lets you validate that a value matches a type without widening its type:
```typescript
type RGB = { r: number; g: number; b: number };
// Before: Type is widened to RGB
const color: RGB = { r: 255, g: 0, b: 0 };
// After: Type is preserved as literal values
const color = { r: 255, g: 0, b: 0 } satisfies RGB;
```
This is especially useful when you want both type safety and precise types.
## 2. Const Assertions
Adding `as const` to an object or array makes all properties readonly and infers literal types:
```typescript
// Regular array: type is string[]
const fruits = ['apple', 'banana'];
// With const assertion: type is readonly ['apple', 'banana']
const fruits = ['apple', 'banana'] as const;
```
This is perfect for configuration objects and enum-like values.
## 3. Utility Type: `Awaited<T>`
Need to get the resolved type of a Promise? Use `Awaited`:
```typescript
async function fetchUser() {
return { id: 1, name: 'Alice' };
}
// Gets the return type without the Promise wrapper
type User = Awaited<ReturnType<typeof fetchUser>>;
// User is { id: number; name: string }
```
## 4. Template Literal Types
Create types based on string patterns:
```typescript
type EventName = 'click' | 'focus' | 'blur';
type EventHandler = `on${Capitalize<EventName>}`;
// Result: 'onClick' | 'onFocus' | 'onBlur'
```
This is incredibly powerful for creating type-safe APIs.
## 5. Discriminated Unions
Use a common property to narrow union types:
```typescript
type Success = { status: 'success'; data: string };
type Error = { status: 'error'; error: string };
type Result = Success | Error;
function handle(result: Result) {
if (result.status === 'success') {
// TypeScript knows this is Success
console.log(result.data);
} else {
// TypeScript knows this is Error
console.log(result.error);
}
}
```
This pattern eliminates entire classes of runtime errors.
## Bonus: Non-Null Assertion Operator
While I generally avoid the `!` operator, it's useful when you know better than TypeScript:
```typescript
// When you're certain the element exists
const button = document.getElementById('submit')!;
button.click();
```
Use sparingly and only when you're absolutely sure.
## Wrapping Up
These tips have made my TypeScript code more robust and easier to maintain. The key is to leverage TypeScript's type system to catch errors at compile time rather than runtime.
What are your favorite TypeScript features? Let me know!

View File

@@ -1,82 +0,0 @@
---
title: Modern Development Workflow
date: 2025-10-21
tags: [Web Development, TypeScript, Productivity]
excerpt: Explore modern development workflows that boost productivity and code quality. Learn about hot module replacement, automated testing, and modern tooling.
draft: false
---
# Modern Development Workflow
In today's fast-paced development environment, having an efficient workflow is crucial for maintaining productivity and code quality.
## Hot Module Replacement
Hot Module Replacement (HMR) revolutionizes the development experience:
```typescript
// Vite configuration with HMR
export default {
server: {
hmr: {
overlay: true
}
},
plugins: [
reactRefresh()
]
}
// During development, changes appear instantly
const Component = () => {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
};
```
## Tool Chain Essentials
A modern tool chain should include:
- **Fast builds**: Vite or Bun for lightning-fast compilation
- **Type safety**: TypeScript for catching errors at compile time
- **Code formatting**: Prettier for consistent code style
- **Linting**: ESLint for maintaining code quality
## Automated Testing
Integrate testing into your workflow:
```typescript
// Component testing with React Testing Library
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('increment works correctly', () => {
render(<Counter />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
```
## Best Practices
- **Git hooks**: Pre-commit hooks for code quality checks
- **CI/CD**: Automated testing and deployment
- **Documentation**: Keep README files up to date
- **Code reviews**: Peer reviews for knowledge sharing
## Conclusion
A modern development workflow combines the right tools with good practices. Invest time in setting up your environment properly, and it will pay dividends in productivity throughout your project.
---
*This post demonstrates the power of modern development tools and workflows.*

View File

@@ -1,101 +0,0 @@
---
title: Building a Modern Blog with Bun and TypeScript
date: 2025-10-20
tags: [Web Development, TypeScript, JavaScript]
excerpt: A deep dive into building a performant, modern blog using Bun's runtime, TypeScript, and server-side rendering. Learn about the architecture decisions and tradeoffs.
draft: false
---
# Building a Modern Blog with Bun and TypeScript
## Why Bun?
Bun is an all-in-one JavaScript runtime that's significantly faster than Node.js for many operations. It includes:
- A blazing-fast JavaScript/TypeScript runtime
- Built-in bundler
- Native TypeScript support
- Package manager
- Test runner
For this blog, the combination of native TypeScript support and the built-in bundler made Bun an obvious choice.
## Architecture Overview
The blog uses a hybrid approach:
### Server-Side Rendering
All HTML is generated on the server using JSX as a templating language. This means:
- **Fast initial page loads** - No waiting for JavaScript to download and execute
- **SEO friendly** - Search engines see fully rendered HTML
- **Works without JavaScript** - Core functionality doesn't depend on client-side JS
### AppShell Pattern
The blog implements an "AppShell" pattern where:
1. First visit loads the full page with AppShell (sidebar, header, etc.)
2. Navigation replaces only the `<main>` content
3. Subsequent requests send a custom header to indicate AppShell is already loaded
4. Server returns just the content, not the full shell
This gives us SPA-like navigation speed with SSR benefits.
## Markdown Processing
Blog posts are written in Markdown and processed at build time:
```typescript
// Simplified example
import { marked } from 'marked';
const html = marked.parse(markdownContent);
```
The plugin:
- Scans the content directory
- Parses frontmatter (title, date, tags, etc.)
- Converts markdown to HTML
- Stores everything in SQLite for fast queries
## Performance Results
The result? Page loads under 128KB including:
- All HTML
- All CSS (inlined)
- Minimal JavaScript for interactivity
First contentful paint happens in under 100ms on a fast connection.
## Developer Experience
With Bun's `--watch` flag, the entire development workflow is seamless:
```bash
bun run --watch ./index.tsx
```
This watches for changes and hot-reloads the server. Combined with the markdown plugin, changing a blog post immediately reflects in the browser.
## Lessons Learned
### What Worked Well
- **JSX for templating** - Familiar, type-safe, and no new syntax to learn
- **SQLite for search** - Fast, embedded, and perfect for a small blog
- **Bun's speed** - Development is incredibly fast
### What Could Be Better
- **Hot reloading** - Would love client-side HMR for styles
- **Build step** - Currently all processing happens at runtime
- **TypeScript types** - Virtual modules need proper type definitions
## Conclusion
Building with Bun has been a great experience. The performance is excellent, and the developer experience is top-notch. If you're building a new project and want to try something modern, I highly recommend giving Bun a shot.
The code for this blog is open source - check it out on GitHub!

View File

@@ -1,51 +0,0 @@
---
title: Welcome to My Blog
date: 2025-10-15
tags: [Career, Web Development, Another Tag, Blogging]
excerpt: Starting a new blog to share my thoughts on web development, programming, and building great software. Here's why I decided to start writing.
draft: false
---
# Welcome to My Blog
I'm excited to finally launch my personal blog! After years of thinking about it, I've decided it's time to start sharing my experiences, learnings, and thoughts about web development and software engineering.
## Why Start a Blog?
There are a few reasons I decided to finally take the plunge:
**Learning in Public**: Writing about what I learn helps solidify my understanding. Teaching is one of the best ways to learn, and writing blog posts forces me to really understand a topic deeply.
**Building a Knowledge Base**: I often solve problems and then forget the solution months later. This blog will serve as my personal reference guide for future me.
**Contributing to the Community**: The developer community has given me so much through blog posts, tutorials, and open source. This is my way of giving back.
## What to Expect
I plan to write about:
- Web development best practices
- TypeScript and JavaScript tips
- Architecture and design patterns
- Career advice and lessons learned
- Tools and productivity
## The Tech Stack
This blog itself is built with some fun technologies:
- **Bun** - The all-in-one JavaScript runtime
- **TypeScript** - For type safety
- **Elysia** - Fast web framework for Bun
- **SQLite** - For blog post search and metadata
I'll be writing more about the technical implementation in future posts.
## Let's Connect
If you enjoy the content, feel free to reach out! You can find me on Twitter, GitHub, and LinkedIn (links in the sidebar).
Thanks for reading, and I hope you find something useful here!
Test change at Wed Oct 22 06:20:32 PDT 2025
<!-- test change -->

View File

@@ -0,0 +1,250 @@
---
title: "Building a High-Performance Blog from Scratch with Bun"
date: 2026-4-28
tags: [Bun, Progressive Enhancement, Performance, React, Typescript, Web App Architecture, AppShell]
excerpt: "A little about building my blog with Bun and achieving less than 40KB page loads by implementing an App Shell architecture that progressively enhances from an MPA into an SPA."
draft: false
---
## Made From Scratch
While it was tempting to deploy something like [Ghost](https://ghost.org/) for my blogging it didn't feel right. As a software engineer, shouldn't my personal site where I share my own thoughts and experiences be something that I built myself? Shouldn't I know what every line of code does and be able to speak to it?
So I built my blog from scratch with no frameworks or CMS (ish) but that doesn't mean modern technologies were off the table. As a big fan of [Bun](https://bun.com/), it only made sense that I would use it here. Its performant, has native Typescript support, and an all-in-one toolbox approach. Initially I was going to use [Elysia](https://elysiajs.com/) as well, but while I still love it and will probably give it it's own blog post, it wasn't the right fit for my blog as much as [Bun's built-in Fullstack Server](https://bun.com/docs/bundler/fullstack) was.
With Bun being an all-in-one toolkit, I only have three dependencies in my package.json
- **React 19** for server-side rendering
- **gray-matter** for parsing frontmatter from blog posts
- **marked** for rendering markdown into html
This combination gives me modern developer tooling that achieves an incredibly performant blog. At it's core, my blog is a static-site generator following the [App Shell Model](https://developer.chrome.com/blog/app-shell) (essentially the precursor to modern island architectures) and progressively enhances into a single-page app should Javascript be allowed to execute. This achieves an initial page load of *less than 40kb over the wire* using gzip compression and even smaller on subsequent navigations. The other benefit of a progressive enhancement approach is that functionality, like filtering by tags, works in JS restricted environments as a multi-page approach and then elevates to client side handling, avoiding the loading cost of the app-shell.
## The App Shell
If you're not familiar with the concept of an App Shell, the short of it is that a website or application has many components that are static and persist across pages called the 'Shell' and once it's loaded, we can then fetch just the content to fill in the 'shell'. Things like navigation bars or footers are often good candidates for being part of the Shell. In a traditional Multi-page-app (MPA) we would be refetching this, often static, content every time we navigate to a new page because we are loading everything on each click. To get around this cost, developers started creating 'Single Page Applications' that are more commonly known as SPAs. These web apps load all the resources upfront and then fetch just the content they need. This means on the first load, we're sending a bunch of things that aren't needed such as the layouts of pages that may never be clicked on, but with the advantage that subsequent pages feel instant because they only need to fetch a (relatively) small amount of data.
Modern web apps usually leverage some kind of server-side rendering (ssr) to improve the initial load performance. My Blog was built with the idea that I wanted my entire site to act like an MPA, and if Javascript is available, it would enhance the user experience by upgrading it to an SPA and load just the content. That's one of the reasons the frontend is so simple, it is just a blog after all.
To achieve this, there are two pieces. The first is the server side implementation and the second is the client side enrichment.
The server is responsible for serving content in two formats, either with or without the AppShell. By default, the server takes the requested content and wraps it in the AppShell before returning it. This is a 'full page load'.
```jsx
return compressResponse(
renderToString(
<AppShell>
<Post
meta={data}
children={post.content}
/>
</AppShell>,
)
);
```
The `renderToString` is a React function to take my JSX components and translate them into browser understandable html. The `compressResponse` function is just a simple utility to gzip a response for sending less data over the network. The key thing to notice is that the AppShell simply wraps the content. This means it's trivial to send content without the AppShell.
```jsx
// AppShell is already loaded, just send the <main> content
if (req.headers.get("shell-loaded") === "true") {
return compressResponse(
renderToString(
<Post
meta={data}
children={post.content}
/>
)
);
}
```
By following semantic html, it's a natural separation to use the `<main>` element and replace it on the client as needed. Included in the AppShell is the onLoad.ts file that gets transpiled into JS and inserted into the AppShell markup.
This script adds special link handling for all the links that take us somewhere else in the blog, we add an onclick handler that stops the default browser behavior of fetching the page and instead calls a custom loadContent function.
```ts
links.forEach(link => {
//Internal Link, use history api
if (link.host == window.location.host) {
link.onclick = async (e) => {
console.log('clicked', link.href);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
window.history.pushState({}, '', link.href);
await loadContent(link.href, true);
}
}
```
`loadContent` fetches the content using JS with a *shell-loaded* header included. This header tells the server that we received a request in which the AppShell was loaded, instructing it to give us just the content, and not the full page with the AppShell included. We then take the response and replace the main element with the new content from the server.
```ts
const response = await fetch(url, {
headers: {
'shell-loaded': 'true'
}
});
const html = await response.text();
const mainElement = document.querySelector('main');
if (mainElement) {
mainElement.outerHTML = html;
attachLinkHandlers();
}
```
We also run the attachLinkHandlers once again to cover any links that are included in the new page to get the enriched behavior and not fall back to a traditional page load.
## Build-Time Magic: Bun Plugins
While there's no web editor for editing blog posts, they have to live somewhere. In my case, they live in the [repo itself](https://git.cbraaten.dev/Caleb/Blog) as markdown files in a *content directory*. This gives me substantial freedom in moving to a different solution easily in the future or using different text editors to write my posts.
Today, most of the time when we reach for a database it's so that we can persist data separate from the lifecycle of our application code execution. In my case, I don't actually care about the data in it because my content is saved in the markdown files. Using Bun's built in [sqlite driver](https://bun.com/docs/runtime/sqlite) I'm able to easily embed all the published posts into my deployment artifact as well as store the metadata for the posts.
Not only does this allow me to deliver on the AppShell model and progressive enhancement of my blog from a MPA into an SPA, this also unlocks the ability for me to easily implement features like pagination for blog posts, an archive to see all of them, being able to show a next and previous button to navigate between posts chronologically, and finally (and most importantly) support filters based on tags.
The beauty of this approach is that the database gets regenerated during the build process from the source markdown files. This means my deployment artifact is completely self-contained, I don't need to configure external database connections, manage database migrations at runtime, or worry about data consistency during deploys. The content is the authoritative source of truth, and the database is simply a convenient in-memory representation optimized for querying.
In order to load these markdown files into my blog I leverage Bun's plugin system. My blog, at the time of this writing, has three plugins. Plugins are implemented by writing some handler code and including the plugin in a `bunfig.toml` file.
```toml
preload = [
"./bun_plugins/onStartup-post-importer.ts"
]
[serve.static]
plugins = [
"./bun_plugins/onImport-markdown-loader.tsx",
"./bun_plugins/onRuntime-external-urls.ts"
]
```
The *preload* key executes the plugins prior to any user space code executing. This allows me to construct the environment that my userspace code expects is a given and I can bifurcate the build process of my markdown files being transformed into html from the runtime code that serves the final product. In my implementation there are three different *contexts* that my 'onStartup-post-importer' could be run within.
First up, if it's production, we assume that the database has already been constructed during the build process and therefore do not have the content directory, but do have a sqlite database that the pre-rendered posts are saved within. This is a simple guard clause that returns early if the environment is production.
If it's not production, we therefore know that the *content directory* exists. In both the **build** and **development** contexts we want to parse the files because we are constructing the database that the application serves. If we are in the **build** context though, we don't need to include draft files so we skip adding those to the database because they aren't yet ready to publish.
```ts
if (process.env.NODE_ENV === 'production') return;
const glob = new Bun.Glob("**/*.md");
for await (const file of glob.scan("./content")) {
// Omit draft blog posts from database initialization in production
if (process.env.NODE_ENV === 'build' && data.draft) {
continue;
}
// Add parsed markdown to database
dbConnection.addPost(route, data, bodyHtml);
}
```
View [full file](https://git.cbraaten.dev/Caleb/Blog/src/branch/main/bun_plugins/onStartup-post-importer.ts)
## The Challenges Navigating HMR
Initially I wanted to use [Bun's built in Hot reloading](https://bun.com/docs/bundler/hot-reloading) but I ran into a problem. The first plugin I wrote was the `onImport-markdown-loader`. This is perhaps *THE COOLEST PART OF BUN'S PLUGIN SYSTEM*. I am able to import the markdown files in my Typescript code and Bun will interject and run my own code allowing me to transform what is actually returned by the import. This plugin removes the metadata, transforms the markdown into html, wraps the blog post in the app shell, returns an html glob
```tsx
const markdownLoader: BunPlugin = {
name: 'markdown-loader',
setup(build) {
build.onLoad({filter: /\.md$/}, async args => {
// Psudo
file = Bun.file(args.path)
{meta, bodyHtml} = parse(file)
// Wrap content with App Shell
const renderedHtml = renderToString(
<AppShell>
<Post meta={meta} children={bodyHtml} />
</AppShell>
);
// Return to importer
return {
contents: renderedHtml,
loader: 'html',
};
});
},
};
```
View [full file](https://git.cbraaten.dev/Caleb/Blog/src/branch/main/bun_plugins/onImport-markdown-loader.tsx)
Inside `index.ts` I have a dynamic import that crawls the *content directory* the same way that the `onStartup-post-importer` does and then attempts to import each markdown file. Once all the files have been 'imported' they are saved into an object and destructured into the Bun server registering each endpoint.
```tsx
Bun.serve({
development: {
hmr: true,
console: true,
},
routes: {
// Destructure the blogPosts object
...(await blogPosts()),
},
});
```
This is great because all files that are imported are automatically recorded in Bun's file watcher and will be reloaded on any changes. This means I can write my blog posts and see them re-rendered in the browser as they would be presented just like I was developing with React.
For as cool as this was, it unfortunately introduced two problems. The first was that this would prevent me from implementing my App Shell approach because the Bun server would register the full page as a route and I wasn't able to hook in and serve a version with just the content using a header. It was all or nothing so I duplicated the mounting of blog posts, one as the standard mount method used in production and one with HMR support / file watching when running in development.
```tsx
Bun.serve({
development: {
hmr: true,
console: true,
},
routes: {
// standard mounting of blog posts
...(await blogPosts(false)),
// hot module replacement in development mode
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
},
});
```
The other issue was that Bun would try to bundle all the assets it saw referenced in the import. This is a great feature, but when the `onStartup-post-importer` plugin would import the markdown, it wrapped it in the AppShell. This AppShell included a `profile-badge` component that used a static image for my profile picture. Bun would then try to resolve this image and bundle it into a dynamically named resource. That same component in production would still have the original file name resulting in a broken image.
This put me in a catch 22. Either I use Bun's HMR and forgo the App Shell model due to not being able to resolve the profile picture or I give up HMR and maintain the AppShell approach. Instead, I went with hacking the bundler through another plugin, `onRuntime-external-urls`. This plugin intercepts resolving assets and if the resource is included in the ignore list, we tell the bundler to move on. This allows me to continue using Bun's HMR system for viewing blog posts as I edit them just with a broken link in the `profile-badge` which, for development, isn't a big deal.
```tsx
const runtimeExternalUrlsPlugin: BunPlugin = {
name: 'runtime-external-urls',
setup(build) {
// Intercept resolution of paths that start with /
build.onResolve({ filter: /^\// }, args => {
// Mark specific runtime URLs as external to prevent bundler analysis
const externalPatterns = [
'/profile-picture.webp',
];
if (externalPatterns.some(pattern => args.path.includes(pattern))) {
return {
path: args.path,
external: true, // This tells the bundler NOT to bundle this
};
}
return undefined; // Let normal resolution continue
});
},
};
```
## Looking Forward To Sharing More
While it certainly would have been a lot easier and substantially faster to deploy an already built solution, it didn't feel right. And while there was a desire to use an existing framework like Astro, I wanted a blog that was performant because I personally made it that way. One core requirement that I had was that my site would be less that 128kb whenever you loaded any page. Not only did I meet that requirement, I surpassed that goal by being less than 40kb, including my profile picture!
It's worth calling out, that this post highlights the success I had building my blog but in reality there were a lot of challenges. I went down several dead ends, went back and forth on what web server I was going to use, and ran into countless bugs. I wanted to give up on it dozens of times and even today I can still see all kinds of issues and architectural problems that I don't like. But I'm learning to let go and ship. Not only does it work, it works really well, and there is no need to let perfection get in the way of starting to share my thoughts.
And while yes, this site may appear to be a simple blog, there's actually a lot that went into achieving an AppShell model that starts the application as an MPA and then progressively enhances into an SPA. Now, every time I visit my blog, I can have the satisfaction of watching how fast it loads and know that I built it from scratch.
> If you're interested, you can view the [full source code here](https://git.cbraaten.dev/Caleb/Blog)

View File

@@ -7,6 +7,54 @@ import { NotFound } from "./src/frontend/pages/not-found";
import { Post } from "./src/frontend/pages/post";
import { dbConnection } from "./src/db";
Bun.serve({
development: {
hmr: true,
console: true,
},
routes: {
// standard mounting of blog posts
...(await blogPosts(false)),
// hot module replacement in development mode
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
// Home page
"/": (req: Request) => {
// Extract URL parameters from the request to pass to the component
const searchParams = new URLSearchParams(req.url.split('?')[1]);
if (req.headers.get("shell-loaded") === "true") {
return compressResponse(renderToString(<Home searchParams={searchParams} />));
}
return compressResponse(
renderToString(
<AppShell searchParams={searchParams}>
<Home searchParams={searchParams} />
</AppShell>,
)
);
},
"/profile-picture.webp": () => {
return new Response(Bun.file("./src/public/profile-picture.webp"), {
headers: {
"Content-Type": "image/webp",
},
});
},
"/healthz": new Response('ok'),
"/*": (req) => {
if(req.headers.get("shell-loaded") === "true") {
return compressResponse(renderToString(<NotFound />), 404);
}
return compressResponse(renderToString(<AppShell><NotFound /></AppShell>), 404);
}
},
});
function compressResponse(html: string, status?: number) {
const compressed = Bun.gzipSync(Buffer.from(html));
return new Response(compressed, {
@@ -78,49 +126,3 @@ async function blogPosts(hmr: boolean) {
});
return blogPosts;
}
Bun.serve({
development: {
hmr: true,
console: true,
},
routes: {
// standard mounting of blog posts
...(await blogPosts(false)),
// hot module replacement in development mode
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
// Home page
"/": (req: Request) => {
// Extract URL parameters from the request to pass to the component
const searchParams = new URLSearchParams(req.url.split('?')[1]);
if (req.headers.get("shell-loaded") === "true") {
return compressResponse(renderToString(<Home searchParams={searchParams} />));
}
return compressResponse(
renderToString(
<AppShell searchParams={searchParams}>
<Home searchParams={searchParams} />
</AppShell>,
)
);
},
"/profile-picture.webp": () => {
return new Response(Bun.file("./src/public/profile-picture.webp"), {
headers: {
"Content-Type": "image/webp",
},
});
},
"/*": (req) => {
if(req.headers.get("shell-loaded") === "true") {
return compressResponse(renderToString(<NotFound />), 404);
}
return compressResponse(renderToString(<AppShell><NotFound /></AppShell>), 404);
}
},
});

View File

@@ -1,5 +1,6 @@
import { Database } from 'bun:sqlite';
import path from 'path';
import { calculateReadTime } from '../frontend/utils';
/**
* Singleton database connection class for managing blog posts and tags
@@ -101,7 +102,7 @@ class DatabaseConnection {
filePath ? String(filePath) : null,
title ? String(title) : null,
date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null,
readingTime ? String(readingTime) : null,
readingTime ? String(readingTime) : calculateReadTime(content),
excerpt ? String(excerpt) : null,
content ? String(content) : null
];
@@ -269,15 +270,7 @@ class DatabaseConnection {
// Add tags to each post and clean up paths
return posts.map(post => {
const basePost = post satisfies {
id: number,
path: string,
title: string,
date: string,
readingTime: string,
summary: string,
content: string
};
const basePost = { ...post, readingTime: post.reading_time}
return {
...basePost,

View File

@@ -18,6 +18,13 @@ export function AppShell({ children, searchParams }: { children: ReactNode, sear
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Caleb's Blog</title>
<script
defer
src="https://l.cbraaten.dev/script.js"
data-domains="cbraaten.dev,www.cbraaten.dev"
data-website-id="3632433b-2e3b-45d7-aea2-dece88184e92"
data-performance="true"
></script>
<style dangerouslySetInnerHTML={{ __html: minifyCSS(styles) }} />
</head>
<body>

View File

@@ -1,6 +1,7 @@
// Add function to 'Clear all filters'
document.querySelector('.clear-tags-btn')
?.addEventListener('click', (e) => {
umami.track("ClearAllTags");
const activeTags = document.querySelectorAll('a.tag-pill.active')
activeTags.forEach((activeTag) => {
activeTag.classList.remove('active')
@@ -20,10 +21,14 @@ function toggleTagPill(e: Event) {
if (target.classList.contains('active')) {
target.classList.remove('active')
searchParams.delete('tag', getURLSafeTagName(target.innerText))
const tagName = getURLSafeTagName(target.innerText)
searchParams.delete('tag', tagName)
umami.track("ToggleTag", { tag: tagName, type: 'remove' });
} else {
target.classList.add('active')
searchParams.append('tag', getURLSafeTagName(target.innerText))
const tagName = getURLSafeTagName(target.innerText)
searchParams.append('tag', tagName)
umami.track("ToggleTag", { tag: tagName, type: 'add' });
}
// Update tag urls after the loader in onLoad completes

View File

@@ -73,6 +73,7 @@ function attachThemeOptionListeners() {
function selectTheme(theme: string) {
THEMES[currentMode] = theme;
localStorage.setItem('theme-' + currentMode, theme);
umami.track("SelectTheme", { type: currentMode, theme: theme });
updateUI();
applyTheme();
}

View File

@@ -13,21 +13,34 @@ export function ProfileBadge() {
</div>
<ul className="social-links">
<li>
<a href={urlLinkedIn} data-external target="_blank" rel="noopener noreferrer" aria-label="LinkedIn">
<a
href={urlLinkedIn}
target="_blank"
no-decorate='true'
aria-label="LinkedIn"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</li>
<li>
<a href={urlX} data-external target="_blank" rel="noopener noreferrer" aria-label="X (Twitter)">
<a href={urlX}
target="_blank"
no-decorate='true'
aria-label="X (Twitter)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
</li>
<li>
<a href={urlGit} data-external target="_blank" rel="noopener noreferrer" aria-label="GitHub / Gitea">
<a href={urlGit}
target="_blank"
no-decorate='true'
aria-label="GitHub / Gitea"
>
<div className="git-icon-wrapper">
<svg className="icon-github" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>

View File

@@ -21,9 +21,22 @@ async function loadContent(url: string, shouldScrollToTop: boolean = true) {
}
function attachLinkHandlers() {
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
const currentDomain = window.location.hostname;
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a');
const internalLinks = Array.from(links).filter(link => {
const linkDomain = new URL(link.href).hostname;
return linkDomain == currentDomain;
});
const externalLinks = Array.from(links).filter(link => {
const linkDomain = new URL(link.href).hostname;
return linkDomain !== currentDomain;
})
links.forEach(link => {
//Internal Link, use history api
if (link.host == window.location.host) {
link.onclick = async (e) => {
console.log('clicked', link.href);
e.preventDefault();
@@ -32,6 +45,15 @@ function attachLinkHandlers() {
window.history.pushState({}, '', link.href);
await loadContent(link.href, true);
}
// External Link, add outbound link recording
} else {
const name = 'outbound-link-click';
if (!link.getAttribute('data-umami-event')) {
link.setAttribute('data-umami-event', name);
link.setAttribute('data-umami-event-url', link.href)
link.setAttribute('target', '_blank')
}
}
});
}

View File

@@ -57,7 +57,7 @@ function PostCard({ post }: { post: Post }) {
<div className="post-card-meta">
<time dateTime={post.date}>{formattedDate}</time>
<span className="meta-separator"></span>
<span className="read-time">5 min read</span>
<span className="read-time">{post.readingTime} min read</span>
</div>
{tags.length > 0 && (
<div className="post-card-tags">

View File

@@ -47,7 +47,7 @@ export function Post({ children, meta }: PostProps) {
{meta.readingTime &&
<>
<span className="meta-separator"></span>
<span>{meta.readingTime}</span>
<span>{meta.readingTime} min read</span>
</>
}
</div>

View File

@@ -552,6 +552,31 @@ a:hover {
text-decoration: underline;
}
a[target="_blank"]:not([no-decorate]) {
position: relative;
}
a[target="_blank"]:not([no-decorate])::after {
content: "";
display: inline-block;
width: 0.75em;
height: 0.75em;
vertical-align: text-top;
background-color: var(--text-secondary);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.0002 5H8.2002C7.08009 5 6.51962 5 6.0918 5.21799C5.71547 5.40973 5.40973 5.71547 5.21799 6.0918C5 6.51962 5 7.08009 5 8.2002V15.8002C5 16.9203 5 17.4801 5.21799 17.9079C5.40973 18.2842 5.71547 18.5905 6.0918 18.7822C6.5192 19 7.07899 19 8.19691 19H15.8031C16.921 19 17.48 19 17.9074 18.7822C18.2837 18.5905 18.5905 18.2839 18.7822 17.9076C19 17.4802 19 16.921 19 15.8031V14M20 9V4M20 4H15M20 4L13 11' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.0002 5H8.2002C7.08009 5 6.51962 5 6.0918 5.21799C5.71547 5.40973 5.40973 5.71547 5.21799 6.0918C5 6.51962 5 7.08009 5 8.2002V15.8002C5 16.9203 5 17.4801 5.21799 17.9079C5.40973 18.2842 5.71547 18.5905 6.0918 18.7822C6.5192 19 7.07899 19 8.19691 19H15.8031C16.921 19 17.48 19 17.9074 18.7822C18.2837 18.5905 18.5905 18.2839 18.7822 17.9076C19 17.4802 19 16.921 19 15.8031V14M20 9V4M20 4H15M20 4L13 11' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
a[target="_blank"]:not([no-decorate]):hover::after {
background-color: var(--text-primary);
}
h1 {
margin-top: 0;
}

View File

@@ -26,8 +26,8 @@ export function calculateReadTime(content: string): number {
// Helper function to format date for display
export function formatDate(dateString: string): string {
// Parse ISO date string (YYYY-MM-DD)
const date = new Date(dateString + 'T00:00:00');
// Parse ISO date string (YYYY-MM-DD)unique
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',