Source of igor hasse — personal blog and public notebook by Igor Hasse Santiago. Bilingual (pt-BR / en), typography-driven, deployed on Cloudflare Workers.
Live: igorhasse.com · RSS: pt / en
Source of the site plus the CI/CD that ships it. Public because the stack is opinionated and the write → publish flow is fully automated.
- Tech stack
- Quick start
- Project structure
- Commands
- Writing a new post
- Adding a new code language
- Site identity
- Pipelines — local, CI, deploy
- Workers gotchas
- Philosophy
- License
| Layer | Choice |
|---|---|
| Framework | vinext 0.0.43 (Vite 8 + React 19 SSR that reimplements the Next.js API) |
| Router | App Router with [locale] dynamic segment (pt-BR / en) |
| Styles | Tailwind v4 + tokens in styles/tokens.css |
| Markdown | marked + shiki (JavaScript regex engine — see gotchas) |
| OG images | next/og (dynamic PNG per route) |
| Linter / Formatter | oxlint + oxfmt |
| Tests | vitest (unit + smoke) |
| Hooks | husky + lint-staged |
| CI | GitHub Actions |
| Deploy | wrangler via yarn dlx vinext deploy |
| Package manager | Yarn 4 (Berry) — pinned via packageManager field, resolved by Corepack |
Requirements: Node.js 22+ with Corepack enabled (corepack enable — ships with Node, no separate install).
git clone https://github.com/igorhasse/unreadable.git
cd unreadable
yarn install # Corepack auto-resolves the pinned Yarn 4 version
cp .env.example .env # fill VITE_MAILCHIMP_URL if you want the newsletter form
yarn dev # http://localhost:3000This project uses Yarn 4 (Berry) with nodeLinker: node-modules. The yarn version is pinned via the packageManager field in package.json — running any yarn command inside the directory automatically uses the pinned version. Do not use npm here: there is no package-lock.json, only yarn.lock. To bump yarn itself: corepack use yarn@stable.
app/ App Router (per-route server components)
[locale]/ locale segment: "pt-BR" or "en"
layout.tsx · page.tsx · about/ · rss/ · posts/[slug]/
opengraph-image.tsx dynamic OG images per route
sitemap.ts · robots.ts
components/ shared components (client-marked only where needed)
content/posts/<slug>/ one folder per post
pt-BR.md · en.md · cover.jpg (markdown + optional media)
i18n/ STRINGS dictionary + t/useT helpers
lib/
site-config.ts author identity (name, email, socials) — single source
posts.ts post loader
markdown.ts marked + shiki pipeline
styles/
tokens.css · globals.css
tests/smoke.test.ts HTTP smoke tests
worker/index.ts Cloudflare Worker entry (generated by vinext)
proxy.ts locale redirect (Next.js 16 rename)
wrangler.jsonc Cloudflare Workers config
vite.config.ts · vitest.config.ts · vitest.smoke.config.ts
.github/workflows/ ci.yml (check + smoke + vuln) and deploy.yml
yarn dev # dev server
yarn build # production build
yarn start # run the production build locally
yarn lint | lint:fix # oxlint [--fix]
yarn format | format:fix # oxfmt [--check]
yarn typecheck # tsc --noEmit
yarn test # unit tests
yarn test:watch
yarn test:coverage
yarn test:smoke # HTTP tests (needs a server on :3000)
yarn check # lint + format + typecheck + test (pre-push & CI)-
Create the bundle
content/posts/my-new-post/ pt-BR.md # required en.md # required cover.jpg # optional, referenced from markdown as ./cover.jpgSlugs are shared across locales — only the language file differs. Any non-
.mdfile in the bundle is copied topublic/posts/<slug>/at build time, and relative paths like./diagram.pngin markdown are rewritten to/posts/<slug>/diagram.png. -
Frontmatter
--- title: Por que TypeScript importa date: 2026-03-25 description: Explorando os benefícios do TypeScript... tags: [typescript, javascript] coverImage: cover.jpg --- # Por que TypeScript importa Conteúdo em markdown...
Fields:
title(required),date(ISO),description,tags,coverImage. -
Code blocks use fenced blocks with one of the supported languages (
css,tsx,typescript,yaml). Anything else falls back toplaintext. To add a language, see the next section. -
Publish
git checkout -b post/my-new-post git add content/posts/my-new-post git commit -m "post: my new post" # pre-commit formats + lints git push # pre-push runs yarn check gh pr create
When CI is green and you merge, Cloudflare auto-deploys in ~40s.
Only languages actually used in posts are bundled — this keeps the Worker small and avoids the WASM regex engine path that Cloudflare Workers blocks.
To register a new language, edit one file: lib/markdown.ts.
Example — adding json:
// 1. Import the grammar
import json from "shiki/langs/json.mjs";
// 2. Include it in the highlighter
const LANGS = [css, tsx, typescript, yaml, json];
// 3. Accept it in normalizeLang's whitelist
const SUPPORTED_LANGS = new Set(["css", "tsx", "typescript", "yaml", "json"]);
// 4. (Optional) Add aliases like "ts" → "typescript" in normalizeLangThe highlighter is built with createHighlighterCore + createJavaScriptRegexEngine() — not the shiki singleton and not oniguruma. See Workers gotchas for why.
All personal info lives in lib/site-config.ts:
export const SITE = {
url: "https://igorhasse.com",
name: "igor hasse",
description: { "pt-BR": "...", en: "..." },
author: {
name: "Igor Hasse Santiago",
email: "igor.hasse@gmail.com",
twitter: "@deserverd",
github: "igorhasse",
linkedin: "...",
},
} as const;Every page reads from here. Forking the repo? Change this file and the rest follows.
Env vars:
VITE_SITE_URL— overrides the default URL (optional, defaults tohttps://igorhasse.com)VITE_MAILCHIMP_URL— newsletter form action (empty = form disabled)
Three gates between editing code and serving it to users. All run automatically.
- pre-commit —
lint-stagedrunsoxlint --fix+oxfmton staged files only. - pre-push —
yarn run check(lint + format + typecheck + all tests). Blocks the push if anything fails.
Runs on every PR and push to main. Three parallel-safe jobs:
- check —
lint,format --check,typecheck,test --coverage,build. - smoke — builds, starts
vinext startin the background, runs HTTP tests against all routes (both locales), proxy redirects, critical head tags, XML outputs. - vuln —
yarn npm audit --severity high --recursive.
main has branch protection requiring all three to pass before merge.
Runs only on push to main. Executes npx vinext deploy with CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID from GitHub Secrets. The production environment requires an approval click before the deploy actually runs — defense-in-depth on top of branch protection.
Dependabot opens a grouped weekly PR for npm-ecosystem updates (covers yarn-managed deps via package.json) and monthly PRs for GitHub Actions updates (see .github/dependabot.yml).
Under the hood: runs vite build + invokes wrangler deploy. The important piece is that @cloudflare/vite-plugin reads wrangler.jsonc during the build and produces the right Worker + assets configuration. Running wrangler deploy directly (without vinext's orchestration) produces an assets-only deploy with no SSR handler — see gotchas.
The three deploy-related files:
wrangler.jsonc—main,assetsbinding,imagesbinding, Custom Domains.worker/index.ts— Worker entry, delegates tovinext/server/app-router-entryplus handles/_vinext/image..github/workflows/deploy.yml— what and when.
Lessons from shipping this site that aren't obvious from the docs.
WebAssembly.instantiate()from bytes is blocked. Workers only allows WASM declared as a binding. This killed shiki's default oniguruma engine with error 1101 on every post page. Fix:createJavaScriptRegexEngine()inlib/markdown.ts.- Vite does not minify SSR/Worker bundles by default. Set
build.minify: "esbuild"per environment invite.config.ts. Without it the Worker ships ~40% more JS than necessary. - Use
vinext deploy, not rawwrangler deploy. The cloudflare-vite-plugin alone won't wire upmainfor the Worker; you get an assets-only deploy that 404s on dynamic routes. next/ogpullsresvg.wasm(~1.3 MiB, duplicated). On the Workers Free tier this consumes most of the 3 MiB gzip budget. Workers Paid ($5/mo) gives 10 MiB and solves it.next/ogdoesn't work invinext dev. Satori's native modules crash Vite's RSC dev environment. Test OG images viavinext build && vinext start.next/font/googleis a CDN runtime load. No self-hosting.
The Digital Curator
Editorial, minimalist, typography-driven.
- Zero border-radius. No visible borders — separation via tonal surface shifts.
- Dark-first.
#131313charcoal, not pure black. - Three fonts: serif (reading), sans (UI), mono (code). No display type.
- Content over chrome. Post list is the homepage. Navigation is three words.
Visual target: the reading surface of Rauch or Stripe Press, not a SaaS landing page.
MIT. Fork it, change lib/site-config.ts to your identity, and run your own blog on the same stack.