Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dist/
.wrangler/
.dev.vars
.idea
*.env

# 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.
Expand Down
5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
"fumadocs-core": "16.10.7",
"fumadocs-mdx": "15.0.13",
"fumadocs-ui": "16.10.7",
"garu-ko": "0.9.7",
"garu-orama-tokenizer": "0.4.1",
"jose": "6.2.3",
"react": "19.2.7",
"react-dom": "19.2.7",
Expand All @@ -55,8 +53,5 @@
"vite": "8.1.3",
"wrangler": "4.107.0"
},
"resolutions": {
"garu-ko": "0.9.7"
},
"packageManager": "yarn@4.17.0"
}
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-96x96.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon.ico
Binary file not shown.
Binary file added public/web-app-manifest-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/web-app-manifest-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 0 additions & 25 deletions src/garu-runtime.d.ts

This file was deleted.

96 changes: 96 additions & 0 deletions src/layouts/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as Sentry from '@sentry/tanstackstart-react';
import { type ErrorComponentProps, useRouterState } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import { localeFromPathname, localizeHref, LocaleProvider, useLocale } from '../lib/locale';
import { useUiStrings } from '../lib/ui-strings';
import { SiteHeader } from './SiteHeader';

// Global error page. Rendered by the router's `defaultErrorComponent`, so it
// catches anything a route's render or loader throws — including the docs
// loaders — and, like `NotFound`, takes its locale from the path so a reader who
// broke on `/ko/...` stays in Korean.
export function ErrorPage({ error, info, reset }: ErrorComponentProps) {
const pathname = useRouterState({ select: state => state.location.pathname });
const locale = localeFromPathname(pathname);

return (
<LocaleProvider locale={locale}>
<div className="flex min-h-svh flex-col bg-fd-background text-fd-foreground">
<SiteHeader className="sticky top-0" />
<main className="flex flex-1 flex-col items-center justify-center px-6 py-24 text-center">
<ErrorBody error={error} info={info} reset={reset} />
</main>
</div>
</LocaleProvider>
);
}

function ErrorBody({ error, info, reset }: ErrorComponentProps) {
const t = useUiStrings();
const locale = useLocale();
const [eventId, setEventId] = useState<string>();
const reported = useRef<unknown>(undefined);

// Report to Sentry once per error instance — the ref survives the re-renders
// and remounts that `reset` triggers, so a reader retrying a persistent
// failure doesn't send the same event repeatedly. `info.componentStack` is
// attached under the `react` context, the same key Sentry's React integration
// uses, so the stack shows up where the UI expects it.
//
// Effects never run during SSR, so this fires on the client only. That is the
// point: an error the router catches and renders here never propagates out of
// the server handler, so the Sentry request/function middleware (src/start.ts)
// never sees it. This is the report.
useEffect(() => {
if (reported.current === error) return;
reported.current = error;
setEventId(
Sentry.captureException(error, {
tags: { boundary: 'router', locale },
contexts: { react: { componentStack: info?.componentStack } },
})
);
}, [error, info, locale]);

return (
<>
<p className="font-mono text-sm font-medium tracking-[0.2em] text-brand uppercase">
{t.error.eyebrow}
</p>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-fd-foreground sm:text-4xl">
{t.error.title}
</h1>
<p className="mt-3 max-w-md text-fd-muted-foreground">{t.error.message}</p>

{/* The raw message is useful while developing and noise (or a leak) in
production, where the error ID is what we'd actually ask a reader for. */}
{import.meta.env.DEV && (
<pre className="mt-6 max-w-xl overflow-x-auto rounded-md border border-zinc-300 bg-fd-muted/50 p-4 text-left font-mono text-xs text-fd-muted-foreground dark:border-zinc-800">
{error.message}
</pre>
)}

<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={reset}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
className="inline-flex items-center justify-center gap-2 rounded-md bg-brand px-5 py-2.5 font-mono text-[14px] font-medium text-white transition-colors hover:bg-brand-hover"
>
{t.error.retry}
</button>
<a
href={localizeHref('/', locale)}
className="inline-flex items-center justify-center gap-2 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"
>
{t.error.home}
</a>
</div>

{eventId != null && (
<p className="mt-8 font-mono text-xs text-fd-muted-foreground">
{t.error.eventId}: <span className="select-all">{eventId}</span>
</p>
)}
</>
);
}
168 changes: 168 additions & 0 deletions src/lib/korean-tokenizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { Tokenizer } from '@orama/orama';
import { tokenizer as oramaTokenizer } from '@orama/orama/components';

// A dependency-free Korean tokenizer for Orama.
//
// Orama has no `ko` language (its default map would throw "Language 'ko' is not
// supported"), and a real morphological analyzer (garu-ko) costs ~1MB of WASM +
// model in the Worker bundle to serve a docs-sized corpus. Two cheap properties
// replace it, because Korean glues its particles (조사) and endings (어미) onto
// the *end* of a word:
//
// 1. Orama's index is a radix tree and matches by prefix, so an indexed
// `설치하세요` is already found by the query `설치`, and `번들을` by `번들`.
// Inflection in the document therefore costs us nothing.
// 2. The reverse — a reader who *types* the inflected form, `번들을`, looking
// for a page that says `번들` — is what this tokenizer adds: it strips a
// trailing particle or ending, so both sides also index the bare stem.
//
// Tokens are additive (the raw word is always kept), so stripping can only add
// recall, never remove a match.
//
// Deliberately *not* done: character bigrams (the usual CJK trick). They buy
// mid-word matches, which Korean docs rarely need since they space their words,
// and they cost real precision — `오프라인` would rank `파이프라인` first, on the
// strength of the shared `프라` and `라인`.

