Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ jobs:
- run: corepack enable
- run: yarn install
- run: yarn format --check
links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # 4.0.1
with:
install_args: 'node'
- run: corepack enable
- run: yarn install
- run: yarn check:links
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ node_modules
#!.yarn/cache
.pnp.*

.tanstack/
.source/
dist/
.next/
.open-next/
next-env.d.ts
.wrangler/
.dev.vars
.idea
*.tsbuildinfo

# Local env files (e.g. VITE_SENTRY_DSN for production builds). Vite loads
# `.env.production.local` automatically; keep the DSN out of this public repo.
# Local env files (e.g. NEXT_PUBLIC_SENTRY_DSN). Keep secrets out of this public repo.
.env*.local

# macOS
Expand Down
1 change: 0 additions & 1 deletion WORKS.md
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@

35 changes: 35 additions & 0 deletions app/[lang]/docs/[[...slug]]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { DocsNavbar } from '@/components/docs/DocsNavbar';
import { withExperimentalBadges } from '@/components/docs/sidebar-badges';
import { GITHUB_URL } from '@/components/home/data';
import { isLocale } from '@/lib/i18n';
import { source } from '@/lib/source';

// Docs shell (shared across every guide/reference page): the notebook layout —
// a full-width top navbar (the shared SiteHeader) with the page tree in the left
// sidebar. The changelog is a sibling route with its own standalone shell, so it
// deliberately doesn't inherit this sidebar.
export default async function DocsLayoutWrapper({
params,
children,
}: {
params: Promise<{ lang: string }>;
children: ReactNode;
}) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
const tree = withExperimentalBadges(source.getPageTree(lang));

return (
<DocsLayout
tree={tree}
tabMode="navbar"
nav={{ mode: 'top', component: <DocsNavbar /> }}
githubUrl={GITHUB_URL}
>
{children}
</DocsLayout>
);
}
60 changes: 60 additions & 0 deletions app/[lang]/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
PageLastUpdate,
} from 'fumadocs-ui/layouts/notebook/page';
import { notFound, redirect } from 'next/navigation';
import { MobileTocBar } from '@/components/docs/MobileTocBar';
import { getMDXComponents } from '@/components/mdx';
import { isLocale, localizeHref, type Locale } from '@/lib/i18n';
import { source } from '@/lib/source';

// Server-render docs on demand. The MDX bodies are bundled, so SSR is fast, and
// this avoids OpenNext's prerender-cache path — which, combined with the
// default-locale middleware rewrite (`/docs/*` → `/en/docs/*`), spins when no
// incremental cache is configured. `generateStaticParams` still drives the
// language switcher's known paths; `dynamic` opts the render itself out of SSG.
export const dynamic = 'force-dynamic';

export default async function Page({
params,
}: {
params: Promise<{ lang: string; slug?: string[] }>;
}) {
const { lang, slug } = await params;
if (!isLocale(lang)) notFound();
const locale = lang as Locale;
const slugs = slug ?? [];

// Neither `/docs` nor `/docs/guide` has a page of its own (the Guide tab has no
// index) — land readers on the Guide's first page, in their locale.
if (slugs.length === 0 || (slugs.length === 1 && slugs[0] === 'guide')) {
redirect(localizeHref('/docs/guide/getting-started', locale));
}

const page = source.getPage(slugs, locale);
if (page == null) notFound();

const MDX = page.data.body;
// `lastModified` is injected by the last-modified plugin (git commit time,
// see source.config.ts). Rendered as a localized "Last updated on" /
// "마지막 업데이트" line under the content.
const lastModified = (page.data as { lastModified?: Date | string }).lastModified;

return (
<DocsPage toc={page.data.toc} tableOfContentPopover={{ component: <MobileTocBar /> }}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={getMDXComponents(locale)} />
</DocsBody>
{lastModified != null && <PageLastUpdate date={new Date(lastModified)} />}
</DocsPage>
);
}

