Personal site of Indra Kusuma. Next.js 15 App Router with output: 'export', so
the whole site builds to static HTML for GitHub Pages. Tailwind v4 over CSS custom
properties, TypeScript strict. Fonts, icons and images are self-hosted; the only
third-party runtime code is the analytics tag.
CONTEXT.md defines the domain terms β Section, Marquee, Post β and is the place to look before naming anything new.
corepack enable # pins pnpm to the version in `packageManager`
pnpm installpnpm is not a preference here β preinstall refuses any other package manager,
and .nvmrc pins Node. Both are there because packageManager alone declared
pnpm for months while the committed lockfile was npm's.
pnpm dev |
Dev server on :3000 |
pnpm build |
Static export into out/ |
pnpm lint |
oxlint |
pnpm typecheck |
tsc --noEmit |
pnpm assets |
Rebuild every generated asset |
pnpm new-post |
Scaffold a new blog post |
pnpm assets:posts |
Vendor blog images and measure them |
pnpm assets:thumbnail |
Draw a thumbnail for a post that has no image |
oxlint replaces ESLint here, configured in .oxlintrc.json
with the nextjs, react, jsx-a11y, import and typescript plugins.
Coverage was checked rule by rule against the next/core-web-vitals config it
replaced: 68 of its 71 active rules have an oxlint equivalent β including all 21
@next/next rules, one for one β and the three that do not are inert here
(jsx-uses-react and jsx-uses-vars belong to the old JSX transform,
require-render-return to class components).
rules-of-hooks is listed explicitly in rules because it is not in oxlint's
default correctness category. Without that line only exhaustive-deps fires,
which is the more forgiving half of the pair β verified both ways.
Linting is a build step (oxlint --deny-warnings && next build) rather than
something Next runs for us, so it still gates the Netlify deploy now that
next build no longer lints. Warnings are fatal: a gate that only reports is a
gate nobody reads.
One suppression, in .oxlintrc.json with the reason next to it: Reveal.tsx
passes a ref object into createElement (it never reads .current) and sets
state in an effect as an IntersectionObserver fallback.
src/
app/ Routes. layout.tsx, page.tsx, not-found.tsx, blog/,
globals.css (tokens + keyframes), robots.ts, sitemap.ts, icons
components/ Markup β one file per Section, plus shared pieces
hooks/ React and DOM glue
lib/ Framework-free logic and content
site-data.ts Β· marquee.ts Β· posts.ts
content/posts/ Blog posts as MDX β the source of truth
scripts/ Asset generators
public/ Generated assets, _redirects, CNAME, .nojekyll
assets/ Masters β never served, only built from
The three src/ layers are ordered by what each is allowed to touch: lib/ uses
neither React nor the DOM, hooks/ adds both, components/ adds markup.
Dependencies only ever point that way. The marquee spans all three and is the
worked example β geometry in lib/marquee.ts, DOM work in hooks/useMarquee.ts,
and a caller in components/ that supplies nothing but rendering.
To change a job, an award or a capability, edit src/lib/site-data.ts only.
31 posts, migrated out of the Hexo blog that used to live on this repo's
source-code branch. content/posts/*.mdx is the source of truth; lib/posts.ts
reads it at build time, and sitemap.ts and the redirects read the same function,
so nothing can be reachable but unlisted.
The migration is recorded rather than remembered:
node scripts/migrate-posts.mjs # one-time: source-code branch β content/posts
pnpm assets:posts # remote images β public/images/posts, rewrites the MDX
pnpm assets:redirects # old Hexo permalinks β public/_redirectsmigrate-posts.mjs is deliberately not in pnpm assets: content/posts is
hand-editable now, and re-running would discard those edits. It repairs what Hexo
never validated β single-digit months, an impossible 13:05:97 β and maps Hexo's
six categories onto the five the design filters on, throwing on anything it does
not recognise.
assets:posts is safe to re-run: it fetches only URLs that are still remote, and
measures every image into public/images/posts/manifest.json. Markdown carries no
image dimensions, so the article reads them from there β an image missing from the
manifest fails the build rather than shipping a layout shift.
pnpm new-post "Judul Tulisan" --category DevelopmentThat writes content/posts/<slug>.mdx with the frontmatter filled in and
draft: true, and prints what is left to do. --slug overrides the URL when the
title makes a poor one. Inside Claude Code, /write-a-article does the same and
then helps write.
Drafts are served by pnpm dev at their real URL and skipped by
pnpm build, so a post can exist and be previewed before it has a thumbnail β
which is what would otherwise block the build the moment the file appeared. (A
draft missing from dev usually means a .next left by a production build:
rm -rf .next.)
To publish: set thumbnail, add tags if you want them, delete
draft: true.
A post with no image of its own can have one drawn for it:
pnpm assets:thumbnail <slug>It renders the title and category over the brand gradient in the site's own
faces, writes public/images/posts/<slug>-thumb.webp, and points the post at it.
Drawn at the index card's 16/11 with padding that keeps everything inside a
social card's 1.91:1 crop too, so the same file serves both. It refuses to
overwrite a thumbnail that is already set.
next/og does the rendering β satori underneath, which emits text as vector
paths, so the result does not depend on the fonts of whatever machine runs it.
The two faces it needs are vendored under assets/fonts/ (OFL); see the README
there for how they were obtained and why they are static instances. Four fields are required of a published post, and getting one
wrong stops the build and names the file:
---
title: 'Judul Tulisan'
date: '2026-08-29' # anything Date can parse
category: Development # Development Β· Story Β· Tutorial Β· Kuliah Β· Tips
thumbnail: '/images/posts/x.webp' # or a remote URL, then `pnpm assets:posts`
---excerpt and readingMinutes are optional β without them the first paragraph
becomes the excerpt and the reading time is counted from the body. Write them
only to override.
Nothing else to run. legacyPath belongs to the migrated posts alone, so a new
post gets no redirect; the index, the sitemap and the article's "next" link all
read the same getPosts, so they pick it up on the next build.
Code fences are highlighted by Shiki through rehype-pretty-code, at build time
β the pages are static, so no highlighter reaches the browser. Both themes are
written onto every token as CSS variables and globals.css picks one from
data-theme, which is how a code block re-colours on the theme toggle without a
second render. The block keeps the design's --code surface rather than the
theme's own, and a fence with no language is left as plain monospace.
Posts are in Bahasa Indonesia on an otherwise English site; each article carries
lang="id".
data-theme on <html> selects the palette. Tokens live in globals.css and
reach Tailwind through @theme inline, so bg-surface / text-muted /
border-line re-theme themselves. An inline script in layout.tsx applies the
stored choice β or the OS preference when there is none β before first paint, so
there is no flash. The nav toggle writes localStorage.ik-theme; until someone
toggles it, the site keeps following the OS.
Everything under public/logos/, public/profile.*, public/logo-*.webp and
src/app/{icon,icon1,apple-icon}.png is generated from a committed master in
assets/. Both sides are in git, so pnpm build never needs these β re-run
after replacing a master:
pnpm assets # all of them, in order
pnpm assets:logos # assets/logos/* β public/logos/companies
pnpm assets:profile # assets/profile-source.png β public/profile.{webp,jpg}
pnpm assets:favicon # assets/favicon-source.png β src/app/{icon,icon1,apple-icon}.png
pnpm assets:mark # assets/logo-{light,dark}-source.png β public/logo-{light,dark}.webpThey are deterministic: pnpm assets on an unchanged tree leaves git status
clean, so an unexpected diff means a master actually moved.
Company logos. Drop the image in assets/logos/ named after the company and
run pnpm assets:logos. Badges are 40 CSS px, which is unforgiving, so each
source is classified and treated accordingly:
| Source | Detected by | Treatment |
|---|---|---|
| Mark on a white card | Light corner pixel | White trimmed off, consistent margin added back |
| Full-bleed brand tile | Dark or coloured corner pixel | Left alone to fill the badge edge to edge |
| Horizontal wordmark | Trimmed aspect ratio > 2 | Side margin dropped to use the full width |
Logo mark. Ships as two artworks rather than one file recoloured by CSS, each
with its own art-directed gradients. The masters differ in canvas size and
padding, so assets:mark trims each to its artwork and letterboxes both into one
shared box β otherwise the mark would shift and resize on every theme change.
.ik-mark paints the result as a background, so only the variant matching
data-theme is ever fetched.
Each script's header documents the rest of its reasoning.