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:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+20
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+60
View File
@@ -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`;
}
+31
View File
@@ -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() };
}
+12
View File
@@ -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\/$/, "");
+43
View File
@@ -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));
}
+52
View File
@@ -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",
},
};
+70
View File
@@ -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;
}
+42
View File
@@ -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;
}
+14
View File
@@ -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>,
);
+284
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+114
View File
@@ -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;
}
+64
View File
@@ -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;
}
+191
View File
@@ -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;
}