// Precomposed Hangul syllables (가-힣). Jamo-only text (ㄱ, ㅏ) is rare outside
// IME state and is left to the base tokenizer.
const HANGUL_RUN = /[가-힣]+/g;

// Particles and endings, longest-first at build time so `번들에서는` strips
// `에서는` rather than the shorter `는`. Not exhaustive, and it doesn't need to
// be: this only has to catch what a reader is likely to type into a search box.
const SUFFIXES = [
// 조사 (particles)
'으로부터',
'에서부터',
'로부터',
'에게서',
'한테서',
'에서는',
'에서도',
'에서의',
'으로서',
'으로써',
'으로는',
'으로도',
'이라는',
'이라고',
'에게는',
'에게도',
'에서',
'에게',
'한테',
'께서',
'으로',
'까지',
'부터',
'처럼',
'보다',
'마다',
'조차',
'밖에',
'이나',
'라는',
'라고',
'와는',
'과는',
'에는',
'에도',
'에만',
'들의',
'들을',
'들이',
'들은',
'은',
'는',
'이',
'가',
'을',
'를',
'에',
'의',
'와',
'과',
'도',
'만',
'로',
'들',
// 어미 (verb / adjective endings)
'하였습니다',
'했습니다',
'됐습니다',
'있습니다',
'없습니다',
'되었다',
'합니다',
'됩니다',
'입니다',
'습니다',
'해주세요',
'하십시오',
'하세요',
'하려면',
'하는',
'하고',
'하며',
'하면',
'하여',
'해서',
'했다',
'하지',
'하기',
'되는',
'되고',
'된다',
'되면',
'되어',
'있는',
'없는',
'한다',
'했던',
'었다',
'았다',
'였다',
].sort((a, b) => b.length - a.length);

// Stems shorter than this are dropped: a 1-character stem (`참고` → `참`) is far
// more likely to be an over-strip than a useful term.
const MIN_STEM = 2;

// The stem of a Hangul word, or undefined when no suffix applies.
function stem(word: string): string | undefined {
for (const suffix of SUFFIXES) {
if (word.length - suffix.length >= MIN_STEM && word.endsWith(suffix)) {
return word.slice(0, -suffix.length);
}
}
return undefined;
}

function koreanTokens(raw: string): string[] {
const tokens: string[] = [];
for (const [word] of raw.normalize('NFC').matchAll(HANGUL_RUN)) {
tokens.push(word);
const root = stem(word);
if (root != null) {
tokens.push(root);
}
}
return tokens;
}

// Korean pages here are dense with English API names, code identifiers, and
// numbers (`.wvb`, `createBundle`, `v1`). Orama's English tokenizer treats
// Hangul as a separator, so it yields exactly those Latin tokens — stopword
// filtered — and we union them with the Korean ones.
export function createKoreanTokenizer(): Tokenizer {
const base = oramaTokenizer.createTokenizer({ language: 'english' });

return {
language: 'korean',
normalizationCache: new Map<string, string>(),
tokenize(raw: string, language?: string, prop?: string, withCache?: boolean): string[] {
return [...new Set([...base.tokenize(raw, language, prop, withCache), ...koreanTokens(raw)])];
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
},
};
}
26 changes: 26 additions & 0 deletions src/lib/ui-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export interface UiStrings {
bundleFormatLink: string;
};
notFound: { title: string; message: string; home: string; docs: string };
error: {
eyebrow: string;
title: string;
message: string;
retry: string;
home: string;
eventId: string;
};
}

const en: UiStrings = {
Expand Down Expand Up @@ -147,6 +155,15 @@ const en: UiStrings = {
home: 'Go home',
docs: 'Browse docs',
},
error: {
eyebrow: 'Error',
title: 'Something went wrong',
message:
'This page failed to load. The error has been reported to us — retrying often clears it.',
retry: 'Try again',
home: 'Go home',
eventId: 'Error ID',
},
};

const ko: UiStrings = {
Expand Down Expand Up @@ -250,6 +267,15 @@ const ko: UiStrings = {
home: '홈으로',
docs: '문서 보기',
},
error: {
eyebrow: '오류',
title: '문제가 발생했습니다',
message:
'페이지를 불러오지 못했습니다. 오류는 자동으로 보고되었으며, 다시 시도하면 해결되는 경우가 많습니다.',
retry: '다시 시도',
home: '홈으로',
eventId: '오류 ID',
},
};

const DICT: Record<Locale, UiStrings> = { en, ko };
Expand Down
2 changes: 2 additions & 0 deletions src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Sentry from '@sentry/tanstackstart-react';
import { createRouter } from '@tanstack/react-router';
import { ErrorPage } from './layouts/ErrorPage';
import { NotFound } from './layouts/NotFound';
import { routeTree } from './routeTree.gen';

Expand All @@ -8,6 +9,7 @@ export function getRouter() {
routeTree,
scrollRestoration: true,
defaultNotFoundComponent: NotFound,
defaultErrorComponent: ErrorPage,
});

if (!router.isServer) {
Expand Down
Loading
Loading