Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* App root: Locale -> Theme -> LocaleScope -> Router provider composition, same as
|
||||
* the landing page (LocaleScope remounts the tree keyed by locale so every `S.x`
|
||||
* read reflects the active language).
|
||||
*/
|
||||
import { LocaleProvider, LocaleScope } from "./state/locale";
|
||||
import { ThemeProvider } from "./state/theme";
|
||||
import { AppRouter } from "./router";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<ThemeProvider>
|
||||
<LocaleScope>
|
||||
<AppRouter />
|
||||
</LocaleScope>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* "Copy Markdown" button: puts the page's Markdown source on the clipboard with a
|
||||
* transient "copied" state — so a page can be pasted into a model context, an issue
|
||||
* or a note as clean Markdown rather than rendered HTML.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { S } from "../lib/strings";
|
||||
import { CheckIcon, CopyIcon } from "./icons";
|
||||
|
||||
export function CopyMarkdownButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Clipboard API unavailable (e.g. non-secure context): fall back to a hidden textarea.
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
ta.remove();
|
||||
}
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCopy()}
|
||||
title={copied ? S.doc.copied : S.doc.copyMarkdown}
|
||||
aria-label={copied ? S.doc.copied : S.doc.copyMarkdown}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? S.doc.copied : S.doc.copyMarkdown}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Slim docs footer: copyright + repo / license / main-site links on one line. */
|
||||
import { S } from "../lib/strings";
|
||||
import { LICENSE_URL, REPO_URL, SITE_URL } from "../lib/links";
|
||||
|
||||
export function Footer() {
|
||||
const link = "transition-colors hover:text-gray-900 dark:hover:text-gray-100 whitespace-nowrap";
|
||||
return (
|
||||
<footer className="border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-6 text-xs text-gray-400 sm:px-6 dark:text-gray-500">
|
||||
<p>{S.footer.copyright}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<a href={SITE_URL} className={link}>
|
||||
{S.footer.site}
|
||||
</a>
|
||||
<a href={REPO_URL} target="_blank" rel="noreferrer" className={link}>
|
||||
{S.footer.repo}
|
||||
</a>
|
||||
<a href={LICENSE_URL} target="_blank" rel="noreferrer" className={link}>
|
||||
{S.footer.license}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Inline icon set (lucide-style 24x24 stroke icons + the GitHub mark), the subset of
|
||||
* the landing page's set that the docs UI needs. Kept local so the docs site has zero
|
||||
* icon dependencies; all icons inherit currentColor.
|
||||
*/
|
||||
import type { ReactNode, SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
function Icon({ children, ...props }: IconProps & { children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.72-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.18-3.09-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.9 10.9 0 0 1 5.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.24 2.76.12 3.05.74.81 1.18 1.83 1.18 3.09 0 4.41-2.69 5.38-5.25 5.67.41.35.78 1.05.78 2.12 0 1.53-.02 2.76-.02 3.14 0 .3.21.67.8.55A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function XIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRightIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobeIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalLinkIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Language menu: 中文 / English / follow system, persisted via the locale context.
|
||||
* A small dropdown (globe + current label); closes on outside click or selection.
|
||||
* Scroll position across the locale remount is preserved by LocaleScope.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocale } from "../state/locale";
|
||||
import type { LangPref } from "../state/locale";
|
||||
import { S } from "../lib/strings";
|
||||
import { CheckIcon, ChevronDownIcon, GlobeIcon } from "./icons";
|
||||
|
||||
export function LangToggle() {
|
||||
const { lang, setLang } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [open]);
|
||||
|
||||
const OPTIONS: Array<{ value: LangPref; label: string }> = [
|
||||
{ value: "en", label: S.lang.en },
|
||||
{ value: "zh", label: S.lang.zh },
|
||||
{ value: "system", label: S.lang.system },
|
||||
];
|
||||
const current = OPTIONS.find((o) => o.value === lang) ?? OPTIONS[2]!;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title={S.lang.label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-transparent px-2.5 text-sm text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<GlobeIcon className="h-[18px] w-[18px]" />
|
||||
<span className="hidden sm:inline">{current.label}</span>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="anim-fade absolute right-0 z-40 mt-1 w-36 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={lang === o.value}
|
||||
onClick={() => {
|
||||
setLang(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{o.label}
|
||||
{lang === o.value && (
|
||||
<CheckIcon className="h-3.5 w-3.5 text-brand-600 dark:text-brand-300" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Sticky top bar: logo + site name + "Docs" badge, then (right) a link back to the
|
||||
* main site, GitHub, language/theme toggles and — on small screens — the sidebar
|
||||
* toggle. The sidebar itself lives in the layout (router.tsx); this bar only flips
|
||||
* its open state.
|
||||
*/
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { REPO_URL, SITE_URL } from "../lib/links";
|
||||
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LangToggle } from "./lang-toggle";
|
||||
|
||||
export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMenu: () => void }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white/85 backdrop-blur dark:border-gray-800 dark:bg-gray-950/85">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center gap-2 px-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMenu}
|
||||
aria-label={menuOpen ? S.nav.closeMenu : S.nav.openMenu}
|
||||
aria-expanded={menuOpen}
|
||||
className="mr-1 inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 lg:hidden dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900"
|
||||
>
|
||||
{menuOpen ? <XIcon className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src={`${import.meta.env.BASE_URL}penguin-logo.svg`} alt="" className="h-7 w-7" />
|
||||
<span className="text-[15px] font-semibold tracking-tight">{S.siteName}</span>
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:border-brand-900 dark:bg-brand-950 dark:text-brand-300">
|
||||
{S.docsBadge}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<a
|
||||
href={SITE_URL}
|
||||
className="hidden rounded-md px-2.5 py-1.5 text-sm text-gray-600 transition-colors hover:text-gray-900 sm:inline-block dark:text-gray-400 dark:hover:text-gray-100"
|
||||
>
|
||||
{S.nav.home}
|
||||
</a>
|
||||
<LangToggle />
|
||||
<ThemeToggle />
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={S.nav.github}
|
||||
aria-label={S.nav.github}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<GitHubIcon className="h-[18px] w-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Docs sidebar: sections + page links from DOCS_NAV, titles resolved from the active
|
||||
* locale's frontmatter. Desktop: sticky column. Mobile: the layout renders it as an
|
||||
* overlay panel under the top bar; onNavigate closes that panel.
|
||||
*/
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { useLocale } from "../state/locale";
|
||||
import { DOCS_NAV, HOME_SLUG } from "../lib/nav";
|
||||
import { docTitle } from "../lib/docs";
|
||||
|
||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { locale } = useLocale();
|
||||
const { pathname } = useLocation();
|
||||
const activeSlug = pathname.replace(/^\/|\/$/g, "") || HOME_SLUG;
|
||||
|
||||
return (
|
||||
<nav aria-label="Docs" className="text-sm">
|
||||
{DOCS_NAV.map((section) => (
|
||||
<div key={section.id} className="mb-6">
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-gray-400 uppercase dark:text-gray-500">
|
||||
{S.sections[section.id]}
|
||||
</p>
|
||||
<ul className="space-y-0.5 border-l border-gray-200 dark:border-gray-800">
|
||||
{section.slugs.map((slug) => {
|
||||
const active = slug === activeSlug;
|
||||
return (
|
||||
<li key={slug}>
|
||||
<Link
|
||||
to={slug === HOME_SLUG ? "/" : `/${slug}`}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`-ml-px block border-l py-1 pl-3 transition-colors ${
|
||||
active
|
||||
? "border-brand-600 font-medium text-brand-700 dark:border-brand-400 dark:text-brand-300"
|
||||
: "border-transparent text-gray-600 hover:border-gray-300 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{docTitle(slug, locale)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Theme cycle button: light -> dark -> system, icon reflects the current mode. */
|
||||
import { useTheme } from "../state/theme";
|
||||
import type { ThemeMode } from "../state/theme";
|
||||
import { S } from "../lib/strings";
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "./icons";
|
||||
|
||||
const NEXT: Record<ThemeMode, ThemeMode> = { light: "dark", dark: "system", system: "light" };
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { mode, setMode } = useTheme();
|
||||
const label = mode === "light" ? S.theme.light : mode === "dark" ? S.theme.dark : S.theme.system;
|
||||
const IconCmp = mode === "light" ? SunIcon : mode === "dark" ? MoonIcon : MonitorIcon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(NEXT[mode])}
|
||||
title={`${S.theme.label}: ${label}`}
|
||||
aria-label={`${S.theme.label}: ${label}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-gray-600 transition-colors hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<IconCmp className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Docs index: local Markdown pages imported at build time via import.meta.glob.
|
||||
* File naming: content/<slug>.<lang>.md — one file per page per language; a page
|
||||
* missing the active language falls back to the other one, so navigation is always
|
||||
* complete in both locales. Same architecture as the landing page blog.
|
||||
*/
|
||||
import { parseFrontmatter } from "./frontmatter";
|
||||
import type { Locale } from "../state/locale";
|
||||
|
||||
export interface DocPage {
|
||||
slug: string;
|
||||
lang: Locale;
|
||||
title: string;
|
||||
/** One-line summary rendered under the title (optional). */
|
||||
description: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const files = import.meta.glob("../../content/*.md", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
function toDoc(path: string, raw: string): DocPage | null {
|
||||
const file = path.split("/").pop() ?? "";
|
||||
const match = /^(.+)\.(zh|en)\.md$/.exec(file);
|
||||
if (!match) return null;
|
||||
const { meta, body } = parseFrontmatter(raw);
|
||||
return {
|
||||
slug: match[1]!,
|
||||
lang: match[2] as Locale,
|
||||
title: meta.title ?? match[1]!,
|
||||
description: meta.description ?? "",
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const ALL: DocPage[] = Object.entries(files)
|
||||
.map(([path, raw]) => toDoc(path, raw))
|
||||
.filter((doc): doc is DocPage => doc !== null);
|
||||
|
||||
/** The locale's version of a page (fallback to the other language). */
|
||||
export function getDoc(slug: string, locale: Locale): DocPage | undefined {
|
||||
const candidates = ALL.filter((doc) => doc.slug === slug);
|
||||
return candidates.find((doc) => doc.lang === locale) ?? candidates[0];
|
||||
}
|
||||
|
||||
/** Localized page title for sidebar / pagination labels. */
|
||||
export function docTitle(slug: string, locale: Locale): string {
|
||||
return getDoc(slug, locale)?.title ?? slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page as plain Markdown (title heading + body) — what the per-page
|
||||
* "Copy Markdown" button puts on the clipboard.
|
||||
*/
|
||||
export function docMarkdown(doc: DocPage): string {
|
||||
return `# ${doc.title}\n\n${doc.body}\n`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Minimal frontmatter parser for doc pages: a leading `---` block of `key: value`
|
||||
* lines (values may contain colons; quotes optional). Kept dependency-free and pure
|
||||
* so it is unit-testable without Vite. Same format as the landing page blog.
|
||||
*/
|
||||
|
||||
export interface Frontmatter {
|
||||
meta: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(raw: string): Frontmatter {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
|
||||
if (!match) return { meta: {}, body: normalized.trim() };
|
||||
const meta: Record<string, string> = {};
|
||||
for (const line of match[1]!.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
let value = line.slice(idx + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) meta[key] = value;
|
||||
}
|
||||
return { meta, body: normalized.slice(match[0].length).trim() };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** External links and language-independent constants used across the docs site. */
|
||||
|
||||
export const REPO_URL = "https://github.com/Prism-Shadow/penguin-harness";
|
||||
export const LICENSE_URL = `${REPO_URL}/blob/main/LICENSE`;
|
||||
|
||||
/**
|
||||
* The main site sits one level above the docs (both ship in one GitHub Pages
|
||||
* artifact: landing at "/<repo>/", docs at "/<repo>/docs/"). In local dev the docs
|
||||
* base is "/" so this resolves to the docs root itself — the landing page runs on
|
||||
* its own dev server there.
|
||||
*/
|
||||
export const SITE_URL = import.meta.env.BASE_URL.replace(/docs\/$/, "");
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Docs navigation: the single source of truth for sidebar sections, page order and
|
||||
* prev/next pagination. Section labels live in the strings dictionaries (S.sections);
|
||||
* page titles come from each Markdown file's frontmatter. Kept pure (no import.meta)
|
||||
* so the content-integrity test can import it under plain node.
|
||||
*/
|
||||
|
||||
export interface DocsSectionDef {
|
||||
/** Section id — also the key into S.sections for the localized label. */
|
||||
id: "start" | "design" | "guides" | "reference";
|
||||
/** Page slugs in display order; content files are content/<slug>.<zh|en>.md. */
|
||||
slugs: string[];
|
||||
}
|
||||
|
||||
export const DOCS_NAV: DocsSectionDef[] = [
|
||||
{ id: "start", slugs: ["introduction", "installation", "quickstart"] },
|
||||
{
|
||||
id: "design",
|
||||
slugs: [
|
||||
"architecture",
|
||||
"omni-message",
|
||||
"agent-loop",
|
||||
"message-flow",
|
||||
"interfaces",
|
||||
"tools",
|
||||
"skills",
|
||||
"models",
|
||||
"sessions-and-traces",
|
||||
],
|
||||
},
|
||||
{ id: "guides", slugs: ["web-app", "self-improvement"] },
|
||||
{ id: "reference", slugs: ["cli", "server-api", "configuration"] },
|
||||
];
|
||||
|
||||
/** All slugs in display order (pagination order). */
|
||||
export const DOC_SLUGS: string[] = DOCS_NAV.flatMap((section) => section.slugs);
|
||||
|
||||
/** The docs landing page ("/" renders this slug). */
|
||||
export const HOME_SLUG = DOC_SLUGS[0]!;
|
||||
|
||||
export function sectionOf(slug: string): DocsSectionDef | undefined {
|
||||
return DOCS_NAV.find((section) => section.slugs.includes(slug));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** English dictionary for the docs UI (same shape as `zh` in strings.ts). */
|
||||
import type { Strings } from "./strings";
|
||||
|
||||
export const en: Strings = {
|
||||
siteName: "PenguinHarness",
|
||||
docsBadge: "Docs",
|
||||
|
||||
nav: {
|
||||
home: "Website",
|
||||
github: "GitHub",
|
||||
openMenu: "Open navigation",
|
||||
closeMenu: "Close navigation",
|
||||
},
|
||||
|
||||
theme: {
|
||||
label: "Theme",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
system: "System",
|
||||
},
|
||||
|
||||
lang: {
|
||||
label: "Language",
|
||||
zh: "中文",
|
||||
en: "English",
|
||||
system: "System",
|
||||
},
|
||||
|
||||
sections: {
|
||||
start: "Get Started",
|
||||
design: "Core Design",
|
||||
guides: "Guides",
|
||||
reference: "Reference",
|
||||
} as Record<string, string>,
|
||||
|
||||
doc: {
|
||||
toc: "On this page",
|
||||
copyMarkdown: "Copy Markdown",
|
||||
copied: "Copied",
|
||||
prev: "Previous",
|
||||
next: "Next",
|
||||
notFound: "Page not found",
|
||||
backHome: "Back to docs home",
|
||||
},
|
||||
|
||||
footer: {
|
||||
repo: "GitHub repository",
|
||||
license: "Apache-2.0 License",
|
||||
site: "Website",
|
||||
copyright: "© 2026 Prism Shadow · Open source under Apache-2.0",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Docs UI copy (bilingual): this file holds the Chinese dictionary `zh` and the runtime
|
||||
* active dictionary `S`; the English dictionary lives in strings-en.ts (constrained to
|
||||
* the same shape by the `Strings` type). Locale switching is handled by state/locale.tsx,
|
||||
* which calls `setActiveStrings` and remounts the tree keyed by locale — keep `S.x`
|
||||
* reads inside components. Doc page bodies are Markdown files under content/, not here.
|
||||
*/
|
||||
export const zh = {
|
||||
siteName: "PenguinHarness",
|
||||
docsBadge: "Docs",
|
||||
|
||||
nav: {
|
||||
home: "产品主页",
|
||||
github: "GitHub",
|
||||
openMenu: "打开目录",
|
||||
closeMenu: "关闭目录",
|
||||
},
|
||||
|
||||
theme: {
|
||||
label: "主题",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
system: "跟随系统",
|
||||
},
|
||||
|
||||
lang: {
|
||||
label: "语言",
|
||||
zh: "中文",
|
||||
en: "English",
|
||||
system: "跟随系统",
|
||||
},
|
||||
|
||||
sections: {
|
||||
start: "开始",
|
||||
design: "核心设计",
|
||||
guides: "使用指南",
|
||||
reference: "参考",
|
||||
} as Record<string, string>,
|
||||
|
||||
doc: {
|
||||
toc: "本页目录",
|
||||
copyMarkdown: "复制 Markdown",
|
||||
copied: "已复制",
|
||||
prev: "上一页",
|
||||
next: "下一页",
|
||||
notFound: "页面不存在",
|
||||
backHome: "返回文档首页",
|
||||
},
|
||||
|
||||
footer: {
|
||||
repo: "GitHub 仓库",
|
||||
license: "Apache-2.0 License",
|
||||
site: "产品主页",
|
||||
copyright: "© 2026 Prism Shadow · 基于 Apache-2.0 协议开源",
|
||||
},
|
||||
};
|
||||
|
||||
/** Dictionary shape (constrains the English dictionary so keys line up). */
|
||||
export type Strings = typeof zh;
|
||||
|
||||
/**
|
||||
* Runtime active dictionary (live binding): the locale Provider calls setActiveStrings
|
||||
* to switch before render, and remounts the whole tree keyed by locale so every `S.x`
|
||||
* read reflects the current language.
|
||||
*/
|
||||
export let S: Strings = zh;
|
||||
|
||||
export function setActiveStrings(next: Strings): void {
|
||||
S = next;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Table-of-contents helpers: extract ##/### headings from a Markdown body (skipping
|
||||
* fenced code blocks) and slugify them the same way the rendered headings do, so TOC
|
||||
* anchors and heading ids always match. Pure and unit-testable; same behavior as the
|
||||
* landing page blog.
|
||||
*/
|
||||
|
||||
export interface TocEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
depth: 2 | 3;
|
||||
}
|
||||
|
||||
/** Heading text -> anchor id (keeps CJK, lowercases latin, hyphenates spaces). */
|
||||
export function slugifyHeading(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
export function extractToc(body: string): TocEntry[] {
|
||||
const entries: TocEntry[] = [];
|
||||
let inFence = false;
|
||||
for (const line of body.split("\n")) {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
const match = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
|
||||
if (!match) continue;
|
||||
const text = match[2]!;
|
||||
entries.push({
|
||||
id: slugifyHeading(text),
|
||||
text,
|
||||
depth: match[1]!.length === 2 ? 2 : 3,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Docs entry point: mounts the React root component. */
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
import "./styles.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("#root mount point not found");
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Doc page: renders the Markdown body (react-markdown + GFM) in .md-body style with a
|
||||
* sticky "on this page" TOC on wide screens, a per-page Copy Markdown button, and
|
||||
* prev/next pagination following the sidebar order. Headings get slug ids (same
|
||||
* slugifier as the TOC) and the active section is tracked while scrolling — the same
|
||||
* mechanics as the landing page blog. Internal links written as "/<slug>" navigate
|
||||
* client-side; external links open in a new tab.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { useLocale } from "../state/locale";
|
||||
import { docMarkdown, docTitle, getDoc } from "../lib/docs";
|
||||
import { DOC_SLUGS, HOME_SLUG, sectionOf } from "../lib/nav";
|
||||
import { extractToc, slugifyHeading } from "../lib/toc";
|
||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
import { ArrowRightIcon } from "../components/icons";
|
||||
|
||||
/** Flatten react-markdown heading children to plain text for slugging. */
|
||||
function nodeText(node: ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(nodeText).join("");
|
||||
if (typeof node === "object" && "props" in node) {
|
||||
return nodeText((node as { props: { children?: ReactNode } }).props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function Toc({
|
||||
entries,
|
||||
activeId,
|
||||
onNavigate,
|
||||
}: {
|
||||
entries: ReturnType<typeof extractToc>;
|
||||
activeId: string;
|
||||
onNavigate: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden xl:block">
|
||||
<nav
|
||||
className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto"
|
||||
aria-label={S.doc.toc}
|
||||
>
|
||||
<p className="text-xs font-semibold tracking-wide text-gray-400 uppercase dark:text-gray-500">
|
||||
{S.doc.toc}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 border-l border-gray-200 text-sm dark:border-gray-800">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<a
|
||||
href={`#${entry.id}`}
|
||||
onClick={() => onNavigate(entry.id)}
|
||||
className={`-ml-px block border-l py-0.5 transition-colors ${
|
||||
entry.depth === 3 ? "pl-6" : "pl-3"
|
||||
} ${
|
||||
activeId === entry.id
|
||||
? "border-brand-600 font-medium text-brand-700 dark:border-brand-400 dark:text-brand-300"
|
||||
: "border-transparent text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{entry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({ slug }: { slug: string }) {
|
||||
const { locale } = useLocale();
|
||||
const index = DOC_SLUGS.indexOf(slug);
|
||||
if (index === -1) return null;
|
||||
const prev = index > 0 ? DOC_SLUGS[index - 1]! : null;
|
||||
const next = index < DOC_SLUGS.length - 1 ? DOC_SLUGS[index + 1]! : null;
|
||||
const card = (target: string, dir: "prev" | "next") => (
|
||||
<Link
|
||||
to={target === HOME_SLUG ? "/" : `/${target}`}
|
||||
className={`group flex flex-col gap-1 rounded-xl border border-gray-200 p-4 transition-colors hover:border-gray-300 dark:border-gray-800 dark:hover:border-gray-700 ${
|
||||
dir === "next" ? "items-end text-right sm:col-start-2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{dir === "prev" && <ArrowRightIcon className="h-3 w-3 rotate-180" />}
|
||||
{dir === "prev" ? S.doc.prev : S.doc.next}
|
||||
{dir === "next" && <ArrowRightIcon className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="text-sm font-medium group-hover:text-brand-700 dark:group-hover:text-brand-300">
|
||||
{docTitle(target, locale)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div className="mt-10 grid gap-3 border-t border-gray-200 pt-6 sm:grid-cols-2 dark:border-gray-800">
|
||||
{prev && card(prev, "prev")}
|
||||
{next && card(next, "next")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Internal "/<slug>" links -> client-side navigation; external links -> new tab. */
|
||||
function MdLink({ href = "", children }: { href?: string; children?: ReactNode }) {
|
||||
if (href.startsWith("/")) {
|
||||
return <Link to={href}>{children}</Link>;
|
||||
}
|
||||
if (/^https?:\/\//.test(href)) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <a href={href}>{children}</a>;
|
||||
}
|
||||
|
||||
export function DocPage() {
|
||||
const { slug = HOME_SLUG } = useParams();
|
||||
const { locale } = useLocale();
|
||||
const doc = getDoc(slug, locale);
|
||||
const section = sectionOf(slug);
|
||||
const toc = useMemo(() => (doc ? extractToc(doc.body) : []), [doc]);
|
||||
const [activeId, setActiveId] = useState("");
|
||||
/**
|
||||
* A TOC click (or an initial #hash) pins the target entry as active: a short tail
|
||||
* section can never reach the reading line, so pure position tracking would highlight
|
||||
* a neighbor instead of what the user just chose. The pin releases on the first real
|
||||
* scroll gesture (wheel / touch / scroll keys) — programmatic smooth scrolling fires
|
||||
* only `scroll` events, so it never unpins by itself.
|
||||
*/
|
||||
const pinnedId = useRef<string | null>(null);
|
||||
|
||||
const pinTo = useCallback((id: string) => {
|
||||
pinnedId.current = id;
|
||||
setActiveId(id);
|
||||
}, []);
|
||||
|
||||
// Track the heading last crossed by a moving reading line for TOC highlighting.
|
||||
// The line sits 100px under the sticky header at the top of the page and slides down
|
||||
// to ~40px above the viewport bottom at full scroll: it is monotonic in scrollY, so
|
||||
// every heading gets a highlight band of its own — short tail sections that could
|
||||
// never reach a fixed line are not skipped, and the highlight steps through sections
|
||||
// in order. Scroll-position based rather than an IntersectionObserver: with an
|
||||
// observer nothing intersects the narrow band between headings, so the highlight
|
||||
// would stall while scrolling.
|
||||
useEffect(() => {
|
||||
if (toc.length < 2) return;
|
||||
// Deep links carry the anchor percent-encoded (CJK headings); pin it if it is ours.
|
||||
const initialHash = decodeURIComponent(window.location.hash.slice(1));
|
||||
pinnedId.current = toc.some((entry) => entry.id === initialHash) ? initialHash : null;
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
if (pinnedId.current !== null) {
|
||||
setActiveId(pinnedId.current);
|
||||
return;
|
||||
}
|
||||
const docEl = document.documentElement;
|
||||
const maxScroll = Math.max(0, docEl.scrollHeight - window.innerHeight);
|
||||
const progress = maxScroll > 0 ? Math.min(1, window.scrollY / maxScroll) : 0;
|
||||
const line = 100 + Math.max(0, window.innerHeight - 140) * progress;
|
||||
let current = toc[0]!.id;
|
||||
for (const entry of toc) {
|
||||
const el = document.getElementById(entry.id);
|
||||
if (el && el.getBoundingClientRect().top <= line) current = entry.id;
|
||||
}
|
||||
// Safety net: fully at the bottom nothing can advance further — settle on the last
|
||||
// entry (only after the page actually scrolled; a viewport-short page stays on top).
|
||||
if (maxScroll > 0 && window.scrollY >= maxScroll - 4) current = toc[toc.length - 1]!.id;
|
||||
setActiveId(current);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (raf === 0) raf = requestAnimationFrame(update);
|
||||
};
|
||||
const unpin = (e?: KeyboardEvent) => {
|
||||
if (e && !["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(e.key))
|
||||
return;
|
||||
if (pinnedId.current === null) return;
|
||||
pinnedId.current = null;
|
||||
onScroll();
|
||||
};
|
||||
const onWheel = () => unpin();
|
||||
const onKey = (e: KeyboardEvent) => unpin(e);
|
||||
// Same-page hash navigation (address bar / in-content anchors) re-runs no effect,
|
||||
// so re-evaluate the pin whenever the hash changes.
|
||||
const onHashChange = () => {
|
||||
const hash = decodeURIComponent(window.location.hash.slice(1));
|
||||
if (toc.some((entry) => entry.id === hash)) {
|
||||
pinnedId.current = hash;
|
||||
setActiveId(hash);
|
||||
}
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll);
|
||||
window.addEventListener("wheel", onWheel, { passive: true });
|
||||
window.addEventListener("touchmove", onWheel, { passive: true });
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
window.removeEventListener("wheel", onWheel);
|
||||
window.removeEventListener("touchmove", onWheel);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("hashchange", onHashChange);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [toc]);
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-24 text-center sm:px-6">
|
||||
<p className="text-lg font-medium">{S.doc.notFound}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-4 inline-flex items-center gap-1.5 text-sm text-brand-700 hover:underline dark:text-brand-300"
|
||||
>
|
||||
<ArrowRightIcon className="h-3.5 w-3.5 rotate-180" />
|
||||
{S.doc.backHome}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showToc = toc.length >= 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`anim-rise px-4 py-10 sm:px-6 lg:px-10 ${
|
||||
showToc ? "xl:grid xl:grid-cols-[minmax(0,1fr)_13rem] xl:gap-10" : ""
|
||||
}`}
|
||||
>
|
||||
<article className="mx-auto w-full max-w-3xl xl:mx-0">
|
||||
<header>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{section && (
|
||||
<p className="text-xs font-semibold tracking-wide text-brand-700 uppercase dark:text-brand-300">
|
||||
{S.sections[section.id]}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="mt-1.5 text-3xl font-semibold tracking-tight text-balance">
|
||||
{doc.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="pt-1.5">
|
||||
<CopyMarkdownButton text={docMarkdown(doc)} />
|
||||
</div>
|
||||
</div>
|
||||
{doc.description && (
|
||||
<p className="mt-3 text-[15px] text-gray-600 dark:text-gray-400">{doc.description}</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="md-body mt-6 text-[15px] text-gray-800 dark:text-gray-200">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) => <MdLink href={href}>{children}</MdLink>,
|
||||
h2: ({ children }) => (
|
||||
<h2 id={slugifyHeading(nodeText(children))} className="scroll-mt-20">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 id={slugifyHeading(nodeText(children))} className="scroll-mt-20">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{doc.body}
|
||||
</Markdown>
|
||||
</div>
|
||||
<Pager slug={slug} />
|
||||
</article>
|
||||
{showToc && <Toc entries={toc} activeId={activeId} onNavigate={pinTo} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Router + layout: sticky top bar, left sidebar (sticky column on desktop, overlay
|
||||
* panel on mobile), doc content, slim footer. basename comes from Vite's BASE_URL so
|
||||
* the site works under the GitHub Pages subpath ("/<repo>/docs/"); scroll restores to
|
||||
* top on route change (hash targets excluded).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { BrowserRouter, Outlet, Route, Routes, useLocation } from "react-router";
|
||||
import { Nav } from "./components/nav";
|
||||
import { Sidebar } from "./components/sidebar";
|
||||
import { Footer } from "./components/footer";
|
||||
import { DocPage } from "./pages/doc-page";
|
||||
|
||||
/**
|
||||
* Last history entry whose scroll was already handled. Module-level so it survives
|
||||
* the locale-keyed remount of the whole tree: switching language re-mounts Layout,
|
||||
* and without this guard the navigation effect would re-run (jumping to the hash or
|
||||
* to the top) and defeat LocaleScope's scroll preservation.
|
||||
*/
|
||||
let handledLocationKey = "";
|
||||
|
||||
function Layout() {
|
||||
const { pathname, hash, key } = useLocation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Genuine route change: jump to top; with a hash scroll to the target once it is
|
||||
// in the DOM. Also close the mobile sidebar.
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
if (key === handledLocationKey) return;
|
||||
handledLocationKey = key;
|
||||
if (hash) {
|
||||
// Anchors of CJK headings arrive percent-encoded in the URL hash.
|
||||
const el = document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||
if (el) {
|
||||
el.scrollIntoView();
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname, hash, key]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<Nav menuOpen={menuOpen} onToggleMenu={() => setMenuOpen((v) => !v)} />
|
||||
|
||||
{menuOpen && (
|
||||
<div className="anim-fade fixed inset-x-0 top-14 bottom-0 z-30 overflow-y-auto border-t border-gray-200 bg-white px-6 py-6 lg:hidden dark:border-gray-800 dark:bg-gray-950">
|
||||
<Sidebar onNavigate={() => setMenuOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto w-full max-w-7xl flex-1 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
<aside className="hidden border-r border-gray-200 lg:block dark:border-gray-800">
|
||||
<div className="sticky top-14 max-h-[calc(100vh-3.5rem)] overflow-y-auto px-4 py-8 pl-6">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="min-w-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
const basename = import.meta.env.BASE_URL.replace(/\/+$/, "") || "/";
|
||||
return (
|
||||
<BrowserRouter basename={basename}>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<DocPage />} />
|
||||
<Route path="/:slug" element={<DocPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Language context: zh / en / system (tracks navigator.language). On switch it first
|
||||
* synchronously calls setActiveStrings, then remounts the tree keyed on locale so every
|
||||
* `S.x` read reflects the new language; the preference persists to localStorage.
|
||||
* Same pattern as the landing page, under the docs site's own storage key.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { setActiveStrings, zh } from "../lib/strings";
|
||||
import { en } from "../lib/strings-en";
|
||||
|
||||
export type LangPref = "zh" | "en" | "system";
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
const STORAGE_KEY = "penguin-docs.lang";
|
||||
|
||||
interface LocaleContextValue {
|
||||
lang: LangPref;
|
||||
locale: Locale;
|
||||
setLang: (lang: LangPref) => void;
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
/** Device language -> UI language: zh* -> zh, anything else -> en. */
|
||||
export function resolveSystemLocale(language: string | undefined): Locale {
|
||||
return language?.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
}
|
||||
|
||||
function systemLocale(): Locale {
|
||||
return resolveSystemLocale(navigator.language);
|
||||
}
|
||||
|
||||
function resolve(lang: LangPref): Locale {
|
||||
return lang === "system" ? systemLocale() : lang;
|
||||
}
|
||||
|
||||
function initialLang(): LangPref {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "zh" || stored === "en" || stored === "system") return stored;
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<LangPref>(initialLang);
|
||||
const [, setSysTick] = useState(0);
|
||||
|
||||
const locale = resolve(lang);
|
||||
// Switch the active dictionary during render (idempotent): children are keyed on
|
||||
// locale and render after this component, so they read the post-switch dictionary.
|
||||
setActiveStrings(locale === "en" ? en : zh);
|
||||
|
||||
// Keep the document language in sync (static index.html ships lang="en").
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lang !== "system") return;
|
||||
const onChange = () => setSysTick((t) => t + 1);
|
||||
window.addEventListener("languagechange", onChange);
|
||||
return () => window.removeEventListener("languagechange", onChange);
|
||||
}, [lang]);
|
||||
|
||||
const setLang = useCallback((next: LangPref) => {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
setLangState(next);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Language scope: a remount boundary keyed on locale. The remount briefly empties the
|
||||
* DOM, which collapses the page height and clamps the scroll position to 0 — so the
|
||||
* scroll offset is captured during the render that switches locale (old DOM still
|
||||
* mounted) and restored right after the new tree lays out.
|
||||
*/
|
||||
export function LocaleScope({ children }: { children: ReactNode }) {
|
||||
const { locale } = useLocale();
|
||||
const prevLocale = useRef(locale);
|
||||
const savedScroll = useRef<number | null>(null);
|
||||
if (prevLocale.current !== locale) {
|
||||
prevLocale.current = locale;
|
||||
savedScroll.current = window.scrollY;
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (savedScroll.current !== null) {
|
||||
window.scrollTo({ top: savedScroll.current, behavior: "instant" });
|
||||
savedScroll.current = null;
|
||||
}
|
||||
}, [locale]);
|
||||
return (
|
||||
<div key={locale} className="contents">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale must be used inside LocaleProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Theme context: light / dark / system (tracks prefers-color-scheme live), toggled
|
||||
* via the html.dark class + Tailwind dark: variant, persisted to localStorage.
|
||||
* Same behavior as the landing page, under the docs site's own storage key
|
||||
* (index.html pre-applies the stored value before first paint).
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
const MODE_KEY = "penguin-docs.theme";
|
||||
|
||||
interface ThemeContextValue {
|
||||
mode: ThemeMode;
|
||||
/** Resolved effective theme (system mode resolved against the OS preference). */
|
||||
dark: boolean;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function initialMode(): ThemeMode {
|
||||
const stored = localStorage.getItem(MODE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
return "system";
|
||||
}
|
||||
|
||||
function systemDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<ThemeMode>(initialMode);
|
||||
const [sysDark, setSysDark] = useState(systemDark);
|
||||
|
||||
const dark = mode === "system" ? sysDark : mode === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "system") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (e: MediaQueryListEvent) => setSysDark(e.matches);
|
||||
setSysDark(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [mode]);
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
localStorage.setItem(MODE_KEY, next);
|
||||
setModeState(next);
|
||||
}, []);
|
||||
|
||||
return <ThemeContext.Provider value={{ mode, dark, setMode }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Docs site styles: single-file Tailwind CSS 4 entry point sharing the landing page's
|
||||
* visual language (GitHub-style simplicity, solid backgrounds + 1px borders + one
|
||||
* brand blue accent) minus its marketing effects — doc pages stay calm and readable.
|
||||
* Dark theme is toggled via the html.dark class.
|
||||
*/
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Dark theme toggled via the html.dark class (Tailwind 4 custom variant). */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Brand color scale (Google blue family), identical to the landing page / Web App. */
|
||||
@theme {
|
||||
--color-brand-25: #f8fbff;
|
||||
--color-brand-50: #e8f0fe;
|
||||
--color-brand-100: #d2e3fc;
|
||||
--color-brand-200: #aecbfa;
|
||||
--color-brand-300: #8ab4f8;
|
||||
--color-brand-400: #669df6;
|
||||
--color-brand-500: #4285f4;
|
||||
--color-brand-600: #1a73e8;
|
||||
--color-brand-700: #0b57d0;
|
||||
--color-brand-800: #0842a0;
|
||||
--color-brand-900: #062e6f;
|
||||
--color-brand-950: #041e49;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
/* Pure-black dark base, matching the landing page (true-neutral gray overrides). */
|
||||
--color-gray-950: #000000;
|
||||
--color-gray-900: #0d0d0d;
|
||||
--color-gray-800: #1f1f1f;
|
||||
--color-gray-700: #303030;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
@apply bg-white text-gray-900 antialiased dark:bg-gray-950 dark:text-gray-100;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
}
|
||||
code,
|
||||
pre,
|
||||
kbd {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid rgb(107 114 128 / 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
::selection {
|
||||
background: rgb(0 0 0 / 0.1);
|
||||
}
|
||||
.dark ::selection {
|
||||
background: rgb(255 255 255 / 0.18);
|
||||
}
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(60 64 67 / 0.28) transparent;
|
||||
}
|
||||
.dark * {
|
||||
scrollbar-color: rgb(232 234 237 / 0.2) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Animations (same tone as the landing page: short ease, slight offset) ---------- */
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.anim-rise {
|
||||
animation: rise-in 280ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
}
|
||||
.anim-fade {
|
||||
animation: fade-in 120ms ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Typography for Markdown doc bodies (same voice as the landing blog) ---------- */
|
||||
|
||||
.md-body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.md-body :is(p, ul, ol, pre, blockquote, table) {
|
||||
margin: 0.625rem 0;
|
||||
}
|
||||
.md-body li {
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
.md-body p,
|
||||
.md-body li {
|
||||
line-height: 1.75;
|
||||
}
|
||||
.md-body :is(h1, h2, h3, h4) {
|
||||
font-weight: 600;
|
||||
margin: 1.75rem 0 0.5rem;
|
||||
}
|
||||
.md-body h1 {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
.md-body h2 {
|
||||
font-size: 1.1875rem;
|
||||
@apply border-b border-gray-200 pb-1.5 dark:border-gray-800;
|
||||
}
|
||||
.md-body h3 {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.md-body ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.md-body ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.md-body a {
|
||||
@apply text-brand-700 underline decoration-brand-300 underline-offset-2 transition-colors hover:text-brand-600 dark:text-brand-300 dark:decoration-brand-700;
|
||||
}
|
||||
.md-body code {
|
||||
@apply rounded bg-gray-100 px-1 py-0.5 text-[0.85em] text-gray-800 dark:bg-gray-800 dark:text-gray-200;
|
||||
}
|
||||
.md-body pre {
|
||||
@apply overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-[13px] leading-6 dark:border-gray-800 dark:bg-gray-900;
|
||||
}
|
||||
.md-body pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.md-body blockquote {
|
||||
@apply border-l-2 border-gray-300 pl-3 text-gray-600 dark:border-gray-700 dark:text-gray-400;
|
||||
}
|
||||
.md-body table {
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md-body :is(th, td) {
|
||||
@apply border border-gray-200 px-2.5 py-1.5 text-sm dark:border-gray-800;
|
||||
}
|
||||
.md-body th {
|
||||
@apply bg-gray-50 text-left dark:bg-gray-900;
|
||||
}
|
||||
.md-body hr {
|
||||
@apply my-4 border-gray-200 dark:border-gray-800;
|
||||
}
|
||||
Reference in New Issue
Block a user