Compare commits
14 Commits
7a9f261189
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a565a9a5e | |||
| b8027deea4 | |||
| afdfdd76ab | |||
| 149489c8b8 | |||
| 38336301e9 | |||
| 1ea315543b | |||
| b8129f6aa5 | |||
| af0434cffb | |||
| 6de2346ee5 | |||
| c29244d2b6 | |||
| f6275e4f58 | |||
| f3eb83bcc0 | |||
| b96f7ed3f0 | |||
| 603687c46b |
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.editorconfig
|
||||
coverage*
|
||||
dist
|
||||
*.log
|
||||
blog.sqlite
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
60
.gitea/workflows/build.yml
Normal file
60
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: git.cbraaten.dev
|
||||
IMAGE_NAME: git.cbraaten.dev/caleb/blog
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: sha
|
||||
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Print Secrets
|
||||
run: |
|
||||
if [ -z "${{ secrets.REGISTRY_USERNAME }}" ]; then
|
||||
echo "ERROR: REGISTRY_USERNAME secret is not set"
|
||||
exit 1
|
||||
else
|
||||
echo "✓ REGISTRY_USERNAME is set"
|
||||
fi
|
||||
if [ -z "${{ secrets.REGISTRY_PASSWORD }}" ]; then
|
||||
echo "ERROR: REGISTRY_PASSWORD secret is not set"
|
||||
exit 1
|
||||
else
|
||||
echo "✓ REGISTRY_PASSWORD is set"
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short_sha }}
|
||||
46
.gitea/workflows/deploy.yml
Normal file
46
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Deploy to Nomad
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push Docker Image"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
env:
|
||||
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
|
||||
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get image tag from git SHA
|
||||
id: tag
|
||||
run: |
|
||||
image_tag=$(git rev-parse --short HEAD)
|
||||
echo "image_tag=$image_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update image tag in job definition
|
||||
run: |
|
||||
# Replace the image tag placeholder with actual tag
|
||||
jq '.Job.TaskGroups[0].Tasks[0].Config.image |= sub(":.*"; ":${{ steps.tag.outputs.image_tag }}")' \
|
||||
blog.nomad.json > blog.nomad.final.json
|
||||
|
||||
- name: Submit job via Nomad API
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "X-Nomad-Token: ${{ env.NOMAD_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @blog.nomad.final.json \
|
||||
"${{ env.NOMAD_ADDR }}/v1/jobs"
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
echo "Waiting for deployment to stabilize..."
|
||||
sleep 5
|
||||
curl -X GET \
|
||||
-H "X-Nomad-Token: ${{ env.NOMAD_TOKEN }}" \
|
||||
"${{ env.NOMAD_ADDR }}/v1/job/blog"
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
blog.sqlite
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
@@ -25,6 +26,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -39,4 +41,4 @@ yarn-error.log*
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
**/*.bun
|
||||
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# Build stage
|
||||
FROM oven/bun:1 AS builder
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code and content
|
||||
COPY . .
|
||||
|
||||
# Copy database initialization script
|
||||
COPY init-db.ts ./
|
||||
|
||||
# Initialize database using init-db.ts script
|
||||
# This script handles draft-aware post verification and stability detection
|
||||
ENV NODE_ENV=build
|
||||
RUN bun run init-db.ts
|
||||
|
||||
# Final production stage
|
||||
FROM oven/bun:1
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy dependencies
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/package.json ./
|
||||
|
||||
# Copy source code (for runtime transpilation)
|
||||
COPY --from=builder /usr/src/app/src ./src
|
||||
COPY --from=builder /usr/src/app/content ./content
|
||||
COPY --from=builder /usr/src/app/bun_plugins ./bun_plugins
|
||||
COPY --from=builder /usr/src/app/index.tsx .
|
||||
COPY --from=builder /usr/src/app/tsconfig.json .
|
||||
|
||||
# Copy the initialized database for efficient layer caching
|
||||
COPY --from=builder /usr/src/app/blog.sqlite ./
|
||||
|
||||
# Fix ownership for bun user (needed for runtime operations)
|
||||
RUN chown -R bun:bun /usr/src/app
|
||||
|
||||
# Switch to bun user
|
||||
USER bun
|
||||
|
||||
# Expose the default port
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
# Production environment (onStartup-post-importer won't run since DB exists)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Run the application directly (Bun handles TSX transpilation at runtime)
|
||||
ENTRYPOINT [ "bun", "run", "index.tsx" ]
|
||||
73
blog.nomad.hcl
Normal file
73
blog.nomad.hcl
Normal file
@@ -0,0 +1,73 @@
|
||||
# export to json for ci/cd scripts with the following command
|
||||
# nomad job run -output blog.nomad.hcl > blog.nomad.json
|
||||
|
||||
locals {
|
||||
HOST = "cbraaten.dev"
|
||||
}
|
||||
|
||||
variable "image_tag" {
|
||||
type = string
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
job "blog" {
|
||||
type = "service"
|
||||
|
||||
group "blog" {
|
||||
count = 1
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 3000
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
name = "blog"
|
||||
provider = "consul"
|
||||
port = "http"
|
||||
|
||||
tags = [
|
||||
# ----- router for the naked domain -----
|
||||
"traefik.enable=true",
|
||||
"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}",
|
||||
]
|
||||
}
|
||||
|
||||
task "blog" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "git.cbraaten.dev/caleb/blog:${var.image_tag}"
|
||||
ports = ["http"]
|
||||
}
|
||||
}
|
||||
|
||||
update {
|
||||
max_parallel = 1
|
||||
min_healthy_time = "10s"
|
||||
healthy_deadline = "3m"
|
||||
auto_revert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
176
blog.nomad.json
Normal file
176
blog.nomad.json
Normal file
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"Job": {
|
||||
"Region": null,
|
||||
"Namespace": null,
|
||||
"ID": "blog",
|
||||
"Name": "blog",
|
||||
"Type": "service",
|
||||
"Priority": null,
|
||||
"AllAtOnce": null,
|
||||
"Datacenters": null,
|
||||
"NodePool": null,
|
||||
"Constraints": null,
|
||||
"Affinities": null,
|
||||
"TaskGroups": [
|
||||
{
|
||||
"Name": "blog",
|
||||
"Count": 1,
|
||||
"Constraints": null,
|
||||
"Affinities": null,
|
||||
"Tasks": [
|
||||
{
|
||||
"Name": "blog",
|
||||
"Driver": "docker",
|
||||
"User": "",
|
||||
"Lifecycle": null,
|
||||
"Config": {
|
||||
"image": "git.cbraaten.dev/caleb/blog:latest",
|
||||
"ports": [
|
||||
"http"
|
||||
]
|
||||
},
|
||||
"Constraints": null,
|
||||
"Affinities": null,
|
||||
"Env": null,
|
||||
"Services": null,
|
||||
"Resources": null,
|
||||
"RestartPolicy": null,
|
||||
"Meta": null,
|
||||
"KillTimeout": null,
|
||||
"LogConfig": null,
|
||||
"Artifacts": null,
|
||||
"Vault": null,
|
||||
"Consul": null,
|
||||
"Templates": null,
|
||||
"DispatchPayload": null,
|
||||
"VolumeMounts": null,
|
||||
"Leader": false,
|
||||
"ShutdownDelay": 0,
|
||||
"KillSignal": "",
|
||||
"Kind": "",
|
||||
"ScalingPolicies": null,
|
||||
"Secrets": null,
|
||||
"Identity": null,
|
||||
"Identities": null,
|
||||
"Actions": null,
|
||||
"Schedule": null
|
||||
}
|
||||
],
|
||||
"Spreads": null,
|
||||
"Volumes": null,
|
||||
"RestartPolicy": null,
|
||||
"Disconnect": null,
|
||||
"ReschedulePolicy": null,
|
||||
"EphemeralDisk": null,
|
||||
"Update": {
|
||||
"Stagger": null,
|
||||
"MaxParallel": 1,
|
||||
"HealthCheck": null,
|
||||
"MinHealthyTime": 10000000000,
|
||||
"HealthyDeadline": 180000000000,
|
||||
"ProgressDeadline": null,
|
||||
"Canary": null,
|
||||
"AutoRevert": true,
|
||||
"AutoPromote": null
|
||||
},
|
||||
"Migrate": null,
|
||||
"Networks": [
|
||||
{
|
||||
"Mode": "",
|
||||
"Device": "",
|
||||
"CIDR": "",
|
||||
"IP": "",
|
||||
"DNS": null,
|
||||
"ReservedPorts": null,
|
||||
"DynamicPorts": [
|
||||
{
|
||||
"Label": "http",
|
||||
"Value": 0,
|
||||
"To": 3000,
|
||||
"HostNetwork": "",
|
||||
"IgnoreCollision": false
|
||||
}
|
||||
],
|
||||
"Hostname": "",
|
||||
"MBits": null,
|
||||
"CNI": null
|
||||
}
|
||||
],
|
||||
"Meta": null,
|
||||
"Services": [
|
||||
{
|
||||
"Name": "blog",
|
||||
"Tags": [
|
||||
"traefik.enable=true",
|
||||
"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,
|
||||
"PortLabel": "http",
|
||||
"AddressMode": "",
|
||||
"Address": "",
|
||||
"Checks": null,
|
||||
"CheckRestart": null,
|
||||
"Connect": null,
|
||||
"Meta": null,
|
||||
"CanaryMeta": null,
|
||||
"TaggedAddresses": null,
|
||||
"TaskName": "",
|
||||
"OnUpdate": "",
|
||||
"Identity": null,
|
||||
"Weights": null,
|
||||
"Provider": "consul",
|
||||
"Cluster": "",
|
||||
"Kind": ""
|
||||
}
|
||||
],
|
||||
"ShutdownDelay": null,
|
||||
"StopAfterClientDisconnect": null,
|
||||
"MaxClientDisconnect": null,
|
||||
"Scaling": null,
|
||||
"Consul": null,
|
||||
"PreventRescheduleOnLost": null
|
||||
}
|
||||
],
|
||||
"Update": null,
|
||||
"Multiregion": null,
|
||||
"Spreads": null,
|
||||
"Periodic": null,
|
||||
"ParameterizedJob": null,
|
||||
"Reschedule": null,
|
||||
"Migrate": null,
|
||||
"Meta": null,
|
||||
"UI": null,
|
||||
"Stop": null,
|
||||
"ParentID": null,
|
||||
"Dispatched": false,
|
||||
"DispatchIdempotencyToken": null,
|
||||
"Payload": null,
|
||||
"ConsulNamespace": null,
|
||||
"VaultNamespace": null,
|
||||
"NomadTokenID": null,
|
||||
"Status": null,
|
||||
"StatusDescription": null,
|
||||
"Stable": null,
|
||||
"Version": null,
|
||||
"SubmitTime": null,
|
||||
"CreateIndex": null,
|
||||
"ModifyIndex": null,
|
||||
"JobModifyIndex": null,
|
||||
"VersionTag": null
|
||||
}
|
||||
}
|
||||
30
bun_plugins/onRuntime-external-urls.ts
Normal file
30
bun_plugins/onRuntime-external-urls.ts
Normal 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;
|
||||
@@ -14,6 +14,11 @@ import { dbConnection } from "../src/db";
|
||||
const {data, content } = matter(await Bun.file(`./content/${file}`).text());
|
||||
const route = `/${file.replace(/\.md$/, "")}`;
|
||||
|
||||
// Omit draft blog posts from database initialization in production
|
||||
if (process.env.NODE_ENV === 'build' && data.draft) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
|
||||
let processedContent = content;
|
||||
if (data.title) {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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!
|
||||
@@ -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.*
|
||||
@@ -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!
|
||||
@@ -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 -->
|
||||
@@ -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. It’s 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)
|
||||
172
index.tsx
172
index.tsx
@@ -7,79 +7,6 @@ import { NotFound } from "./src/frontend/pages/not-found";
|
||||
import { Post } from "./src/frontend/pages/post";
|
||||
import { dbConnection } from "./src/db";
|
||||
|
||||
async function blogPosts(hmr: boolean) {
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
const blogPosts: Record<string, any> = {};
|
||||
for await (const file of glob.scan("./content")) {
|
||||
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
||||
const route = `/${file.replace(/\.md$/, "")}`;
|
||||
dbConnection.getAllTags();
|
||||
|
||||
if (hmr) {
|
||||
// Use Bun Importer plugin for hot reloading in the browser
|
||||
blogPosts[`/hmr${route}`] = post.default;
|
||||
} else {
|
||||
// Use the Database for sending just the HTML or the HTML and AppShell
|
||||
blogPosts[route] = async (req: Request) => {
|
||||
const path = new URL(req.url).pathname;
|
||||
const post = dbConnection.getPost(path);
|
||||
if (!post)
|
||||
return new Response(renderToString(<NotFound />), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
|
||||
// Get adjacent posts for navigation
|
||||
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
|
||||
|
||||
const data = {
|
||||
title: post.title,
|
||||
summary: post.summary,
|
||||
date: new Date(post.date),
|
||||
readingTime: post.reading_time,
|
||||
tags: post.tags || [],
|
||||
previousPost,
|
||||
nextPost,
|
||||
};
|
||||
|
||||
// AppShell is already loaded, just send the <main> content
|
||||
if (req.headers.get("shell-loaded") === "true") {
|
||||
return new Response(
|
||||
renderToString(<Post meta={data} children={post.content} />),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// AppShell is not loaded, send the <AppShell> with the <main> content inside
|
||||
return new Response(
|
||||
renderToString(
|
||||
<AppShell>
|
||||
<Post
|
||||
meta={data}
|
||||
children={post.content}
|
||||
/>
|
||||
</AppShell>,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(blogPosts).map((route) => {
|
||||
console.info(route);
|
||||
});
|
||||
return blogPosts;
|
||||
}
|
||||
|
||||
Bun.serve({
|
||||
development: {
|
||||
hmr: true,
|
||||
@@ -99,24 +26,15 @@ Bun.serve({
|
||||
const searchParams = new URLSearchParams(req.url.split('?')[1]);
|
||||
|
||||
if (req.headers.get("shell-loaded") === "true") {
|
||||
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
return compressResponse(renderToString(<Home searchParams={searchParams} />));
|
||||
}
|
||||
|
||||
return new Response(
|
||||
return compressResponse(
|
||||
renderToString(
|
||||
<AppShell searchParams={searchParams}>
|
||||
<Home searchParams={searchParams} />
|
||||
</AppShell>,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
},
|
||||
"/profile-picture.webp": () => {
|
||||
@@ -126,17 +44,85 @@ Bun.serve({
|
||||
},
|
||||
});
|
||||
},
|
||||
"/healthz": new Response('ok'),
|
||||
"/*": (req) => {
|
||||
if(req.headers.get("shell-loaded") === "true") {
|
||||
return new Response(renderToString(<NotFound />), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
return compressResponse(renderToString(<NotFound />), 404);
|
||||
}
|
||||
return new Response(renderToString(<AppShell><NotFound /></AppShell>), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
return compressResponse(renderToString(<AppShell><NotFound /></AppShell>), 404);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
function compressResponse(html: string, status?: number) {
|
||||
const compressed = Bun.gzipSync(Buffer.from(html));
|
||||
return new Response(compressed, {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"Content-Encoding": "gzip",
|
||||
"Vary": "Accept-Encoding"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function blogPosts(hmr: boolean) {
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
const blogPosts: Record<string, any> = {};
|
||||
for await (const file of glob.scan("./content")) {
|
||||
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
||||
const route = `/${file.replace(/\.md$/, "")}`;
|
||||
dbConnection.getAllTags();
|
||||
|
||||
if (hmr) {
|
||||
// Use Bun Importer plugin for hot reloading in the browser
|
||||
blogPosts[`/hmr${route}`] = post.default;
|
||||
} else {
|
||||
// Use the Database for sending just the HTML or the HTML and AppShell
|
||||
blogPosts[route] = async (req: Request) => {
|
||||
const path = new URL(req.url).pathname;
|
||||
const post = dbConnection.getPost(path);
|
||||
if (!post)
|
||||
return compressResponse(renderToString(<NotFound />), 404);
|
||||
|
||||
// Get adjacent posts for navigation
|
||||
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
|
||||
|
||||
const data = {
|
||||
title: post.title,
|
||||
summary: post.summary,
|
||||
date: new Date(post.date),
|
||||
readingTime: post.reading_time,
|
||||
tags: post.tags || [],
|
||||
previousPost,
|
||||
nextPost,
|
||||
};
|
||||
|
||||
// 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} />)
|
||||
);
|
||||
}
|
||||
|
||||
// AppShell is not loaded, send the <AppShell> with the <main> content inside
|
||||
return compressResponse(
|
||||
renderToString(
|
||||
<AppShell>
|
||||
<Post
|
||||
meta={data}
|
||||
children={post.content}
|
||||
/>
|
||||
</AppShell>,
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(blogPosts).map((route) => {
|
||||
console.info(route);
|
||||
});
|
||||
return blogPosts;
|
||||
}
|
||||
|
||||
85
init-db.ts
Normal file
85
init-db.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
const { Database } = require("bun:sqlite");
|
||||
|
||||
async function waitForDatabase() {
|
||||
const serverProcess = Bun.spawn(["bun", "run", "index.tsx"], {
|
||||
env: { ...process.env, NODE_ENV: "development" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe"
|
||||
});
|
||||
|
||||
// Count expected markdown files - this gives us total potential posts
|
||||
const files = [];
|
||||
for await (const file of new Bun.Glob("**/*.md").scan("./content")) {
|
||||
files.push(file);
|
||||
}
|
||||
const totalMarkdownFiles = files.length;
|
||||
console.log(`Found ${totalMarkdownFiles} markdown files to process`);
|
||||
|
||||
const maxWait = 30000; // 30 seconds total timeout
|
||||
const checkInterval = 100; // Check every 100ms
|
||||
let waited = 0;
|
||||
let stableCount = 0;
|
||||
let previousCount = -1;
|
||||
|
||||
while (waited < maxWait) {
|
||||
if (await Bun.file("blog.sqlite").exists()) {
|
||||
try {
|
||||
const db = new Database("blog.sqlite", { readonly: true });
|
||||
|
||||
// Count only published posts (routes ending without .md)
|
||||
// This matches the logic used in the actual application queries
|
||||
const result = db.query("SELECT COUNT(*) as count FROM posts WHERE path NOT LIKE '%.md'").get();
|
||||
const publishedPosts = result.count;
|
||||
db.close();
|
||||
|
||||
if (publishedPosts === previousCount) {
|
||||
stableCount++;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
previousCount = publishedPosts;
|
||||
console.log(`Importing progress: ${publishedPosts}/${totalMarkdownFiles} published posts`);
|
||||
}
|
||||
|
||||
// If the count has been stable for 10 consecutive checks (1 second), consider import complete
|
||||
// We check against available posts, not total markdown files, since some may be drafts
|
||||
if (stableCount >= 10 && publishedPosts >= 1) {
|
||||
console.log(`Import complete: ${publishedPosts} published posts ready`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Database might still be writing or locked
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
waited += checkInterval;
|
||||
}
|
||||
|
||||
// Check if we succeeded
|
||||
if (!(await Bun.file("blog.sqlite").exists())) {
|
||||
console.error("Database file was not created");
|
||||
await serverProcess.kill();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Final verification - check published posts count
|
||||
const db = new Database("blog.sqlite", { readonly: true });
|
||||
const result = db.query("SELECT COUNT(*) as count FROM posts WHERE path NOT LIKE '%.md'").get();
|
||||
const finalPublishedPosts = result.count;
|
||||
|
||||
// Also get total posts for informational purposes
|
||||
const totalResult = db.query("SELECT COUNT(*) as count FROM posts").get();
|
||||
const totalPosts = totalResult.count;
|
||||
db.close();
|
||||
|
||||
if (finalPublishedPosts < 1) {
|
||||
console.error(`No published posts found! Total: ${totalPosts}, Published: ${finalPublishedPosts}`);
|
||||
await serverProcess.kill();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await serverProcess.kill();
|
||||
console.log(`Database initialization complete: ${totalPosts} total posts (${finalPublishedPosts} published)`);
|
||||
}
|
||||
|
||||
waitForDatabase();
|
||||
@@ -2,8 +2,11 @@
|
||||
"name": "portfolio",
|
||||
"version": "1.0.50",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch ./index.tsx"
|
||||
"dev": "bun run --watch ./index.tsx",
|
||||
"build": "bun run docker:build",
|
||||
"docker:build": "docker build --no-cache -t blog:latest .",
|
||||
"docker:run": "docker run -d -p 3000:3000 --name blog blog:latest",
|
||||
"docker:stop": "docker stop blog && docker rm blog"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -15,4 +18,4 @@
|
||||
"@types/react-dom": "^19.2.3"
|
||||
},
|
||||
"module": "src/index.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -21,16 +21,38 @@ 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 => {
|
||||
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);
|
||||
//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);
|
||||
}
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -19,7 +19,7 @@ interface PostProps {
|
||||
|
||||
export function Post({ children, meta }: PostProps) {
|
||||
const { previousPost, nextPost } = meta;
|
||||
|
||||
|
||||
return (
|
||||
<main>
|
||||
<article className="blog-post">
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user