Skip to content

igorhasse/unreadable

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

64 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

unreadable

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.


Contents


Tech stack

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

Quick start

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:3000

This 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.


Project structure

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

Commands

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)

Writing a new post

  1. Create the bundle

    content/posts/my-new-post/
      pt-BR.md         # required
      en.md            # required
      cover.jpg        # optional, referenced from markdown as ./cover.jpg
    

    Slugs are shared across locales — only the language file differs. Any non-.md file in the bundle is copied to public/posts/<slug>/ at build time, and relative paths like ./diagram.png in markdown are rewritten to /posts/<slug>/diagram.png.

  2. 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.

  3. Code blocks use fenced blocks with one of the supported languages (css, tsx, typescript, yaml). Anything else falls back to plaintext. To add a language, see the next section.

  4. 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.


Adding a new code language

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 normalizeLang

The highlighter is built with createHighlighterCore + createJavaScriptRegexEngine()not the shiki singleton and not oniguruma. See Workers gotchas for why.


Site identity

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 to https://igorhasse.com)
  • VITE_MAILCHIMP_URL — newsletter form action (empty = form disabled)

Pipelines — local, CI, deploy

Three gates between editing code and serving it to users. All run automatically.

Local (git hooks via husky)

  • pre-commitlint-staged runs oxlint --fix + oxfmt on staged files only.
  • pre-pushyarn run check (lint + format + typecheck + all tests). Blocks the push if anything fails.

CI (.github/workflows/ci.yml)

Runs on every PR and push to main. Three parallel-safe jobs:

  • checklint, format --check, typecheck, test --coverage, build.
  • smoke — builds, starts vinext start in the background, runs HTTP tests against all routes (both locales), proxy redirects, critical head tags, XML outputs.
  • vulnyarn npm audit --severity high --recursive.

main has branch protection requiring all three to pass before merge.

Deploy (.github/workflows/deploy.yml)

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).

What vinext deploy actually does

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.jsoncmain, assets binding, images binding, Custom Domains.
  • worker/index.ts — Worker entry, delegates to vinext/server/app-router-entry plus handles /_vinext/image.
  • .github/workflows/deploy.yml — what and when.

Workers gotchas

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() in lib/markdown.ts.
  • Vite does not minify SSR/Worker bundles by default. Set build.minify: "esbuild" per environment in vite.config.ts. Without it the Worker ships ~40% more JS than necessary.
  • Use vinext deploy, not raw wrangler deploy. The cloudflare-vite-plugin alone won't wire up main for the Worker; you get an assets-only deploy that 404s on dynamic routes.
  • next/og pulls resvg.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/og doesn't work in vinext dev. Satori's native modules crash Vite's RSC dev environment. Test OG images via vinext build && vinext start.
  • next/font/google is a CDN runtime load. No self-hosting.

Philosophy

The Digital Curator

Editorial, minimalist, typography-driven.

  • Zero border-radius. No visible borders — separation via tonal surface shifts.
  • Dark-first. #131313 charcoal, 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.


License

MIT. Fork it, change lib/site-config.ts to your identity, and run your own blog on the same stack.

About

personal blog

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors