feat(docs): add bilingual package documentation site
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user