Skip to content

Repository files navigation

indrakusuma.web.id

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.

Setup

corepack enable   # pins pnpm to the version in `packageManager`
pnpm install

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

Commands

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

Linting

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.

Layout

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.

Blog

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/_redirects

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

Writing a new post

pnpm new-post "Judul Tulisan" --category Development

That 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".

Theming

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.

Assets

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}.webp

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

About

My brand new Personal Website ✨ Powered by Next.js and built by Claude & Codex πŸ‘Ύ

Topics

Resources

Stars

9 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages