feat(docs): add bilingual package documentation site
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
startTransition,
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react"
|
||||
import { rootCSSVariables } from "./components/layout"
|
||||
import { LocaleProvider } from "./components/locale-provider"
|
||||
import { DocsPreferencesProvider } from "./components/preferences-provider"
|
||||
import { DocsSearchProvider } from "./components/docs-search-provider"
|
||||
import { resolveDocLocale } from "./content/registry"
|
||||
import { useTranslate } from "./lib/locale"
|
||||
|
||||
const Index = lazy(() => import("./pages/index"))
|
||||
const Article = lazy(() => import("./pages/article"))
|
||||
const Settings = lazy(() => import("./pages/settings"))
|
||||
|
||||
type RouteDefinition = {
|
||||
Component: ComponentType
|
||||
matches: (pathname: string) => boolean
|
||||
}
|
||||
|
||||
const routes: RouteDefinition[] = [
|
||||
{
|
||||
Component: Index,
|
||||
matches: (pathname) =>
|
||||
pathname === "/" || /^\/(?:en-US|zh-Hans)\/?$/.test(pathname),
|
||||
},
|
||||
{
|
||||
Component: Article,
|
||||
matches: (pathname) =>
|
||||
/^(?:\/(?:en-US|zh-Hans))?\/articles?(?:\/|$)/.test(pathname),
|
||||
},
|
||||
{
|
||||
Component: Settings,
|
||||
matches: (pathname) =>
|
||||
/^(?:\/(?:en-US|zh-Hans))?\/settings\/?$/.test(pathname),
|
||||
},
|
||||
]
|
||||
|
||||
function App() {
|
||||
const [currentUrl, setCurrentUrl] = useState(() => window.location.href)
|
||||
|
||||
useEffect(() => {
|
||||
function updateRoute() {
|
||||
startTransition(() => setCurrentUrl(window.location.href))
|
||||
}
|
||||
|
||||
function navigate(event: MouseEvent) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
|
||||
const anchor = target.closest<HTMLAnchorElement>("a[href]")
|
||||
if (
|
||||
!anchor ||
|
||||
anchor.hasAttribute("download") ||
|
||||
(anchor.target && anchor.target !== "_self")
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const destination = new URL(anchor.href, window.location.href)
|
||||
if (destination.origin !== window.location.origin) return
|
||||
|
||||
const current = new URL(window.location.href)
|
||||
if (
|
||||
destination.pathname === current.pathname &&
|
||||
destination.search === current.search &&
|
||||
destination.hash
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
window.history.pushState(null, "", destination)
|
||||
window.scrollTo({ left: 0, top: 0 })
|
||||
updateRoute()
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", updateRoute)
|
||||
document.addEventListener("click", navigate)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", updateRoute)
|
||||
document.removeEventListener("click", navigate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { pathname } = new URL(currentUrl)
|
||||
const Route = routes.find((route) => route.matches(pathname))?.Component
|
||||
const locale = resolveDocLocale(pathname)
|
||||
|
||||
return (
|
||||
<DocsPreferencesProvider routeLocale={locale}>
|
||||
<LocaleProvider locale={locale}>
|
||||
<DocsSearchProvider>
|
||||
<div style={rootCSSVariables}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="p-6 text-slate-500 dark:text-slate-400">
|
||||
Loading…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{Route ? <Route key={pathname} /> : <NotFound />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</DocsSearchProvider>
|
||||
</LocaleProvider>
|
||||
</DocsPreferencesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center px-6">
|
||||
<h1 className="text-4xl">
|
||||
{t({ zh: "页面不存在", us: "Page not found" })}
|
||||
</h1>
|
||||
<a
|
||||
className="mt-4 w-fit text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
href="/"
|
||||
>
|
||||
{t({ zh: "返回首页", us: "Back to home" })}
|
||||
</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,179 @@
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchProvider,
|
||||
type SearchAdapter,
|
||||
type SearchResultItem,
|
||||
} from "@workspace/search"
|
||||
import { messages as searchMessagesEnUS } from "@workspace/search/locales/en-US"
|
||||
import { messages as searchMessagesZhHans } from "@workspace/search/locales/zh-Hans"
|
||||
import React from "react"
|
||||
|
||||
import {
|
||||
getDocHref,
|
||||
getDocPackages,
|
||||
getDocSearchText,
|
||||
getDocSectionLabel,
|
||||
} from "../content/registry"
|
||||
import type {
|
||||
DocLocale,
|
||||
DocPackage,
|
||||
DocPage,
|
||||
DocTocItem,
|
||||
} from "../content/types"
|
||||
import { useLocale } from "../lib/locale"
|
||||
|
||||
const searchCatalogs = {
|
||||
"en-US": searchMessagesEnUS,
|
||||
"zh-Hans": searchMessagesZhHans,
|
||||
} as const
|
||||
|
||||
type DocsSearchPayload = {
|
||||
href: string
|
||||
}
|
||||
|
||||
export function DocsSearchProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const locale = useLocale()
|
||||
const adapter = React.useMemo(() => createDocsSearchAdapter(locale), [locale])
|
||||
|
||||
return (
|
||||
<I18nProvider catalogs={searchCatalogs} locale={locale}>
|
||||
<SearchProvider
|
||||
adapter={adapter}
|
||||
historyStorageKey={`docs-search-history:${locale}`}
|
||||
onSelect={({ item }) => {
|
||||
if (isDocsSearchPayload(item.payload)) navigate(item.payload.href)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SearchDialog className="font-sans" />
|
||||
</SearchProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function createDocsSearchAdapter(locale: DocLocale): SearchAdapter {
|
||||
const docPackages = getDocPackages(locale)
|
||||
|
||||
return {
|
||||
async search({ query, signal }) {
|
||||
const normalizedQuery = normalize(query)
|
||||
if (!normalizedQuery) return { groups: [] }
|
||||
|
||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean)
|
||||
const groups = docPackages.flatMap((docPackage) => {
|
||||
const items = docPackage.pages
|
||||
.map((page) => ({
|
||||
item: createResultItem(docPackage, page, locale),
|
||||
score: getMatchScore(
|
||||
normalizedQuery,
|
||||
terms,
|
||||
docPackage.label,
|
||||
page
|
||||
),
|
||||
}))
|
||||
.filter((result) => result.score >= 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
left.item.title.localeCompare(right.item.title, locale)
|
||||
)
|
||||
.map((result) => result.item)
|
||||
|
||||
return items.length > 0
|
||||
? [
|
||||
{
|
||||
id: docPackage.key,
|
||||
label: docPackage.label,
|
||||
items,
|
||||
total: items.length,
|
||||
},
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
if (signal.aborted) return { groups: [] }
|
||||
return { groups }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createResultItem(
|
||||
docPackage: DocPackage,
|
||||
page: DocPage,
|
||||
locale: DocLocale
|
||||
): SearchResultItem {
|
||||
return {
|
||||
id: `${page.section}/${page.slug}`,
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
keywords: [
|
||||
docPackage.label,
|
||||
getDocSectionLabel(page.section, locale),
|
||||
...page.toc.flatMap(flattenTocTitles),
|
||||
getDocSearchText(page),
|
||||
],
|
||||
meta: [getDocSectionLabel(page.section, locale)],
|
||||
payload: {
|
||||
href: getDocHref(docPackage, page),
|
||||
} satisfies DocsSearchPayload,
|
||||
}
|
||||
}
|
||||
|
||||
function getMatchScore(
|
||||
query: string,
|
||||
terms: readonly string[],
|
||||
packageLabel: string,
|
||||
page: DocPage
|
||||
) {
|
||||
const title = normalize(page.title)
|
||||
const description = normalize(page.description)
|
||||
const packageName = normalize(packageLabel)
|
||||
const toc = normalize(page.toc.flatMap(flattenTocTitles).join(" "))
|
||||
const body = normalize(getDocSearchText(page))
|
||||
const searchableText = [packageName, title, description, toc, body].join(" ")
|
||||
|
||||
if (!terms.every((term) => searchableText.includes(term))) return -1
|
||||
|
||||
let score = 0
|
||||
if (title === query) score += 100
|
||||
else if (title.startsWith(query)) score += 70
|
||||
else if (title.includes(query)) score += 50
|
||||
if (packageName === query) score += 40
|
||||
else if (packageName.includes(query)) score += 20
|
||||
if (description.includes(query)) score += 15
|
||||
if (toc.includes(query)) score += 10
|
||||
if (page.section === "overview") score += 2
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function flattenTocTitles(item: DocTocItem): readonly string[] {
|
||||
return [item.title, ...(item.items?.flatMap(flattenTocTitles) ?? [])]
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.normalize("NFKC").toLocaleLowerCase().trim()
|
||||
}
|
||||
|
||||
function isDocsSearchPayload(value: unknown): value is DocsSearchPayload {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"href" in value &&
|
||||
typeof value.href === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function navigate(href: string) {
|
||||
const destination = new URL(href, window.location.href)
|
||||
if (destination.href === window.location.href) return
|
||||
|
||||
window.history.pushState(null, "", destination)
|
||||
window.scrollTo({ left: 0, top: 0 })
|
||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { LocaleFlag } from "./locale-flag"
|
||||
import { getLocalizedHref } from "../content/registry"
|
||||
import type { DocLocale } from "../content/types"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
import { useIsMobile } from "../lib/use-mobile"
|
||||
|
||||
type LocaleOption = {
|
||||
label: string
|
||||
locale: DocLocale
|
||||
}
|
||||
|
||||
const localeOptions: readonly LocaleOption[] = [
|
||||
{ label: "简体中文", locale: "zh-Hans" },
|
||||
{ label: "English", locale: "en-US" },
|
||||
]
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const isMobile = useIsMobile()
|
||||
const [, setLocaleMode] = usePreference("locale-mode")
|
||||
const [, setPreferredLocale] = usePreference("locale")
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuId = useId()
|
||||
const selectedLocale = getLocaleOption(locale)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function isInsideSwitcher(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Node &&
|
||||
(rootRef.current?.contains(target) || menuRef.current?.contains(target))
|
||||
)
|
||||
}
|
||||
|
||||
function closeAndRestoreFocus() {
|
||||
setOpen(false)
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus())
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (isInsideSwitcher(event.target)) return
|
||||
|
||||
if (isMobile) {
|
||||
closeAndRestoreFocus()
|
||||
} else {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusIn(event: FocusEvent) {
|
||||
if (!isMobile && !isInsideSwitcher(event.target)) setOpen(false)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeAndRestoreFocus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMobile || event.key !== "Tab") return
|
||||
|
||||
const items = getMenuItems(menuRef.current)
|
||||
const firstItem = items[0]
|
||||
const lastItem = items.at(-1)
|
||||
if (!firstItem || !lastItem) return
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstItem) {
|
||||
event.preventDefault()
|
||||
lastItem.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === lastItem) {
|
||||
event.preventDefault()
|
||||
firstItem.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
if (isMobile) {
|
||||
document.body.style.overflow = "hidden"
|
||||
window.requestAnimationFrame(() => {
|
||||
menuRef.current
|
||||
?.querySelector<HTMLElement>('[aria-checked="true"]')
|
||||
?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown)
|
||||
document.addEventListener("focusin", handleFocusIn)
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown)
|
||||
document.removeEventListener("focusin", handleFocusIn)
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
if (isMobile) document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [isMobile, open])
|
||||
|
||||
function focusMenuItem(position: "first" | "last") {
|
||||
setOpen(true)
|
||||
window.requestAnimationFrame(() => {
|
||||
const items = getMenuItems(menuRef.current)
|
||||
const item = position === "first" ? items[0] : items.at(-1)
|
||||
item?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function selectLocale(nextLocale: DocLocale) {
|
||||
setPreferredLocale(nextLocale)
|
||||
setLocaleMode("manual")
|
||||
setOpen(false)
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus())
|
||||
}
|
||||
|
||||
const menu = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role={isMobile ? "dialog" : undefined}
|
||||
aria-label={t({ zh: "语言", us: "Language" })}
|
||||
aria-modal={isMobile || undefined}
|
||||
className="w-[min(80vw,20rem)] rounded-xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur-md md:w-auto md:min-w-40 md:p-1 dark:border-slate-800 dark:bg-slate-950/95 dark:shadow-black/60"
|
||||
>
|
||||
<p className="px-2 py-2 text-sm text-slate-500 md:text-xs dark:text-slate-400">
|
||||
{t({ zh: "语言", us: "Language" })}
|
||||
</p>
|
||||
<ul
|
||||
role="menu"
|
||||
aria-label={t({ zh: "语言", us: "Languages" })}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key !== "ArrowDown" &&
|
||||
event.key !== "ArrowUp" &&
|
||||
event.key !== "Home" &&
|
||||
event.key !== "End"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const items = getMenuItems(event.currentTarget)
|
||||
const currentIndex = items.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
let nextIndex = currentIndex
|
||||
|
||||
if (event.key === "Home") nextIndex = 0
|
||||
if (event.key === "End") nextIndex = items.length - 1
|
||||
if (event.key === "ArrowDown") {
|
||||
nextIndex = (currentIndex + 1) % items.length
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
nextIndex = (currentIndex - 1 + items.length) % items.length
|
||||
}
|
||||
|
||||
items[nextIndex]?.focus()
|
||||
}}
|
||||
>
|
||||
{localeOptions.map((option) => {
|
||||
const selected = option.locale === locale
|
||||
|
||||
return (
|
||||
<li role="none" key={option.locale}>
|
||||
<a
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
lang={option.locale}
|
||||
href={getLocalizedHref(window.location.pathname, option.locale)}
|
||||
className="flex h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-left whitespace-nowrap transition-colors hover:bg-slate-100 focus:bg-slate-100 focus:outline-none aria-checked:text-indigo-600 dark:hover:bg-slate-900 dark:focus:bg-slate-900 dark:aria-checked:text-indigo-400"
|
||||
onClick={() => selectLocale(option.locale)}
|
||||
>
|
||||
<LocaleFlag
|
||||
locale={option.locale}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
{selected && (
|
||||
<Icon
|
||||
name="tabler:check"
|
||||
className="ml-auto size-4 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex items-center md:relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup={isMobile ? "dialog" : "menu"}
|
||||
aria-label={t({ zh: "选择语言", us: "Choose language" })}
|
||||
title={t({ zh: "选择语言", us: "Choose language" })}
|
||||
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors outline-none hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
focusMenuItem("first")
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
focusMenuItem("last")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LocaleFlag locale={selectedLocale.locale} className="size-7" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
(isMobile ? (
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-100 flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t({
|
||||
zh: "关闭语言菜单",
|
||||
us: "Close language menu",
|
||||
})}
|
||||
className="absolute inset-0 cursor-default bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
window.requestAnimationFrame(() =>
|
||||
triggerRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="relative">{menu}</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
) : (
|
||||
<div className="absolute top-full right-0 z-100 pt-2">{menu}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getMenuItems(root: ParentNode | null) {
|
||||
return Array.from(
|
||||
root?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function getLocaleOption(locale: DocLocale) {
|
||||
return localeOptions.find((option) => option.locale === locale)!
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { cn } from "../lib/utils"
|
||||
import { useTranslate } from "../lib/locale"
|
||||
import React from "react"
|
||||
import { SearchTrigger } from "./search-trigger"
|
||||
import { SettingsLink } from "./settings-link"
|
||||
|
||||
// oxlint-disable-next-line react/only-export-components -- Shared layout tokens are consumed by App.
|
||||
export const rootCSSVariables = {
|
||||
"--cw": "60rem",
|
||||
"--max-lw": "18rem",
|
||||
"--max-rw": "15rem",
|
||||
"--min-lw": "12rem",
|
||||
"--min-rw": "10rem",
|
||||
"--w": "calc(var(--max-lw) + var(--cw) + var(--max-rw))",
|
||||
"--page-pl": "calc(2rem + max(0px, (100vw - var(--w)) * 6 / 11 - 1px))",
|
||||
"--page-pr": "calc(2rem + max(0px, 100vw - var(--w)) * 5 / 11)",
|
||||
} as const as React.CSSProperties
|
||||
|
||||
export function Container({ className, ...rest }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("px-4 md:pr-(--page-pr) md:pl-(--page-pl)", className)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeButton({
|
||||
href = "/",
|
||||
label,
|
||||
}: {
|
||||
href?: string
|
||||
label?: string
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
|
||||
>
|
||||
<Icon name="tabler:smart-home" className="size-5" />
|
||||
{label ?? t({ zh: "首页", us: "Home" })}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackButton({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if (!href) {
|
||||
e.preventDefault()
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href ?? "/"}
|
||||
onClick={href ? undefined : handleClick}
|
||||
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
|
||||
>
|
||||
<span className="inline-flex h-6 w-4 items-center justify-center rounded-xl bg-slate-200 dark:bg-slate-800">
|
||||
<Icon name="tabler:chevron-compact-left" className="size-4" />
|
||||
</span>
|
||||
{children ?? <span>{t({ zh: "返回", us: "Back" })}</span>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlashLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-2 h-4 w-px rotate-30 bg-current opacity-35", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Actions({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", className)}>
|
||||
<SearchTrigger />
|
||||
<SettingsLink />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Icon } from "@icones/react"
|
||||
|
||||
import type { DocLocale } from "../content/types"
|
||||
|
||||
export function LocaleFlag({
|
||||
className,
|
||||
locale,
|
||||
}: {
|
||||
className?: string
|
||||
locale: DocLocale
|
||||
}) {
|
||||
return locale === "zh-Hans" ? (
|
||||
<Icon name="flag:cn-circle" className={className} />
|
||||
) : (
|
||||
<Icon name="flag:us-circle" className={className} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, type ReactNode } from "react"
|
||||
|
||||
import type { DocLocale } from "../content/types"
|
||||
import { LocaleContext } from "../lib/locale"
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
locale,
|
||||
}: {
|
||||
children: ReactNode
|
||||
locale: DocLocale
|
||||
}) {
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale
|
||||
}, [locale])
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { MdxComponents } from "../content/types"
|
||||
|
||||
/* oxlint-disable react/only-export-components -- This module exports an MDX component registry. */
|
||||
export const mdxComponents: MdxComponents = {
|
||||
a: MdxAnchor,
|
||||
blockquote: MdxBlockquote,
|
||||
code: MdxCode,
|
||||
h2: MdxHeading2,
|
||||
h3: MdxHeading3,
|
||||
pre: MdxPre,
|
||||
}
|
||||
|
||||
function MdxHeading2({ children, id, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<h2
|
||||
id={id}
|
||||
className="group scroll-mt-24 pt-7 text-2xl font-semibold tracking-tight text-slate-950 first:pt-0 dark:text-slate-100"
|
||||
{...props}
|
||||
>
|
||||
{id ? (
|
||||
<a className="text-inherit no-underline" href={`#${id}`}>
|
||||
{children}
|
||||
<span className="ml-2 text-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 dark:text-indigo-400">
|
||||
#
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function MdxHeading3({ children, id, ...props }: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
id={id}
|
||||
className="group scroll-mt-24 pt-5 text-xl font-semibold tracking-tight text-slate-950 dark:text-slate-100"
|
||||
{...props}
|
||||
>
|
||||
{id ? (
|
||||
<a className="text-inherit no-underline" href={`#${id}`}>
|
||||
{children}
|
||||
<span className="ml-2 text-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 dark:text-indigo-400">
|
||||
#
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
function MdxAnchor({ className, ...props }: React.ComponentProps<"a">) {
|
||||
return (
|
||||
<a
|
||||
className={`font-medium text-indigo-600 underline decoration-indigo-300 underline-offset-4 transition-colors hover:text-indigo-500 dark:text-indigo-400 dark:decoration-indigo-800 dark:hover:text-indigo-300 ${className ?? ""}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MdxBlockquote({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"blockquote">) {
|
||||
return (
|
||||
<blockquote
|
||||
className={`rounded-r-lg border-l-2 border-indigo-500 bg-indigo-50 px-4 py-3 text-slate-700 dark:border-indigo-400 dark:bg-indigo-950/30 dark:text-slate-300 ${className ?? ""}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MdxCode({ className, ...props }: React.ComponentProps<"code">) {
|
||||
return (
|
||||
<code
|
||||
className={`rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.9em] text-slate-900 dark:bg-slate-900 dark:text-slate-100 ${className ?? ""}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MdxPre({ className, ...props }: React.ComponentProps<"pre">) {
|
||||
return (
|
||||
<pre
|
||||
className={`overflow-x-auto rounded-xl border border-slate-200 bg-slate-950 p-4 font-mono text-sm leading-6 text-slate-100 dark:border-slate-800 dark:bg-black/40 [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit ${className ?? ""}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* oxlint-enable react/only-export-components */
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import {
|
||||
PreferencesProvider,
|
||||
usePreference,
|
||||
usePreferences,
|
||||
type PreferenceChangeHandler,
|
||||
type PreferenceUpdate,
|
||||
type Preferences,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import type { DocLocale } from "../content/types"
|
||||
import { navigateToLocale } from "../lib/locale-navigation"
|
||||
import {
|
||||
detectLocale,
|
||||
docsPreferenceEffects,
|
||||
isDocsPreferenceUpdate,
|
||||
parseDocsPreferences,
|
||||
persistDocsPreference,
|
||||
readDocsPreferences,
|
||||
} from "../lib/preferences"
|
||||
|
||||
const preferencesChannelName = "docs-preferences-sync"
|
||||
|
||||
type PreferencesMessage =
|
||||
| { type: "request" }
|
||||
| { preferences: unknown; type: "snapshot" }
|
||||
| { type: "update"; update: unknown }
|
||||
|
||||
export function DocsPreferencesProvider({
|
||||
children,
|
||||
routeLocale,
|
||||
}: {
|
||||
children: ReactNode
|
||||
routeLocale: DocLocale
|
||||
}) {
|
||||
const [initialPreferences] = useState(() => readDocsPreferences(routeLocale))
|
||||
const channelRef = useRef<BroadcastChannel>(null)
|
||||
const applyingExternalUpdateRef = useRef(false)
|
||||
|
||||
const handlePreferenceChange = useCallback<PreferenceChangeHandler>(
|
||||
(update) => {
|
||||
persistDocsPreference(update)
|
||||
if (!applyingExternalUpdateRef.current) {
|
||||
channelRef.current?.postMessage({ type: "update", update })
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<PreferencesProvider
|
||||
effects={docsPreferenceEffects}
|
||||
initialPreferences={initialPreferences}
|
||||
onPreferenceChange={handlePreferenceChange}
|
||||
>
|
||||
<PreferencesRuntime
|
||||
applyingExternalUpdateRef={applyingExternalUpdateRef}
|
||||
channelRef={channelRef}
|
||||
routeLocale={routeLocale}
|
||||
/>
|
||||
{children}
|
||||
</PreferencesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function PreferencesRuntime({
|
||||
applyingExternalUpdateRef,
|
||||
channelRef,
|
||||
routeLocale,
|
||||
}: {
|
||||
applyingExternalUpdateRef: MutableRefObject<boolean>
|
||||
channelRef: MutableRefObject<BroadcastChannel | null>
|
||||
routeLocale: DocLocale
|
||||
}) {
|
||||
const preferences = usePreferences()
|
||||
const preferencesRef = useRef(preferences)
|
||||
const [, setAccentColor] = usePreference("accent-color")
|
||||
const [, setBackgroundShade] = usePreference("background-shade")
|
||||
const [, setForegroundShade] = usePreference("foreground-shade")
|
||||
const [, setLocaleMode] = usePreference("locale-mode")
|
||||
const [, setLocale] = usePreference("locale")
|
||||
const [, setThemeMode] = usePreference("theme-mode")
|
||||
|
||||
useLayoutEffect(() => {
|
||||
preferencesRef.current = preferences
|
||||
}, [preferences])
|
||||
|
||||
useEffect(() => {
|
||||
if (!("BroadcastChannel" in window)) return
|
||||
|
||||
const channel = new BroadcastChannel(preferencesChannelName)
|
||||
channelRef.current = channel
|
||||
|
||||
function applyUpdate(update: PreferenceUpdate) {
|
||||
applyingExternalUpdateRef.current = true
|
||||
try {
|
||||
switch (update.key) {
|
||||
case "accent-color":
|
||||
setAccentColor(update.value)
|
||||
break
|
||||
case "background-shade":
|
||||
setBackgroundShade(update.value)
|
||||
break
|
||||
case "foreground-shade":
|
||||
setForegroundShade(update.value)
|
||||
break
|
||||
case "locale-mode":
|
||||
setLocaleMode(update.value)
|
||||
break
|
||||
case "locale":
|
||||
setLocale(update.value)
|
||||
break
|
||||
case "theme-mode":
|
||||
setThemeMode(update.value)
|
||||
break
|
||||
}
|
||||
} finally {
|
||||
applyingExternalUpdateRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(nextPreferences: Preferences) {
|
||||
preferencesRef.current = nextPreferences
|
||||
for (const [key, value] of Object.entries(nextPreferences)) {
|
||||
const update = { key, value }
|
||||
if (isDocsPreferenceUpdate(update)) applyUpdate(update)
|
||||
}
|
||||
}
|
||||
|
||||
function synchronizeRoute(nextPreferences: Preferences) {
|
||||
const locale =
|
||||
nextPreferences["locale-mode"] === "auto"
|
||||
? detectLocale()
|
||||
: nextPreferences.locale
|
||||
navigateToLocale(locale, true)
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent<unknown>) {
|
||||
const message = event.data as Partial<PreferencesMessage> | undefined
|
||||
if (!message || typeof message !== "object") return
|
||||
|
||||
if (message.type === "request") {
|
||||
channel.postMessage({
|
||||
type: "snapshot",
|
||||
preferences: preferencesRef.current,
|
||||
} satisfies PreferencesMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "update" && isDocsPreferenceUpdate(message.update)) {
|
||||
const nextPreferences = {
|
||||
...preferencesRef.current,
|
||||
[message.update.key]: message.update.value,
|
||||
}
|
||||
preferencesRef.current = nextPreferences
|
||||
applyUpdate(message.update)
|
||||
synchronizeRoute(nextPreferences)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "snapshot") {
|
||||
const nextPreferences = parseDocsPreferences(message.preferences)
|
||||
if (!nextPreferences) return
|
||||
applySnapshot(nextPreferences)
|
||||
synchronizeRoute(nextPreferences)
|
||||
}
|
||||
}
|
||||
|
||||
channel.addEventListener("message", handleMessage)
|
||||
channel.postMessage({ type: "request" } satisfies PreferencesMessage)
|
||||
|
||||
return () => {
|
||||
channelRef.current = null
|
||||
channel.close()
|
||||
}
|
||||
}, [
|
||||
applyingExternalUpdateRef,
|
||||
channelRef,
|
||||
setAccentColor,
|
||||
setBackgroundShade,
|
||||
setForegroundShade,
|
||||
setLocale,
|
||||
setLocaleMode,
|
||||
setThemeMode,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences["locale-mode"] !== "auto") return
|
||||
|
||||
function synchronizeDetectedLocale() {
|
||||
navigateToLocale(detectLocale(), true)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") synchronizeDetectedLocale()
|
||||
}
|
||||
|
||||
synchronizeDetectedLocale()
|
||||
window.addEventListener("focus", synchronizeDetectedLocale)
|
||||
window.addEventListener("languagechange", synchronizeDetectedLocale)
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", synchronizeDetectedLocale)
|
||||
window.removeEventListener("languagechange", synchronizeDetectedLocale)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
}, [preferences])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
preferences["locale-mode"] === "manual" &&
|
||||
preferences.locale !== routeLocale
|
||||
) {
|
||||
setLocale(routeLocale)
|
||||
}
|
||||
}, [preferences, routeLocale, setLocale])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { SearchTrigger as RawSearchTrigger } from "@workspace/search"
|
||||
|
||||
import { useTranslate } from "../lib/locale"
|
||||
|
||||
export function SearchTrigger() {
|
||||
const t = useTranslate()
|
||||
const label = t({ zh: "搜索文档", us: "Search documentation" })
|
||||
|
||||
return (
|
||||
<RawSearchTrigger
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-2 text-slate-500 transition-colors sm:hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 sm:px-3 dark:text-slate-400 sm:dark:hover:bg-slate-900 dark:hover:text-slate-100"
|
||||
>
|
||||
<Icon name="tabler:search" className="size-5" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<kbd className="hidden rounded border border-slate-200 bg-white px-1.5 py-0.5 font-sans text-[0.6875rem] leading-none text-slate-400 lg:inline dark:border-slate-700 dark:bg-slate-950 dark:text-slate-500">
|
||||
⌘K
|
||||
</kbd>
|
||||
</RawSearchTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { getLocalizedHref } from "../content/registry"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
export type SettingsLinkProps = Omit<
|
||||
ComponentProps<"a">,
|
||||
"children" | "href"
|
||||
> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function SettingsLink({
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: SettingsLinkProps) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const accessibleLabel = label ?? t({ zh: "设置", us: "Settings" })
|
||||
|
||||
return (
|
||||
<a
|
||||
href={getLocalizedHref("/settings", locale)}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
className={cn(
|
||||
"inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:text-slate-400 dark:hover:text-slate-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon name="tabler:settings" className="size-5" />
|
||||
<span className="sr-only">{accessibleLabel}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
export type SwatchProps = Omit<ComponentProps<"button">, "children"> & {
|
||||
light: string
|
||||
dark?: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export function Swatch({
|
||||
className,
|
||||
light,
|
||||
dark = light,
|
||||
selected = false,
|
||||
...props
|
||||
}: SwatchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"relative inline-flex flex-col size-10 shrink-0 cursor-pointer gap-px overflow-hidden rounded-full border border-slate-200 transition-transform outline-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 active:scale-95 aria-pressed:ring-2 aria-pressed:ring-indigo-500 aria-pressed:ring-offset-2 dark:border-slate-700 dark:ring-offset-(--docs-background-dark) text-xs text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="block size-full dark:hidden"
|
||||
style={{ backgroundColor: light }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden dark:block size-full"
|
||||
style={{ backgroundColor: dark }}
|
||||
/>
|
||||
{selected && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-white/90 text-slate-950 shadow-sm ring-1 ring-black/10">
|
||||
<Icon name="tabler:check" className="size-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { useResolvedTheme } from "../lib/appearance"
|
||||
import { useTranslate } from "../lib/locale"
|
||||
|
||||
export function ThemeSwitch({ className }: { className?: string }) {
|
||||
const t = useTranslate()
|
||||
const [, setThemeMode] = usePreference("theme-mode")
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const dark = resolvedTheme === "dark"
|
||||
const label = dark
|
||||
? t({ zh: "切换到浅色主题", us: "Switch to light theme" })
|
||||
: t({ zh: "切换到深色主题", us: "Switch to dark theme" })
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={dark}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={[
|
||||
"relative inline-flex h-7 w-13 shrink-0 cursor-pointer items-center rounded-full border transition-colors duration-200",
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 motion-reduce:transition-none",
|
||||
dark
|
||||
? "border-zinc-700 bg-zinc-900 shadow-inner hover:border-zinc-600"
|
||||
: "border-slate-300 bg-slate-100 shadow-inner hover:border-slate-400",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => {
|
||||
setThemeMode(dark ? "light" : "dark")
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
"absolute top-px left-px inline-flex size-6 items-center justify-center rounded-full ring-1 shadow-sm transition-transform duration-200 ease-out motion-reduce:transition-none",
|
||||
dark
|
||||
? "translate-x-6 bg-black text-zinc-100 ring-zinc-700"
|
||||
: "translate-x-0 bg-white text-slate-500 ring-slate-200",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon
|
||||
name="tabler:sun-high"
|
||||
altName="tabler:moon"
|
||||
showAlt={dark}
|
||||
className="z-10 size-4"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
defaultDocLocale,
|
||||
docLocales,
|
||||
docSections,
|
||||
type DocLocale,
|
||||
type DocLocation,
|
||||
type DocPackage,
|
||||
type DocPage,
|
||||
type DocSection,
|
||||
type DocTocItem,
|
||||
type MdxComponents,
|
||||
} from "./types"
|
||||
import { trans, type Translation } from "../lib/locale"
|
||||
|
||||
type DocModule = {
|
||||
default: React.ComponentType<{ components?: MdxComponents }>
|
||||
frontmatter: Record<string, unknown>
|
||||
}
|
||||
|
||||
type LocalizedText = Translation<string>
|
||||
|
||||
type PackageDefinition = {
|
||||
key: string
|
||||
label: string
|
||||
description: LocalizedText
|
||||
}
|
||||
|
||||
type ParsedDocPage = DocPage & {
|
||||
packageKey: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const packageDefinitions: readonly PackageDefinition[] = [
|
||||
{
|
||||
key: "blocks",
|
||||
label: "Blocks",
|
||||
description: {
|
||||
zh: "构建应用外壳,并接入导航、通知、聊天、媒体和外观控制。",
|
||||
us: "Build an application shell and add navigation, notifications, chat, media, and appearance controls.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "i18n",
|
||||
label: "I18n",
|
||||
description: {
|
||||
zh: "接入应用自有词典、本地化运行时、开发工具和完整翻译工作流。",
|
||||
us: "Add application-owned catalogs, localized runtime rendering, development tools, and translation workflows.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lexical",
|
||||
label: "Lexical",
|
||||
description: {
|
||||
zh: "从根节点和内容区域开始,逐步构建支持自定义操作与媒体的富文本编辑器。",
|
||||
us: "Build a rich-text editor progressively from its root and content surface to custom actions and media.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "Preferences",
|
||||
description: {
|
||||
zh: "使用 SSR 快照、持久化回调和可组合副作用管理类型安全且可扩展的应用偏好。",
|
||||
us: "Manage typed, extensible application preferences with SSR snapshots, persistence callbacks, and composable effects.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: "Search",
|
||||
description: {
|
||||
zh: "通过可替换的 Adapter、分组结果、分页和搜索历史构建应用级搜索体验。",
|
||||
us: "Build application search with a replaceable adapter, grouped results, pagination, and search history.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ui",
|
||||
label: "UI",
|
||||
description: {
|
||||
zh: "配置共享 UI 层,并学习组合组件、Hooks、图标和本地化词典。",
|
||||
us: "Set up the shared UI layer and learn how to compose its components, hooks, icons, and locale catalogs.",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const docModules = import.meta.glob<DocModule>(
|
||||
"../../../packages/*/docs/*/**/*.mdx",
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
const docSources = import.meta.glob<string>(
|
||||
"../../../packages/*/docs/*/**/*.mdx",
|
||||
{ eager: true, import: "default", query: "?docs-search-raw" }
|
||||
)
|
||||
|
||||
const parsedPages = Object.entries(docModules).map(([path, module]) =>
|
||||
parseDocPage(path, module, docSources[path] ?? "")
|
||||
)
|
||||
|
||||
const packagesByLocale: Record<DocLocale, readonly DocPackage[]> = {
|
||||
"zh-Hans": packageDefinitions.map((definition) =>
|
||||
createDocPackage(definition, "zh-Hans")
|
||||
),
|
||||
"en-US": packageDefinitions.map((definition) =>
|
||||
createDocPackage(definition, "en-US")
|
||||
),
|
||||
}
|
||||
|
||||
validateLocalizedPagePairs(packagesByLocale)
|
||||
|
||||
export function getDocPackages(locale: DocLocale = defaultDocLocale) {
|
||||
return packagesByLocale[locale]
|
||||
}
|
||||
|
||||
export function getDocHref(docPackage: DocPackage, page?: DocPage) {
|
||||
const prefix = getLocalePrefix(docPackage.locale)
|
||||
const target = page ?? getOverviewPage(docPackage)
|
||||
const pagePath =
|
||||
target.section === "overview"
|
||||
? ""
|
||||
: `/${target.section}/${encodeURIComponent(target.slug)}`
|
||||
|
||||
return `${prefix}/articles/${docPackage.key}${pagePath}`
|
||||
}
|
||||
|
||||
export function getDocsHomeHref(locale: DocLocale) {
|
||||
return getLocalePrefix(locale) || "/"
|
||||
}
|
||||
|
||||
export function getLocalizedHref(pathname: string, locale: DocLocale) {
|
||||
if (/^(?:\/(?:en-US|zh-Hans))?\/settings\/?$/.test(pathname)) {
|
||||
return `${getLocalePrefix(locale)}/settings`
|
||||
}
|
||||
|
||||
const location = resolveDocLocation(pathname)
|
||||
if (!location) return getDocsHomeHref(locale)
|
||||
|
||||
const docPackage = getDocPackages(locale).find(
|
||||
(item) => item.key === location.package.key
|
||||
)
|
||||
const page = docPackage?.pages.find(
|
||||
(item) =>
|
||||
item.section === location.page.section && item.slug === location.page.slug
|
||||
)
|
||||
|
||||
return docPackage && page
|
||||
? getDocHref(docPackage, page)
|
||||
: getDocsHomeHref(locale)
|
||||
}
|
||||
|
||||
export function resolveDocLocale(pathname: string): DocLocale {
|
||||
const firstSegment = pathname.split("/").filter(Boolean)[0]
|
||||
return isDocLocale(firstSegment) ? firstSegment : defaultDocLocale
|
||||
}
|
||||
|
||||
export function resolveDocLocation(pathname: string): DocLocation | undefined {
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let locale: DocLocale = defaultDocLocale
|
||||
if (isDocLocale(segments[0])) {
|
||||
locale = segments.shift() as DocLocale
|
||||
}
|
||||
const [resource, packageKey, sectionValue, pageSlug, ...extra] = segments
|
||||
|
||||
if (resource !== "articles" || extra.length > 0) return undefined
|
||||
|
||||
const docPackage = getDocPackages(locale).find(
|
||||
(item) => item.key === packageKey
|
||||
)
|
||||
if (!docPackage) return undefined
|
||||
|
||||
if (sectionValue === undefined) {
|
||||
const page = getOverviewPage(docPackage)
|
||||
return { locale, package: docPackage, page }
|
||||
}
|
||||
|
||||
if (sectionValue === "overview" || !isDocSection(sectionValue) || !pageSlug) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const page = docPackage.pages.find(
|
||||
(item) => item.section === sectionValue && item.slug === pageSlug
|
||||
)
|
||||
|
||||
return page ? { locale, package: docPackage, page } : undefined
|
||||
}
|
||||
|
||||
export function getDocSectionLabel(section: DocSection, locale: DocLocale) {
|
||||
const labels: Record<DocSection, Translation> = {
|
||||
overview: { zh: "概览", us: "Overview" },
|
||||
guide: { zh: "指南", us: "Guide" },
|
||||
advanced: { zh: "进阶", us: "Advanced" },
|
||||
examples: { zh: "示例", us: "Examples" },
|
||||
}
|
||||
|
||||
return trans(locale, labels[section])
|
||||
}
|
||||
|
||||
export function getDocSearchText(page: DocPage) {
|
||||
return (page as ParsedDocPage).searchText
|
||||
}
|
||||
|
||||
function createDocPackage(
|
||||
definition: PackageDefinition,
|
||||
locale: DocLocale
|
||||
): DocPackage {
|
||||
const pages = parsedPages
|
||||
.filter(
|
||||
(page) => page.packageKey === definition.key && page.locale === locale
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
docSections.indexOf(left.section) -
|
||||
docSections.indexOf(right.section) ||
|
||||
left.order - right.order ||
|
||||
left.slug.localeCompare(right.slug)
|
||||
)
|
||||
|
||||
const overviewCount = pages.filter(
|
||||
(page) => page.section === "overview"
|
||||
).length
|
||||
if (overviewCount !== 1) {
|
||||
throw new Error(
|
||||
`${definition.key}/${locale} must contain exactly one docs/${locale}/overview.mdx file.`
|
||||
)
|
||||
}
|
||||
|
||||
for (const section of docSections.slice(1)) {
|
||||
if (!pages.some((page) => page.section === section)) {
|
||||
throw new Error(
|
||||
`${definition.key}/${locale} must contain at least one ${section} document.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
description: trans(locale, definition.description),
|
||||
locale,
|
||||
pages,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDocPage(
|
||||
path: string,
|
||||
module: DocModule,
|
||||
source: string
|
||||
): ParsedDocPage {
|
||||
const match = path.match(
|
||||
/\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid documentation path "${path}". Expected docs/<locale>/overview.mdx or docs/<locale>/<guide|advanced|examples>/*.mdx.`
|
||||
)
|
||||
}
|
||||
|
||||
const [, packageKey, localeValue, sectionValue, nestedSlug] = match
|
||||
if (!isDocLocale(localeValue)) {
|
||||
throw new Error(
|
||||
`Unsupported documentation locale "${localeValue}" in "${path}".`
|
||||
)
|
||||
}
|
||||
if (!isDocSection(sectionValue)) {
|
||||
throw new Error(`Unsupported documentation section in "${path}".`)
|
||||
}
|
||||
if (
|
||||
(sectionValue === "overview" && nestedSlug !== undefined) ||
|
||||
(sectionValue !== "overview" && nestedSlug === undefined)
|
||||
) {
|
||||
throw new Error(`Documentation file is in the wrong directory: "${path}".`)
|
||||
}
|
||||
|
||||
const frontmatter = module.frontmatter
|
||||
return {
|
||||
Content: module.default,
|
||||
description: readString(frontmatter, "description"),
|
||||
locale: localeValue,
|
||||
order: readNumber(frontmatter, "order"),
|
||||
packageKey,
|
||||
searchText: source,
|
||||
section: sectionValue,
|
||||
slug: nestedSlug ?? "overview",
|
||||
title: readString(frontmatter, "title"),
|
||||
toc: readToc(frontmatter.toc),
|
||||
}
|
||||
}
|
||||
|
||||
function validateLocalizedPagePairs(
|
||||
packages: Record<DocLocale, readonly DocPackage[]>
|
||||
) {
|
||||
for (const definition of packageDefinitions) {
|
||||
const keysByLocale = docLocales.map((locale) => {
|
||||
const docPackage = packages[locale].find(
|
||||
(item) => item.key === definition.key
|
||||
)!
|
||||
return docPackage.pages.map((page) => `${page.section}/${page.slug}`)
|
||||
})
|
||||
|
||||
if (keysByLocale[0]?.join("\n") !== keysByLocale[1]?.join("\n")) {
|
||||
throw new Error(
|
||||
`${definition.key} documentation must contain matching zh-Hans and en-US page paths.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOverviewPage(docPackage: DocPackage) {
|
||||
return docPackage.pages.find((page) => page.section === "overview")!
|
||||
}
|
||||
|
||||
function getLocalePrefix(locale: DocLocale) {
|
||||
return locale === defaultDocLocale ? "" : `/${locale}`
|
||||
}
|
||||
|
||||
function isDocLocale(value: unknown): value is DocLocale {
|
||||
return docLocales.some((locale) => locale === value)
|
||||
}
|
||||
|
||||
function isDocSection(value: unknown): value is DocSection {
|
||||
return docSections.some((section) => section === value)
|
||||
}
|
||||
|
||||
function readString(frontmatter: Record<string, unknown>, key: string) {
|
||||
const value = frontmatter[key]
|
||||
if (typeof value === "string" && value.trim()) return value
|
||||
throw new Error(`Documentation frontmatter requires a string "${key}".`)
|
||||
}
|
||||
|
||||
function readNumber(frontmatter: Record<string, unknown>, key: string) {
|
||||
const value = frontmatter[key]
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
throw new Error(`Documentation frontmatter requires a number "${key}".`)
|
||||
}
|
||||
|
||||
function readToc(value: unknown): readonly DocTocItem[] {
|
||||
if (value === undefined) return []
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Documentation frontmatter "toc" must be an array.')
|
||||
}
|
||||
|
||||
return value.map((item) => {
|
||||
if (!isRecord(item)) {
|
||||
throw new Error('Each documentation "toc" entry must be an object.')
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(item, "id"),
|
||||
title: readString(item, "title"),
|
||||
items: item.items === undefined ? undefined : readToc(item.items),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ComponentType, ElementType } from "react"
|
||||
|
||||
export const docLocales = ["zh-Hans", "en-US"] as const
|
||||
export const defaultDocLocale = "zh-Hans" as const
|
||||
export const docSections = [
|
||||
"overview",
|
||||
"guide",
|
||||
"advanced",
|
||||
"examples",
|
||||
] as const
|
||||
|
||||
export type DocLocale = (typeof docLocales)[number]
|
||||
export type DocSection = (typeof docSections)[number]
|
||||
export type MdxComponents = Record<string, ElementType>
|
||||
|
||||
export type DocTocItem = {
|
||||
id: string
|
||||
title: string
|
||||
items?: readonly DocTocItem[]
|
||||
}
|
||||
|
||||
export type DocPage = {
|
||||
Content: ComponentType<{ components?: MdxComponents }>
|
||||
description: string
|
||||
locale: DocLocale
|
||||
order: number
|
||||
section: DocSection
|
||||
slug: string
|
||||
title: string
|
||||
toc: readonly DocTocItem[]
|
||||
}
|
||||
|
||||
export type DocPackage = {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
locale: DocLocale
|
||||
pages: readonly DocPage[]
|
||||
}
|
||||
|
||||
export type DocLocation = {
|
||||
locale: DocLocale
|
||||
package: DocPackage
|
||||
page: DocPage
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/search/globals.css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@apply border-slate-200 dark:border-slate-800;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply antialiased;
|
||||
background-color: var(--docs-background-light, var(--color-white));
|
||||
color: var(--docs-foreground-light, var(--color-slate-950));
|
||||
font-family: "DM Serif Display", Georgia, serif;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: var(--docs-background-dark, var(--color-slate-950));
|
||||
color: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: var(--docs-background-light, var(--color-white));
|
||||
--foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--card: var(--docs-background-light, var(--color-white));
|
||||
--card-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--popover: var(--docs-background-light, var(--color-white));
|
||||
--popover-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--primary: var(--color-indigo-600);
|
||||
--primary-foreground: var(--color-white);
|
||||
--secondary: var(--docs-surface-light, var(--color-slate-100));
|
||||
--secondary-foreground: var(
|
||||
--docs-foreground-light,
|
||||
var(--color-slate-950)
|
||||
);
|
||||
--muted: var(--docs-surface-light, var(--color-slate-100));
|
||||
--muted-foreground: var(--docs-muted-light, var(--color-slate-600));
|
||||
--accent: var(--docs-surface-light, var(--color-slate-100));
|
||||
--accent-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--border: var(--color-slate-200);
|
||||
--input: var(--color-slate-200);
|
||||
--ring: var(--color-indigo-500);
|
||||
--sidebar: var(--docs-background-light, var(--color-white));
|
||||
--sidebar-foreground: var(--docs-foreground-light, var(--color-slate-950));
|
||||
--sidebar-border: var(--color-slate-200);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--background: var(--docs-background-dark, var(--color-slate-950));
|
||||
--foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--card: var(--docs-surface-dark, var(--color-slate-900));
|
||||
--card-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--popover: var(--docs-surface-dark, var(--color-slate-900));
|
||||
--popover-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--primary: var(--color-indigo-400);
|
||||
--primary-foreground: var(--color-slate-950);
|
||||
--secondary: var(--color-slate-800);
|
||||
--secondary-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--muted: var(--color-slate-800);
|
||||
--muted-foreground: var(--docs-muted-dark, var(--color-slate-400));
|
||||
--accent: var(--color-slate-800);
|
||||
--accent-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--border: var(--color-slate-800);
|
||||
--input: var(--color-slate-700);
|
||||
--ring: var(--color-indigo-400);
|
||||
--sidebar: var(--docs-surface-dark, var(--color-slate-900));
|
||||
--sidebar-foreground: var(--docs-foreground-dark, var(--color-slate-100));
|
||||
--sidebar-border: var(--color-slate-800);
|
||||
}
|
||||
|
||||
body,
|
||||
#root {
|
||||
@apply min-h-dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-inherit text-inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-indigo-200 text-indigo-950 dark:bg-indigo-500/40 dark:text-indigo-50;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useSyncExternalStore } from "react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
import colors from "tailwindcss/colors"
|
||||
|
||||
export const themeModes = ["light", "dark", "system"] as const
|
||||
export const accentColors = [
|
||||
"neutral",
|
||||
"blue",
|
||||
"cyan",
|
||||
"emerald",
|
||||
"fuchsia",
|
||||
"green",
|
||||
"indigo",
|
||||
"lime",
|
||||
"orange",
|
||||
"pink",
|
||||
"purple",
|
||||
"red",
|
||||
"rose",
|
||||
"sky",
|
||||
"teal",
|
||||
"violet",
|
||||
"yellow",
|
||||
] as const
|
||||
export const backgroundShades = [
|
||||
"neutral",
|
||||
"stone",
|
||||
"zinc",
|
||||
"mauve",
|
||||
"olive",
|
||||
"mist",
|
||||
"taupe",
|
||||
] as const
|
||||
export const foregroundShades = ["slate", "neutral", "stone"] as const
|
||||
|
||||
export type ThemeMode = (typeof themeModes)[number]
|
||||
export type AccentColor = (typeof accentColors)[number]
|
||||
export type BackgroundShade = (typeof backgroundShades)[number]
|
||||
export type ForegroundShade = (typeof foregroundShades)[number]
|
||||
export type ResolvedTheme = Exclude<ThemeMode, "system">
|
||||
|
||||
export type Appearance = {
|
||||
accentColor: AccentColor
|
||||
backgroundShade: BackgroundShade
|
||||
foregroundShade: ForegroundShade
|
||||
themeMode: ThemeMode
|
||||
}
|
||||
|
||||
export type AppearanceController = Appearance & {
|
||||
updateAppearance: (update: Partial<Appearance>) => void
|
||||
}
|
||||
|
||||
const systemThemeQuery = "(prefers-color-scheme: dark)"
|
||||
const colorSteps = [
|
||||
50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
|
||||
] as const
|
||||
|
||||
type ColorStep = (typeof colorSteps)[number]
|
||||
type ColorScale = Record<ColorStep, string>
|
||||
|
||||
export function useAppearance(): AppearanceController {
|
||||
const [accentColor, setAccentColor] = usePreference("accent-color")
|
||||
const [backgroundShade, setBackgroundShade] =
|
||||
usePreference("background-shade")
|
||||
const [foregroundShade, setForegroundShade] =
|
||||
usePreference("foreground-shade")
|
||||
const [themeMode, setThemeMode] = usePreference("theme-mode")
|
||||
|
||||
const updateAppearance = useCallback(
|
||||
(update: Partial<Appearance>) => {
|
||||
if (update.accentColor !== undefined) {
|
||||
setAccentColor(update.accentColor)
|
||||
}
|
||||
if (update.backgroundShade !== undefined) {
|
||||
setBackgroundShade(update.backgroundShade)
|
||||
}
|
||||
if (update.foregroundShade !== undefined) {
|
||||
setForegroundShade(update.foregroundShade)
|
||||
}
|
||||
if (update.themeMode !== undefined) setThemeMode(update.themeMode)
|
||||
},
|
||||
[setAccentColor, setBackgroundShade, setForegroundShade, setThemeMode]
|
||||
)
|
||||
|
||||
return {
|
||||
accentColor,
|
||||
backgroundShade,
|
||||
foregroundShade,
|
||||
themeMode,
|
||||
updateAppearance,
|
||||
}
|
||||
}
|
||||
|
||||
export function useResolvedTheme() {
|
||||
const [themeMode] = usePreference("theme-mode")
|
||||
const systemTheme = useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemTheme,
|
||||
() => "light" as const
|
||||
)
|
||||
|
||||
return themeMode === "system" ? systemTheme : themeMode
|
||||
}
|
||||
|
||||
export function getPaletteColor(
|
||||
color: AccentColor | BackgroundShade | ForegroundShade,
|
||||
step: ColorStep = 500
|
||||
) {
|
||||
return getColorScale(color)[step]
|
||||
}
|
||||
|
||||
export function applyAppearance(appearance: Appearance) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const resolvedTheme =
|
||||
appearance.themeMode === "system" ? getSystemTheme() : appearance.themeMode
|
||||
const accentScale = getColorScale(appearance.accentColor)
|
||||
const backgroundScale = getColorScale(appearance.backgroundShade)
|
||||
const foregroundScale = getColorScale(appearance.foregroundShade)
|
||||
|
||||
root.classList.toggle("dark", resolvedTheme === "dark")
|
||||
root.style.colorScheme = resolvedTheme
|
||||
root.dataset.themeMode = appearance.themeMode
|
||||
root.dataset.accentColor = appearance.accentColor
|
||||
root.dataset.backgroundShade = appearance.backgroundShade
|
||||
root.dataset.foregroundShade = appearance.foregroundShade
|
||||
|
||||
for (const step of colorSteps) {
|
||||
root.style.setProperty(`--color-indigo-${step}`, accentScale[step])
|
||||
}
|
||||
|
||||
root.style.setProperty("--docs-background-light", backgroundScale[50])
|
||||
root.style.setProperty("--docs-background-dark", backgroundScale[950])
|
||||
root.style.setProperty("--docs-surface-light", backgroundScale[100])
|
||||
root.style.setProperty("--docs-surface-dark", backgroundScale[900])
|
||||
root.style.setProperty("--docs-foreground-light", foregroundScale[950])
|
||||
root.style.setProperty("--docs-foreground-dark", foregroundScale[50])
|
||||
root.style.setProperty("--docs-muted-light", foregroundScale[600])
|
||||
root.style.setProperty("--docs-muted-dark", foregroundScale[400])
|
||||
}
|
||||
|
||||
export function subscribeToSystemTheme(listener: VoidFunction) {
|
||||
const mediaQuery = window.matchMedia(systemThemeQuery)
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
return () => mediaQuery.removeEventListener("change", listener)
|
||||
}
|
||||
|
||||
export function getSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
function getColorScale(color: string) {
|
||||
return colors[color as keyof typeof colors] as ColorScale
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getLocalizedHref } from "../content/registry"
|
||||
import type { DocLocale } from "../content/types"
|
||||
|
||||
export function navigateToLocale(locale: DocLocale, replace = false) {
|
||||
const href = getLocalizedHref(window.location.pathname, locale)
|
||||
if (href === window.location.pathname) return
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(null, "", href)
|
||||
} else {
|
||||
window.history.pushState(null, "", href)
|
||||
}
|
||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useCallback, useContext } from "react"
|
||||
|
||||
import { defaultDocLocale, type DocLocale } from "../content/types"
|
||||
|
||||
export type Translation<Value = string> = Readonly<{
|
||||
zh: Value
|
||||
us: Value
|
||||
}>
|
||||
|
||||
export type Translate = <Value>(translation: Translation<Value>) => Value
|
||||
|
||||
export const LocaleContext = createContext<DocLocale>(defaultDocLocale)
|
||||
|
||||
export function useLocale() {
|
||||
return useContext(LocaleContext)
|
||||
}
|
||||
|
||||
export function useTranslate(): Translate {
|
||||
const locale = useLocale()
|
||||
|
||||
return useCallback(
|
||||
<Value>(translation: Translation<Value>) => trans(locale, translation),
|
||||
[locale]
|
||||
)
|
||||
}
|
||||
|
||||
export function trans<Value>(
|
||||
locale: DocLocale,
|
||||
translation: Translation<Value>
|
||||
): Value {
|
||||
return locale === "zh-Hans" ? translation.zh : translation.us
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
createPreferenceUpdateGuard,
|
||||
type PreferenceDefinitions,
|
||||
type PreferenceEffect,
|
||||
type PreferenceUpdate,
|
||||
type Preferences,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import { defaultDocLocale, docLocales, type DocLocale } from "../content/types"
|
||||
import {
|
||||
accentColors,
|
||||
applyAppearance,
|
||||
backgroundShades,
|
||||
foregroundShades,
|
||||
subscribeToSystemTheme,
|
||||
themeModes,
|
||||
type AccentColor,
|
||||
type Appearance,
|
||||
type BackgroundShade,
|
||||
type ForegroundShade,
|
||||
type ThemeMode,
|
||||
} from "./appearance"
|
||||
|
||||
export type LocaleMode = "auto" | "manual"
|
||||
|
||||
export type DocsPreferences = {
|
||||
"accent-color": AccentColor
|
||||
"background-shade": BackgroundShade
|
||||
"foreground-shade": ForegroundShade
|
||||
"locale-mode": LocaleMode
|
||||
locale: DocLocale
|
||||
"theme-mode": ThemeMode
|
||||
}
|
||||
|
||||
declare module "@workspace/preferences" {
|
||||
interface PreferencesCustom extends DocsPreferences {}
|
||||
}
|
||||
|
||||
export const docsPreferenceDefinitions = {
|
||||
"accent-color": createStringPreference(
|
||||
"docs-accent-color",
|
||||
"indigo",
|
||||
accentColors
|
||||
),
|
||||
"background-shade": createStringPreference(
|
||||
"docs-background-shade",
|
||||
"neutral",
|
||||
backgroundShades
|
||||
),
|
||||
"foreground-shade": createStringPreference(
|
||||
"docs-foreground-shade",
|
||||
"slate",
|
||||
foregroundShades
|
||||
),
|
||||
"locale-mode": {
|
||||
cookie: "docs-locale-auto-detect",
|
||||
defaultValue: "manual",
|
||||
is: (value): value is LocaleMode => value === "auto" || value === "manual",
|
||||
parse: (value) => (value === "1" || value === "auto" ? "auto" : "manual"),
|
||||
serialize: (value) => (value === "auto" ? "1" : "0"),
|
||||
},
|
||||
locale: createStringPreference("docs-locale", defaultDocLocale, docLocales),
|
||||
"theme-mode": createStringPreference("docs-theme", "system", themeModes),
|
||||
} satisfies PreferenceDefinitions<DocsPreferences>
|
||||
|
||||
export const isDocsPreferenceUpdate = createPreferenceUpdateGuard(
|
||||
docsPreferenceDefinitions
|
||||
)
|
||||
|
||||
export const docsPreferenceEffects = [
|
||||
{
|
||||
layoutEffect: ({ preferences }) => {
|
||||
applyAppearance(toAppearance(preferences))
|
||||
},
|
||||
effect: ({ preferences, store }) => {
|
||||
if (preferences["theme-mode"] !== "system") return
|
||||
|
||||
return subscribeToSystemTheme(() => {
|
||||
applyAppearance(toAppearance(store.getSnapshot()))
|
||||
})
|
||||
},
|
||||
},
|
||||
] satisfies readonly PreferenceEffect[]
|
||||
|
||||
export function initializeDocsPreferences() {
|
||||
applyAppearance(toAppearance(readDocsPreferences()))
|
||||
}
|
||||
|
||||
export function readDocsPreferences(
|
||||
fallbackLocale: DocLocale = defaultDocLocale
|
||||
): Preferences {
|
||||
const preferences = Object.fromEntries(
|
||||
(
|
||||
Object.keys(docsPreferenceDefinitions) as Array<keyof DocsPreferences>
|
||||
).map((key) => {
|
||||
const definition = docsPreferenceDefinitions[key]
|
||||
return [key, readStoredValue(definition.cookie, definition.parse)]
|
||||
})
|
||||
) as DocsPreferences
|
||||
|
||||
if (!readStoredString(docsPreferenceDefinitions.locale.cookie)) {
|
||||
preferences.locale = fallbackLocale
|
||||
}
|
||||
|
||||
return preferences
|
||||
}
|
||||
|
||||
export function persistDocsPreference(update: PreferenceUpdate) {
|
||||
const definition = docsPreferenceDefinitions[update.key]
|
||||
const serializedValue =
|
||||
update.key === "locale-mode"
|
||||
? docsPreferenceDefinitions["locale-mode"].serialize(update.value)
|
||||
: update.value
|
||||
writeStoredString(definition.cookie, serializedValue)
|
||||
}
|
||||
|
||||
export function parseDocsPreferences(value: unknown): Preferences | undefined {
|
||||
if (!value || typeof value !== "object") return undefined
|
||||
|
||||
const candidate = value as Partial<Record<keyof DocsPreferences, unknown>>
|
||||
for (const key of Object.keys(docsPreferenceDefinitions) as Array<
|
||||
keyof DocsPreferences
|
||||
>) {
|
||||
if (!docsPreferenceDefinitions[key].is(candidate[key])) return undefined
|
||||
}
|
||||
|
||||
return candidate as DocsPreferences
|
||||
}
|
||||
|
||||
export function toAppearance(preferences: Preferences): Appearance {
|
||||
return {
|
||||
accentColor: preferences["accent-color"],
|
||||
backgroundShade: preferences["background-shade"],
|
||||
foregroundShade: preferences["foreground-shade"],
|
||||
themeMode: preferences["theme-mode"],
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLocale(): DocLocale {
|
||||
const languages = navigator.languages.length
|
||||
? navigator.languages
|
||||
: [navigator.language]
|
||||
return languages.some((language) => language.toLowerCase().startsWith("zh"))
|
||||
? "zh-Hans"
|
||||
: "en-US"
|
||||
}
|
||||
|
||||
function createStringPreference<const Options extends readonly string[]>(
|
||||
cookie: string,
|
||||
defaultValue: Options[number],
|
||||
options: Options
|
||||
) {
|
||||
const is = (value: unknown): value is Options[number] =>
|
||||
typeof value === "string" && options.some((option) => option === value)
|
||||
|
||||
return {
|
||||
cookie,
|
||||
defaultValue,
|
||||
is,
|
||||
parse: (value: string | undefined) => (is(value) ? value : defaultValue),
|
||||
serialize: (value: Options[number]) => value,
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredValue<Value>(
|
||||
key: string,
|
||||
parse: (value: string | undefined) => Value
|
||||
) {
|
||||
return parse(readStoredString(key))
|
||||
}
|
||||
|
||||
function readStoredString(key: string) {
|
||||
if (typeof window === "undefined") return undefined
|
||||
|
||||
try {
|
||||
return window.sessionStorage.getItem(key) ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredString(key: string, value: string) {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
// The in-memory preference remains active when storage is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export type { ClassValue }
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import App from "./App.tsx"
|
||||
import "./index.css"
|
||||
import { initializeDocsPreferences } from "./lib/preferences"
|
||||
|
||||
document.documentElement.lang = window.location.pathname.startsWith("/en-US")
|
||||
? "en-US"
|
||||
: "zh-Hans"
|
||||
|
||||
initializeDocsPreferences()
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,704 @@
|
||||
import * as React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Icon } from "@icones/react"
|
||||
import {
|
||||
getDocHref,
|
||||
getDocPackages,
|
||||
getDocSectionLabel,
|
||||
getDocsHomeHref,
|
||||
resolveDocLocation,
|
||||
} from "../content/registry"
|
||||
import type {
|
||||
DocLocation,
|
||||
DocPage,
|
||||
DocTocItem,
|
||||
} from "../content/types"
|
||||
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
|
||||
import { mdxComponents } from "../components/mdx"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
|
||||
const noTocItems: readonly DocTocItem[] = []
|
||||
|
||||
export default function ArticlePage() {
|
||||
const t = useTranslate()
|
||||
const docLocation = resolveDocLocation(window.location.pathname)
|
||||
const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems)
|
||||
const [mobilePanel, setMobilePanel] = React.useState<
|
||||
"navigation" | "toc" | null
|
||||
>(null)
|
||||
const mobilePanelRef = React.useRef<HTMLDivElement>(null)
|
||||
const navigationTriggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
const tocTriggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mobilePanel) return
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
const unavailableAt = window.matchMedia(
|
||||
mobilePanel === "navigation" ? "(min-width: 48rem)" : "(min-width: 64rem)"
|
||||
)
|
||||
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
mobilePanelRef.current
|
||||
?.querySelector<HTMLElement>(
|
||||
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
?.focus()
|
||||
})
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
setMobilePanel(null)
|
||||
const trigger =
|
||||
mobilePanel === "navigation"
|
||||
? navigationTriggerRef.current
|
||||
: tocTriggerRef.current
|
||||
trigger?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") return
|
||||
|
||||
const focusableElements = Array.from(
|
||||
mobilePanelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
) ?? []
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements.at(-1)
|
||||
|
||||
if (!firstElement || !lastElement) return
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault()
|
||||
lastElement.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault()
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleBreakpointChange(event: MediaQueryListEvent) {
|
||||
if (event.matches) setMobilePanel(null)
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
unavailableAt.addEventListener("change", handleBreakpointChange)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame)
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
unavailableAt.removeEventListener("change", handleBreakpointChange)
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [mobilePanel])
|
||||
|
||||
if (!docLocation) return <ArticleNotFound />
|
||||
|
||||
const currentIndex = docLocation.package.pages.indexOf(docLocation.page)
|
||||
const previousPage = docLocation.package.pages[currentIndex - 1]
|
||||
const nextPage = docLocation.package.pages[currentIndex + 1]
|
||||
const previous = previousPage
|
||||
? {
|
||||
locale: docLocation.locale,
|
||||
package: docLocation.package,
|
||||
page: previousPage,
|
||||
}
|
||||
: undefined
|
||||
const next = nextPage
|
||||
? {
|
||||
locale: docLocation.locale,
|
||||
package: docLocation.package,
|
||||
page: nextPage,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
data-article-sticky-header
|
||||
className="z-10 h-16 border-b bg-white/70 backdrop-blur-sm md:sticky md:top-0 dark:border-slate-800 dark:bg-slate-950/80"
|
||||
>
|
||||
<Container className="flex size-full items-center gap-4">
|
||||
<HomeButton href={getDocsHomeHref(docLocation.locale)} />
|
||||
<SlashLine />
|
||||
<h1 className="truncate">{docLocation.package.label}</h1>
|
||||
<Actions className="ml-auto" />
|
||||
</Container>
|
||||
</header>
|
||||
|
||||
<section
|
||||
data-article-sticky-header
|
||||
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 backdrop-blur-sm md:hidden dark:border-slate-800 dark:bg-slate-950/80"
|
||||
>
|
||||
<button
|
||||
ref={navigationTriggerRef}
|
||||
type="button"
|
||||
className="inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
|
||||
aria-controls="article-mobile-navigation"
|
||||
aria-expanded={mobilePanel === "navigation"}
|
||||
onClick={() =>
|
||||
setMobilePanel((current) =>
|
||||
current === "navigation" ? null : "navigation"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon name="tabler:menu" className="size-5" />
|
||||
<span>{t({ zh: "文档菜单", us: "Docs Menu" })}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={tocTriggerRef}
|
||||
type="button"
|
||||
className="ml-auto inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
|
||||
aria-controls="article-mobile-toc"
|
||||
aria-expanded={mobilePanel === "toc"}
|
||||
onClick={() =>
|
||||
setMobilePanel((current) => (current === "toc" ? null : "toc"))
|
||||
}
|
||||
>
|
||||
<span>{t({ zh: "本页内容", us: "On this page" })}</span>
|
||||
<Icon name="tabler:list" className="size-5 rotate-180" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{mobilePanel &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-950/50 backdrop-blur-[1px] dark:bg-black/70"
|
||||
aria-hidden="true"
|
||||
onClick={() => {
|
||||
const trigger =
|
||||
mobilePanel === "navigation"
|
||||
? navigationTriggerRef.current
|
||||
: tocTriggerRef.current
|
||||
setMobilePanel(null)
|
||||
window.requestAnimationFrame(() => trigger?.focus())
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={mobilePanelRef}
|
||||
id={
|
||||
mobilePanel === "navigation"
|
||||
? "article-mobile-navigation"
|
||||
: "article-mobile-toc"
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={
|
||||
mobilePanel === "navigation"
|
||||
? t({ zh: "文档导航", us: "Documentation navigation" })
|
||||
: t({ zh: "本页内容", us: "On this page" })
|
||||
}
|
||||
className={[
|
||||
"absolute inset-y-0 w-[min(88vw,35rem)] overflow-y-auto overscroll-contain bg-white p-7 shadow-2xl dark:bg-slate-950 dark:shadow-black/60",
|
||||
mobilePanel === "navigation" ? "left-0" : "right-0",
|
||||
].join(" ")}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest("a[href]")
|
||||
) {
|
||||
setMobilePanel(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mobilePanel === "navigation" ? (
|
||||
<nav aria-label="Documentation navigation">
|
||||
<Sidebar current={docLocation} />
|
||||
</nav>
|
||||
) : (
|
||||
<TableOfContents
|
||||
activeIds={visibleTocIds}
|
||||
items={docLocation.page.toc}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div className="flex w-full">
|
||||
<div
|
||||
data-slot="left"
|
||||
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r bg-slate-50 md:block dark:border-slate-800 dark:bg-slate-900/50"
|
||||
>
|
||||
<aside className="ml-auto w-full max-w-(--max-lw) p-8">
|
||||
<Sidebar current={docLocation} />
|
||||
</aside>
|
||||
</div>
|
||||
<div
|
||||
data-slot="center"
|
||||
className="min-w-0 flex-1 sm:flex-[0_1_var(--cw)]"
|
||||
>
|
||||
<main className="w-full max-w-(--cw) px-4 pt-8 pb-16 md:px-22">
|
||||
<Header docLocation={docLocation} />
|
||||
<ArticleContent page={docLocation.page} />
|
||||
<Footer next={next} previous={previous} />
|
||||
</main>
|
||||
</div>
|
||||
<div
|
||||
data-slot="right"
|
||||
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-rw) flex-[5_1_var(--min-rw)] lg:block"
|
||||
>
|
||||
<div className="h-full w-full max-w-(--max-rw)">
|
||||
<TableOfContents
|
||||
activeIds={visibleTocIds}
|
||||
items={docLocation.page.toc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<Container className="flex h-full w-full items-center">
|
||||
Copyright © 2026
|
||||
</Container>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({ current }: { current: DocLocation }) {
|
||||
const overview = current.package.pages.find(
|
||||
(page) => page.section === "overview"
|
||||
)
|
||||
const groups = current.package.pages
|
||||
.filter((page) => page.section !== "overview")
|
||||
.reduce<Array<{ section: DocPage["section"]; pages: DocPage[] }>>(
|
||||
(result, page) => {
|
||||
const group = result.at(-1)
|
||||
if (group?.section === page.section) {
|
||||
group.pages.push(page)
|
||||
} else {
|
||||
result.push({ section: page.section, pages: [page] })
|
||||
}
|
||||
return result
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{overview && (
|
||||
<li>
|
||||
<SidebarPageLink current={current} page={overview} prominent />
|
||||
</li>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<li className="not-first:mt-8" key={group.section}>
|
||||
<h2 className="font-semibold">
|
||||
{getDocSectionLabel(group.section, current.locale)}
|
||||
</h2>
|
||||
<ul className="mt-1.5">
|
||||
{group.pages.map((page) => (
|
||||
<li key={`${page.section}/${page.slug}`}>
|
||||
<SidebarPageLink current={current} page={page} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarPageLink({
|
||||
current,
|
||||
page,
|
||||
prominent = false,
|
||||
}: {
|
||||
current: DocLocation
|
||||
page: DocPage
|
||||
prominent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className={`block py-1 transition-colors hover:text-indigo-500 aria-[current=page]:text-indigo-500 dark:hover:text-indigo-300 dark:aria-[current=page]:text-indigo-400 ${prominent ? "font-semibold text-slate-950 dark:text-slate-100" : "text-gray-600 aria-[current=page]:font-medium dark:text-slate-400"}`}
|
||||
href={getDocHref(current.package, page)}
|
||||
aria-current={
|
||||
current.page.section === page.section && current.page.slug === page.slug
|
||||
? "page"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{page.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function Header({ docLocation }: { docLocation: DocLocation }) {
|
||||
return (
|
||||
<header className="mb-12 border-b pb-12">
|
||||
<ul className="flex items-center gap-2">
|
||||
<li>
|
||||
<a
|
||||
href={getDocHref(docLocation.package)}
|
||||
className="transition-colors hover:text-indigo-500 dark:hover:text-indigo-300"
|
||||
>
|
||||
{docLocation.package.label}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-slate-200 dark:text-slate-700">/</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-indigo-500 dark:text-indigo-400">
|
||||
{docLocation.page.title}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<h1 className="mt-4 text-4xl">{docLocation.page.title}</h1>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{docLocation.page.description}
|
||||
</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticleContent({ page }: { page: DocPage }) {
|
||||
const Content = page.Content
|
||||
|
||||
return (
|
||||
<article
|
||||
data-article-content
|
||||
className="space-y-5 text-slate-700 dark:text-slate-300 [&_hr]:my-12 [&_hr]:border-slate-200 dark:[&_hr]:border-slate-800 [&_li]:leading-7 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_p]:leading-7 [&_strong]:font-semibold [&_strong]:text-slate-950 dark:[&_strong]:text-slate-100 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:p-3 dark:[&_td]:border-slate-800 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:p-3 [&_th]:text-left dark:[&_th]:border-slate-800 dark:[&_th]:bg-slate-900 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
|
||||
>
|
||||
<Content components={mdxComponents} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function TableOfContents({
|
||||
activeIds,
|
||||
items,
|
||||
}: {
|
||||
activeIds: readonly string[]
|
||||
items: readonly DocTocItem[]
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
const label = t({ zh: "本页内容", us: "On this page" })
|
||||
const rootRef = React.useRef<HTMLElement>(null)
|
||||
const railRef = React.useRef<HTMLDivElement>(null)
|
||||
const [marker, setMarker] = React.useState<{
|
||||
height: number
|
||||
top: number
|
||||
} | null>(null)
|
||||
const activeKey = activeIds.join("\n")
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
const rail = railRef.current
|
||||
if (!root || !rail) return
|
||||
|
||||
function measureMarker() {
|
||||
const activeIdSet = new Set(activeIds)
|
||||
const activeRows = Array.from(
|
||||
root!.querySelectorAll<HTMLElement>("[data-toc-id]")
|
||||
).filter((row) => activeIdSet.has(row.dataset.tocId ?? ""))
|
||||
|
||||
if (activeRows.length === 0 || rail!.offsetHeight === 0) {
|
||||
setMarker(null)
|
||||
return
|
||||
}
|
||||
|
||||
const railRect = rail!.getBoundingClientRect()
|
||||
const firstRect = activeRows[0]!.getBoundingClientRect()
|
||||
const lastRect = activeRows.at(-1)!.getBoundingClientRect()
|
||||
setMarker({
|
||||
top: firstRect.top - railRect.top,
|
||||
height: lastRect.bottom - firstRect.top,
|
||||
})
|
||||
}
|
||||
|
||||
measureMarker()
|
||||
const resizeObserver = new ResizeObserver(measureMarker)
|
||||
resizeObserver.observe(root)
|
||||
window.addEventListener("resize", measureMarker)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener("resize", measureMarker)
|
||||
}
|
||||
}, [activeKey, activeIds])
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={rootRef}
|
||||
aria-label={label}
|
||||
className="relative max-h-full overflow-y-auto p-8"
|
||||
>
|
||||
<div
|
||||
ref={railRef}
|
||||
className="absolute top-8 bottom-6 left-8 hidden w-px bg-slate-200 md:block dark:bg-slate-800"
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 w-px bg-indigo-500 transition-[height,transform,opacity] duration-200 dark:bg-indigo-400"
|
||||
style={{
|
||||
height: marker?.height ?? 0,
|
||||
opacity: marker ? 1 : 0,
|
||||
transform: `translateY(${marker?.top ?? 0}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:ps-4">
|
||||
<h2 className="shrink-0">{label}</h2>
|
||||
<ul className="mt-2">
|
||||
{items.map((item) => (
|
||||
<TocItem activeIds={activeIds} item={item} key={item.id} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function TocItem({
|
||||
activeIds,
|
||||
item,
|
||||
}: {
|
||||
activeIds: readonly string[]
|
||||
item: DocTocItem
|
||||
}) {
|
||||
const active = activeIds.includes(item.id)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<TocAnchor
|
||||
active={active}
|
||||
href={`#${item.id}`}
|
||||
itemId={item.id}
|
||||
title={item.title}
|
||||
/>
|
||||
{item.items && item.items.length > 0 && (
|
||||
<ul className="ps-4">
|
||||
{item.items.map((child) => (
|
||||
<TocItem activeIds={activeIds} item={child} key={child.id} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function TocAnchor({
|
||||
active,
|
||||
href,
|
||||
itemId,
|
||||
title,
|
||||
}: {
|
||||
active: boolean
|
||||
href: string
|
||||
itemId: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="py-1" data-toc-id={itemId}>
|
||||
<a
|
||||
href={href}
|
||||
data-active={active || undefined}
|
||||
className="text-slate-600 transition-colors hover:text-indigo-500 data-active:font-medium data-active:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 dark:data-active:text-indigo-400"
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TocVisibilityEntry = {
|
||||
id: string
|
||||
parentIds: readonly string[]
|
||||
}
|
||||
|
||||
function useVisibleTocIds(items: readonly DocTocItem[]) {
|
||||
const entries = React.useMemo(() => flattenTocItems(items), [items])
|
||||
const [activeIds, setActiveIds] = React.useState<readonly string[]>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (entries.length === 0) {
|
||||
setActiveIds([])
|
||||
return
|
||||
}
|
||||
|
||||
let animationFrame: number | undefined
|
||||
const articleElement = document.querySelector<HTMLElement>(
|
||||
"[data-article-content]"
|
||||
)
|
||||
if (!articleElement) return
|
||||
const article = articleElement
|
||||
|
||||
function update() {
|
||||
animationFrame = undefined
|
||||
const headings = entries.flatMap((entry) => {
|
||||
const element = document.getElementById(entry.id)
|
||||
return element ? [{ element, entry }] : []
|
||||
})
|
||||
const viewportTop = getVisibleViewportTop()
|
||||
const viewportBottom = window.innerHeight
|
||||
const articleBottom = article.getBoundingClientRect().bottom
|
||||
const visibleIds = new Set<string>()
|
||||
|
||||
headings.forEach(({ element, entry }, index) => {
|
||||
const sectionTop = element.getBoundingClientRect().top
|
||||
const sectionBottom =
|
||||
headings[index + 1]?.element.getBoundingClientRect().top ??
|
||||
articleBottom
|
||||
|
||||
if (sectionBottom > viewportTop && sectionTop < viewportBottom) {
|
||||
entry.parentIds.forEach((id) => visibleIds.add(id))
|
||||
visibleIds.add(entry.id)
|
||||
}
|
||||
})
|
||||
|
||||
const nextActiveIds = entries
|
||||
.map((entry) => entry.id)
|
||||
.filter((id) => visibleIds.has(id))
|
||||
setActiveIds((current) =>
|
||||
arraysEqual(current, nextActiveIds) ? current : nextActiveIds
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (animationFrame !== undefined) return
|
||||
animationFrame = window.requestAnimationFrame(update)
|
||||
}
|
||||
|
||||
scheduleUpdate()
|
||||
const resizeObserver = new ResizeObserver(scheduleUpdate)
|
||||
resizeObserver.observe(article)
|
||||
window.addEventListener("scroll", scheduleUpdate, { passive: true })
|
||||
window.addEventListener("resize", scheduleUpdate)
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== undefined) {
|
||||
window.cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener("scroll", scheduleUpdate)
|
||||
window.removeEventListener("resize", scheduleUpdate)
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
return activeIds
|
||||
}
|
||||
|
||||
function flattenTocItems(
|
||||
items: readonly DocTocItem[],
|
||||
parentIds: readonly string[] = []
|
||||
): readonly TocVisibilityEntry[] {
|
||||
return items.flatMap((item) => [
|
||||
{ id: item.id, parentIds },
|
||||
...flattenTocItems(item.items ?? [], [...parentIds, item.id]),
|
||||
])
|
||||
}
|
||||
|
||||
function getVisibleViewportTop() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-article-sticky-header]")
|
||||
).reduce((bottom, element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.top <= 0 && rect.bottom > 0
|
||||
? Math.max(bottom, rect.bottom)
|
||||
: bottom
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function arraysEqual(left: readonly string[], right: readonly string[]) {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
)
|
||||
}
|
||||
|
||||
function Footer({
|
||||
next,
|
||||
previous,
|
||||
}: {
|
||||
next?: DocLocation
|
||||
previous?: DocLocation
|
||||
}) {
|
||||
return (
|
||||
<footer className="mt-12 flex flex-col gap-4 border-t border-dashed pt-12 md:grid md:grid-cols-2 md:gap-8">
|
||||
{previous && (
|
||||
<Surround
|
||||
href={getDocHref(previous.package, previous.page)}
|
||||
title={previous.page.title}
|
||||
description={previous.page.description}
|
||||
align="left"
|
||||
/>
|
||||
)}
|
||||
{next && (
|
||||
<Surround
|
||||
href={getDocHref(next.package, next.page)}
|
||||
title={next.page.title}
|
||||
description={next.page.description}
|
||||
align="right"
|
||||
/>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function Surround({
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
align = "right",
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
align?: "left" | "right"
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={[
|
||||
"group flex min-w-0 flex-col gap-1 rounded-xl border p-4",
|
||||
"transition-colors hover:bg-slate-50 dark:hover:bg-slate-900",
|
||||
align === "left"
|
||||
? "col-start-1 items-start text-left"
|
||||
: "col-start-2 items-end text-right",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-full border bg-slate-50 text-sm font-bold transition-colors group-hover:border-indigo-500 group-hover:bg-indigo-100 group-hover:text-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:group-hover:border-indigo-400 dark:group-hover:bg-indigo-950/70 dark:group-hover:text-indigo-300">
|
||||
{align === "left" ? (
|
||||
<Icon name="tabler:arrow-left" />
|
||||
) : (
|
||||
<Icon name="tabler:arrow-right" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-4 w-full min-w-0 truncate text-lg">{title}</h2>
|
||||
<p className="text-md mt-1 line-clamp-2 w-full min-w-0 break-all text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticleNotFound() {
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center px-6">
|
||||
<h1 className="text-4xl">
|
||||
{t({ zh: "未找到文档", us: "Document not found" })}
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
||||
{t({
|
||||
zh: "请求的文档包或页面不存在。",
|
||||
us: "The requested package or document does not exist.",
|
||||
})}
|
||||
</p>
|
||||
<a
|
||||
className="mt-6 w-fit text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
href={getDocHref(getDocPackages(locale)[0]!)}
|
||||
>
|
||||
{t({ zh: "打开文档", us: "Open the documentation" })}
|
||||
</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Actions, Container } from "../components/layout"
|
||||
import { getDocHref, getDocPackages } from "../content/registry"
|
||||
import { useLocale, useTranslate } from "../lib/locale"
|
||||
|
||||
export default function IndexPage() {
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const docPackages = getDocPackages(locale)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className="flex items-center justify-between gap-4 pt-8 pb-10 sm:pt-12 sm:pb-14 lg:pb-16">
|
||||
<h1 className="min-w-0 text-2xl font-semibold capitalize sm:text-3xl">
|
||||
my-shadcn-ui
|
||||
</h1>
|
||||
<Actions className="ml-auto" />
|
||||
</Container>
|
||||
<Container
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8"
|
||||
aria-label={t({ zh: "文档导航", us: "Documentation" })}
|
||||
>
|
||||
{docPackages.map((docPackage, index) => (
|
||||
<a
|
||||
key={docPackage.key}
|
||||
href={getDocHref(docPackage)}
|
||||
className="block overflow-hidden rounded-xl border border-slate-200 bg-indigo-50 outline-0 outline-offset-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-400 dark:hover:bg-slate-800/80 dark:focus-visible:outline-indigo-400"
|
||||
>
|
||||
<div className="min-h-fit p-4 sm:min-h-40 lg:min-h-46">
|
||||
<div className="text-slate-400 dark:text-slate-500">
|
||||
{index > 9 ? `${index + 1}` : `0${index + 1}`}
|
||||
</div>
|
||||
<h2 className="mt-2 mb-4 text-xl font-semibold text-slate-950 dark:text-slate-100">
|
||||
{docPackage.label}
|
||||
</h2>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
{docPackage.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</Container>
|
||||
<footer className="py-8 sm:py-12">
|
||||
<Container className="text-center text-slate-500 dark:text-slate-400">
|
||||
<div>Copyright © 2026</div>
|
||||
</Container>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Icon } from "@icones/react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { BackButton, Container, SlashLine } from "../components/layout"
|
||||
import { LocaleFlag } from "../components/locale-flag"
|
||||
import { SearchTrigger } from "../components/search-trigger"
|
||||
import { Swatch } from "../components/swatch"
|
||||
import { getLocalizedHref } from "../content/registry"
|
||||
import type { DocLocale } from "../content/types"
|
||||
import {
|
||||
accentColors,
|
||||
backgroundShades,
|
||||
getPaletteColor,
|
||||
themeModes,
|
||||
useAppearance,
|
||||
type AccentColor,
|
||||
type BackgroundShade,
|
||||
type ForegroundShade,
|
||||
type ThemeMode,
|
||||
} from "../lib/appearance"
|
||||
import { useLocale, useTranslate, type Translation } from "../lib/locale"
|
||||
import { navigateToLocale } from "../lib/locale-navigation"
|
||||
import { detectLocale } from "../lib/preferences"
|
||||
|
||||
const foregroundOptions: ReadonlyArray<{
|
||||
label: Translation
|
||||
value: ForegroundShade
|
||||
}> = [
|
||||
{ value: "slate", label: { zh: "冷色", us: "Cool" } },
|
||||
{ value: "neutral", label: { zh: "中性", us: "Neutral" } },
|
||||
{ value: "stone", label: { zh: "暖色", us: "Warm" } },
|
||||
]
|
||||
|
||||
const messages = {
|
||||
accent: { zh: "强调色", us: "Accent color" },
|
||||
appearance: { zh: "外观", us: "Appearance" },
|
||||
autoDetect: { zh: "自动检测", us: "Automatic detection" },
|
||||
autoDetectDescription: {
|
||||
zh: "跟随浏览器或操作系统语言设置。",
|
||||
us: "Follow the browser or operating system language.",
|
||||
},
|
||||
background: { zh: "背景色调", us: "Background shade" },
|
||||
enable: { zh: "启用", us: "Enable" },
|
||||
foreground: { zh: "前景色调", us: "Foreground shade" },
|
||||
language: { zh: "语言", us: "Language" },
|
||||
settings: { zh: "设置", us: "Settings" },
|
||||
theme: { zh: "主题", us: "Theme" },
|
||||
themeModes: {
|
||||
light: { zh: "浅色", us: "Light" },
|
||||
dark: { zh: "深色", us: "Dark" },
|
||||
system: { zh: "跟随系统", us: "System" },
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function SettingsPage() {
|
||||
const locale = useLocale()
|
||||
const t = useTranslate()
|
||||
const appearance = useAppearance()
|
||||
const [localeMode, setLocaleMode] = usePreference("locale-mode")
|
||||
const [, setPreferredLocale] = usePreference("locale")
|
||||
const autoDetectLocale = localeMode === "auto"
|
||||
|
||||
function selectLocale(nextLocale: DocLocale) {
|
||||
setPreferredLocale(nextLocale)
|
||||
setLocaleMode("manual")
|
||||
navigateToLocale(nextLocale)
|
||||
}
|
||||
|
||||
function setAutomaticLocale(enabled: boolean) {
|
||||
if (enabled) {
|
||||
setLocaleMode("auto")
|
||||
navigateToLocale(detectLocale())
|
||||
} else {
|
||||
setPreferredLocale(locale)
|
||||
setLocaleMode("manual")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80">
|
||||
<Container className="flex h-16 items-center gap-4">
|
||||
<BackButton />
|
||||
<SlashLine />
|
||||
<h1 className="text-xl font-semibold">{t(messages.settings)}</h1>
|
||||
<div className="ml-auto">
|
||||
<SearchTrigger />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<Container className="py-8 sm:py-12">
|
||||
<section aria-labelledby="appearance-heading">
|
||||
<h2 id="appearance-heading" className="mb-4 text-xl font-semibold">
|
||||
{t(messages.appearance)}
|
||||
</h2>
|
||||
<div className="space-y-8 rounded-xl border bg-white/50 p-5 sm:p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
||||
<fieldset>
|
||||
<legend className="mb-3">{t(messages.theme)}</legend>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t(messages.theme)}
|
||||
className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-56 dark:border-slate-700"
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key !== "ArrowDown" &&
|
||||
event.key !== "ArrowUp" &&
|
||||
event.key !== "ArrowRight" &&
|
||||
event.key !== "ArrowLeft" &&
|
||||
event.key !== "Home" &&
|
||||
event.key !== "End"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const options = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="radio"]'
|
||||
)
|
||||
)
|
||||
const currentIndex = options.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
const moveForward =
|
||||
event.key === "ArrowDown" || event.key === "ArrowRight"
|
||||
let nextIndex = currentIndex
|
||||
|
||||
if (event.key === "Home") nextIndex = 0
|
||||
else if (event.key === "End") nextIndex = options.length - 1
|
||||
else if (moveForward) {
|
||||
nextIndex = (currentIndex + 1) % options.length
|
||||
} else {
|
||||
nextIndex =
|
||||
(currentIndex - 1 + options.length) % options.length
|
||||
}
|
||||
|
||||
options[nextIndex]?.focus()
|
||||
options[nextIndex]?.click()
|
||||
}}
|
||||
>
|
||||
{themeModes.map((mode) => {
|
||||
const selected = appearance.themeMode === mode
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
className="flex h-11 w-full cursor-pointer items-center gap-3 rounded-lg px-3 text-left transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-indigo-500 aria-checked:bg-indigo-100 aria-checked:text-indigo-700 dark:hover:bg-slate-800 dark:aria-checked:bg-indigo-950 dark:aria-checked:text-indigo-300"
|
||||
onClick={() =>
|
||||
appearance.updateAppearance({ themeMode: mode })
|
||||
}
|
||||
>
|
||||
<ThemeIcon mode={mode} />
|
||||
<span>{t(messages.themeModes[mode])}</span>
|
||||
{selected && (
|
||||
<Icon
|
||||
name="tabler:check"
|
||||
className="ml-auto size-4"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<ColorField
|
||||
label={t(messages.accent)}
|
||||
value={appearance.accentColor}
|
||||
options={accentColors}
|
||||
getColors={(accentColor) => ({
|
||||
light: getPaletteColor(accentColor, 600),
|
||||
dark: getPaletteColor(accentColor, 400),
|
||||
})}
|
||||
onChange={(accentColor) =>
|
||||
appearance.updateAppearance({ accentColor })
|
||||
}
|
||||
/>
|
||||
|
||||
<ColorField
|
||||
label={t(messages.background)}
|
||||
value={appearance.backgroundShade}
|
||||
options={backgroundShades}
|
||||
getColors={(backgroundShade) => ({
|
||||
light: getPaletteColor(backgroundShade, 50),
|
||||
dark: getPaletteColor(backgroundShade, 950),
|
||||
})}
|
||||
onChange={(backgroundShade) =>
|
||||
appearance.updateAppearance({ backgroundShade })
|
||||
}
|
||||
/>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-3">{t(messages.foreground)}</legend>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{foregroundOptions.map((option) => (
|
||||
<Swatch
|
||||
key={option.value}
|
||||
light={getPaletteColor(option.value, 950)}
|
||||
dark={getPaletteColor(option.value, 50)}
|
||||
aria-label={t(option.label)}
|
||||
title={t(option.label)}
|
||||
selected={appearance.foregroundShade === option.value}
|
||||
onClick={() =>
|
||||
appearance.updateAppearance({
|
||||
foregroundShade: option.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="mt-10 sm:mt-12"
|
||||
aria-labelledby="language-heading"
|
||||
>
|
||||
<h2 id="language-heading" className="mb-4 text-xl font-semibold">
|
||||
{t(messages.language)}
|
||||
</h2>
|
||||
<div className="space-y-8 rounded-xl border bg-white/50 p-5 sm:p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
||||
<fieldset>
|
||||
<legend className="mb-3">{t(messages.language)}</legend>
|
||||
<div className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-48 dark:border-slate-700">
|
||||
<LocaleLink
|
||||
disabled={autoDetectLocale}
|
||||
href={getLocalizedHref(window.location.pathname, "zh-Hans")}
|
||||
label="简体中文"
|
||||
locale="zh-Hans"
|
||||
selected={locale === "zh-Hans"}
|
||||
onClick={() => selectLocale("zh-Hans")}
|
||||
/>
|
||||
<LocaleLink
|
||||
disabled={autoDetectLocale}
|
||||
href={getLocalizedHref(window.location.pathname, "en-US")}
|
||||
label="English"
|
||||
locale="en-US"
|
||||
selected={locale === "en-US"}
|
||||
onClick={() => selectLocale("en-US")}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>{t(messages.autoDetect)}</div>
|
||||
<label className="inline-flex w-fit cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-indigo-600"
|
||||
checked={autoDetectLocale}
|
||||
onChange={(event) =>
|
||||
setAutomaticLocale(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<span>{t(messages.enable)}</span>
|
||||
</label>
|
||||
<p className="text-sm text-(--docs-muted-light) dark:text-(--docs-muted-dark)">
|
||||
{t(messages.autoDetectDescription)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorField<T extends AccentColor | BackgroundShade>({
|
||||
getColors,
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
getColors: (value: T) => { dark: string; light: string }
|
||||
label: string
|
||||
onChange: (value: T) => void
|
||||
options: readonly T[]
|
||||
value: T
|
||||
}) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="mb-3">{label}</legend>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{options.map((option) => {
|
||||
const colors = getColors(option)
|
||||
const optionLabel = capitalize(option)
|
||||
|
||||
return (
|
||||
<Swatch
|
||||
key={option}
|
||||
light={colors.light}
|
||||
dark={colors.dark}
|
||||
aria-label={optionLabel}
|
||||
title={optionLabel}
|
||||
selected={option === value}
|
||||
onClick={() => onChange(option)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeIcon({ mode }: { mode: ThemeMode }) {
|
||||
if (mode === "light") {
|
||||
return <Icon name="tabler:sun-high" className="size-5" />
|
||||
}
|
||||
if (mode === "dark") {
|
||||
return <Icon name="tabler:moon" className="size-5" />
|
||||
}
|
||||
return <Icon name="tabler:device-desktop" className="size-5" />
|
||||
}
|
||||
|
||||
function LocaleLink({
|
||||
disabled,
|
||||
href,
|
||||
label,
|
||||
locale,
|
||||
onClick,
|
||||
selected,
|
||||
}: {
|
||||
disabled: boolean
|
||||
href: string
|
||||
label: string
|
||||
locale: DocLocale
|
||||
onClick: () => void
|
||||
selected: boolean
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
hrefLang={locale}
|
||||
lang={locale}
|
||||
aria-disabled={disabled}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
className="flex h-11 items-center gap-2 rounded-lg px-3 transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-indigo-500 aria-disabled:cursor-not-allowed aria-disabled:opacity-45 aria-disabled:hover:bg-transparent aria-[current=page]:bg-indigo-100 aria-[current=page]:text-indigo-700 dark:hover:bg-slate-800 dark:aria-disabled:hover:bg-transparent dark:aria-[current=page]:bg-indigo-950 dark:aria-[current=page]:text-indigo-300"
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
<LocaleFlag locale={locale} className="size-5" />
|
||||
<span>{label}</span>
|
||||
{selected && <Icon name="tabler:check" className="ml-auto size-4" />}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function capitalize(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module "*.mdx" {
|
||||
import type { ComponentType, ElementType } from "react"
|
||||
|
||||
const MDXContent: ComponentType<{
|
||||
components?: Record<string, ElementType>
|
||||
}>
|
||||
export const frontmatter: Record<string, unknown>
|
||||
export default MDXContent
|
||||
}
|
||||
Reference in New Issue
Block a user