export function generateStaticParams() {
return source.generateParams();
}
31 changes: 31 additions & 0 deletions app/[lang]/docs/changelog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { notFound } from 'next/navigation';
import { Changelog } from '@/components/changelog/Changelog';
import { SiteHeader } from '@/components/SiteHeader';
import { getChangelog } from '@/lib/changelog-data';
import { isLocale } from '@/lib/i18n';

// The changelog is served from GitHub releases (not the MDX source), so it lives
// outside the docs notebook layout: a standalone shell with the shared SiteHeader
// (the "Changelog" tab reads as active) and no page-tree sidebar. Its own scoped
// search + package filter live in the toolbar.
//
// Rendered as an async Server Component: releases are fetched on the server at
// request time (cached per isolate + at the edge, see `getChangelog`) and handed
// to the interactive `<Changelog>` client component as data.
export const dynamic = 'force-dynamic';

export default async function ChangelogPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
if (!isLocale(lang)) notFound();

const { releases, error } = await getChangelog();

return (
<div className="flex min-h-svh flex-col bg-fd-background text-fd-foreground">
<SiteHeader className="sticky top-0" />
<main className="mx-auto w-full max-w-3xl flex-1 px-4 pt-10 pb-24 sm:px-6">
<Changelog releases={releases} error={error} />
</main>
</div>
);
}
21 changes: 21 additions & 0 deletions app/[lang]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
import { ErrorScreen } from '@/components/ErrorScreen';

// Segment error boundary for everything under `[lang]`. Renders inside the locale
// layout (providers present), reports the error to Sentry, and offers a retry.
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);

return <ErrorScreen reset={reset} />;
}
46 changes: 46 additions & 0 deletions app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { i18n, isLocale, KO_UI_TRANSLATIONS, type Locale } from '@/lib/i18n';
import { LocaleProvider } from '@/lib/locale';

// Available languages for the fumadocs search dialog / built-in switcher.
const LOCALES = [
{ name: 'English', locale: 'en' },
{ name: '한국어', locale: 'ko' },
];

// Per-locale document root. The i18n middleware rewrites every request to a
// `/<lang>/...` path (with the default locale's prefix hidden), so `[lang]` is
// always present and this layout owns `<html>`/`<body>` and the providers.
export default async function LangLayout({
params,
children,
}: {
params: Promise<{ lang: string }>;
children: ReactNode;
}) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
const locale = lang as Locale;

return (
<html lang={locale} suppressHydrationWarning={true}>
<body className="flex min-h-screen flex-col">
<RootProvider
i18n={{
locale,
locales: LOCALES,
translations: locale === 'ko' ? KO_UI_TRANSLATIONS : undefined,
}}
>
<LocaleProvider locale={locale}>{children}</LocaleProvider>
</RootProvider>
</body>
</html>
);
}

export function generateStaticParams() {
return i18n.languages.map(lang => ({ lang }));
}
8 changes: 8 additions & 0 deletions app/[lang]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NotFound } from '@/components/NotFound';

// Rendered for `notFound()` and unmatched paths under `[lang]`, inside the locale
// layout (so the providers/theme are present). `<NotFound>` infers the locale
// from the pathname to localize its copy and links.
export default function NotFoundPage() {
return <NotFound />;
}
12 changes: 12 additions & 0 deletions app/[lang]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Landing } from '@/components/home';
import { i18n } from '@/lib/i18n';

// Landing page. Locale comes from the `[lang]` segment and is provided to the
// tree by `LocaleProvider` in the layout, so `<Landing>` reads it via `useLocale`.
export default function Home() {
return <Landing />;
}

export function generateStaticParams() {
return i18n.languages.map(lang => ({ lang }));
}
33 changes: 33 additions & 0 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';

// Route handlers run on Node/workerd; force the Node runtime (never edge).
export const runtime = 'nodejs';

// Bilingual search over the fumadocs source. English uses Orama's built-in
// tokenizer. Korean can't: Orama has no Korean stemmer, and its English/default
// normalizer strips non-Latin characters — so a Korean index built with it comes
// out empty and every query returns nothing. Instead we use a lightweight
// tokenizer that lowercases and splits on whitespace + punctuation, keeping the
// Hangul intact. This is eojeol-level (word-level) matching — not morphological,
// so particles/endings aren't stripped — but it makes Korean search work with no
// extra dependency or WASM.
const koreanTokenizer = {
language: 'korean',
normalizationCache: new Map<string, string>(),
tokenize(raw: string): string[] {
if (!raw) return [];
return raw
.toLowerCase()
.split(/[\s\p{P}\p{S}]+/u)
.filter(Boolean);
},
};

export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
localeMap: {
en: 'english',
ko: { tokenizer: koreanTokenizer },
},
});
Binary file added app/apple-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import './global.css';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';

// Root error boundary: only fires when the root/[lang] layout itself throws, so
// it must render its own <html>/<body> and can't rely on the locale providers.
// Kept minimal and English-only; reports to Sentry like the segment boundary.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);

return (
<html lang="en" suppressHydrationWarning>
<body className="flex min-h-svh flex-col items-center justify-center bg-white px-6 py-24 text-center text-zinc-900 dark:bg-[#09090a] dark:text-zinc-100">
<p className="font-mono text-sm font-medium tracking-[0.2em] text-brand">500</p>
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
Something went wrong
</h1>
<p className="mt-3 max-w-md text-zinc-500 dark:text-zinc-400">
An unexpected error occurred. The issue has been reported — please try again.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={reset}
className="inline-flex items-center justify-center rounded-md bg-brand px-5 py-2.5 font-mono text-[14px] font-medium text-white transition-colors hover:bg-brand-hover"
>
Try again
</button>
<a
href="/"
className="inline-flex items-center justify-center rounded-md border border-zinc-300 px-5 py-2.5 font-mono text-[14px] font-medium text-zinc-800 transition-colors hover:border-zinc-500 dark:border-zinc-800 dark:text-zinc-200 dark:hover:border-zinc-600"
>
Go home
</a>
</div>
</body>
</html>
);
}
10 changes: 8 additions & 2 deletions src/styles.css → app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
@import '@fontsource-variable/inter/wght.css';
@import '@fontsource-variable/jetbrains-mono/wght.css';

/* Scan app source + docs content for utility classes (PostCSS build). */
@source '../app';
@source '../components';
@source '../lib';
@source '../content';

@theme inline {
--font-sans: 'Inter Variable', Inter, sans-serif;
--font-mono: 'JetBrains Mono Variable', monospace;
Expand All @@ -13,7 +19,7 @@
/* Brand accent — derived from the cube logo's blue. `brand-2` is an in-family
* violet companion (the cube's blue pushed toward indigo) used only inside
* gradients and glows, never as a flat fill; it echoes the Showcase footage's
* own blue→violet lighting. Together they give the page vite-style energy
* own blue→violet lighting. Together they give the page a lively, modern energy
* without leaving the blue family. */
@theme {
--color-brand: #2563eb;
Expand Down Expand Up @@ -101,7 +107,7 @@
}

/* Left sidebar: blend with the page base so it shares the landing's tone, with
* a single hairline divider against the content (vite.dev-style clean sidebar).
* a single hairline divider against the content (clean, minimal sidebar).
* In `nav.mode: top` fumadocs already leaves the sidebar transparent; this keeps
* it that way across themes and adds the divider. */
#nd-notebook-layout #nd-sidebar {
Expand Down
Binary file added app/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import './global.css';
import type { ReactNode } from 'react';

// The real document (`<html>`/`<body>` + providers) lives in `app/[lang]/layout.tsx`,
// which every request reaches via the i18n middleware. This root only exists to
// satisfy Next's "a root layout is required" rule and to load global styles.
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
}
Loading