This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Info, Lightbulb, TriangleAlert, CircleAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 提示块:可在 MDX 正文中直接使用。
|
||||
*
|
||||
* 颜色取自 DESIGN.md 的语义色:
|
||||
* - info → brand-tag(信息蓝)
|
||||
* - tip → brand-green(品牌绿)
|
||||
* - warning → brand-warn(警告橙)
|
||||
* - danger → brand-error(错误红)
|
||||
*/
|
||||
const CALLOUT_VARIANTS = {
|
||||
info: {
|
||||
icon: Info,
|
||||
label: "说明",
|
||||
className: "border-brand-tag/30 bg-brand-tag/[0.06] text-brand-tag",
|
||||
},
|
||||
tip: {
|
||||
icon: Lightbulb,
|
||||
label: "提示",
|
||||
className:
|
||||
"border-brand-green/40 bg-brand-green/[0.07] text-brand-green-deep",
|
||||
},
|
||||
warning: {
|
||||
icon: TriangleAlert,
|
||||
label: "注意",
|
||||
className: "border-brand-warn/30 bg-brand-warn/[0.07] text-brand-warn",
|
||||
},
|
||||
danger: {
|
||||
icon: CircleAlert,
|
||||
label: "警告",
|
||||
className: "border-brand-error/30 bg-brand-error/[0.06] text-brand-error",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type CalloutType = keyof typeof CALLOUT_VARIANTS;
|
||||
|
||||
export interface CalloutProps {
|
||||
type?: CalloutType;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Callout({ type = "info", title, children }: CalloutProps) {
|
||||
const variant = CALLOUT_VARIANTS[type] ?? CALLOUT_VARIANTS.info;
|
||||
const Icon = variant.icon;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"my-xl flex gap-sm rounded-md border p-md",
|
||||
variant.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="mt-[2px] size-4 shrink-0" aria-hidden="true" />
|
||||
|
||||
<div className="flex-1 text-body-sm">
|
||||
<p className="mb-xxs font-semibold">
|
||||
{title ?? variant.label}
|
||||
</p>
|
||||
<div className="text-slate [&>p]:text-slate [&>p+p]:mt-xs">{children}</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 代码块容器:对应 DESIGN.md 的 code-block + code-block-header + copy-code-button。
|
||||
*
|
||||
* Shiki 已在构建期把高亮结果编译成 <pre><code>…</code></pre>,
|
||||
* 这里只负责套上头部(语言标签 + 复制按钮),不重新解析代码。
|
||||
*
|
||||
* 复制逻辑优先使用 Clipboard API,失败时回退到 execCommand,
|
||||
* 以便在非 HTTPS 环境下依然可用。
|
||||
*/
|
||||
export interface CodeBlockProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
/** Shiki 输出的 data-language(由 rehype-pretty-code 注入到 figure 上) */
|
||||
"data-language"?: string;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 落回下面的兼容实现
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const succeeded = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return succeeded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 <pre> 的 React 子节点中抽取纯文本,作为复制内容 */
|
||||
function extractText(node: ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") {
|
||||
return "";
|
||||
}
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractText).join("");
|
||||
}
|
||||
if (typeof node === "object" && "props" in node) {
|
||||
const props = (node as { props?: { children?: ReactNode } }).props;
|
||||
return extractText(props?.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function CodeBlock({ children, className, ...props }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const language =
|
||||
props["data-language"] ?? detectLanguage(className) ?? "text";
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = extractText(children).replace(/\n$/, "");
|
||||
const succeeded = await copyText(text);
|
||||
|
||||
if (succeeded) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="absolute right-xs top-xs z-10 flex items-center gap-xs">
|
||||
<span className="font-mono text-caption text-on-dark-muted">
|
||||
{language}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "已复制代码" : "复制代码"}
|
||||
className="inline-flex items-center gap-[4px] rounded-sm border border-hairline-dark bg-surface-code/90 px-xs py-[4px] font-mono text-caption text-on-dark-muted transition-colors duration-150 active:text-on-dark"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-brand-green" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
<span className={cn(copied && "text-brand-green")}>
|
||||
{copied ? "已复制" : "复制"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre className={className} {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 从 className 中提取语言,如 "language-ts" → "ts" */
|
||||
function detectLanguage(className?: string): string | null {
|
||||
const match = /language-([\w-]+)/.exec(className ?? "");
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Image from "next/image";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { Callout } from "./callout";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
/**
|
||||
* MDX 组件映射。
|
||||
*
|
||||
* 通过替换原生元素,把 DESIGN.md 的排版规则与可访问性要求统一注入正文:
|
||||
* - img → next/image(图片优化 + 保留 alt)
|
||||
* - pre → 带语言标签与复制按钮的代码块
|
||||
* - a → 外链自动补 rel/target
|
||||
*
|
||||
* 类型说明:这里不使用 `mdx/types` 的 MDXComponents,因为它依赖可选安装的
|
||||
* @types/mdx。改为按组件签名手写映射类型,避免引入额外类型包。
|
||||
*/
|
||||
|
||||
type MdxImageProps = ComponentPropsWithoutRef<"img">;
|
||||
type MdxAnchorProps = ComponentPropsWithoutRef<"a"> & { children?: ReactNode };
|
||||
|
||||
/**
|
||||
* MDX 组件映射类型。
|
||||
*
|
||||
* `next-mdx-remote` 内部使用 `MDXComponents`(带字符串索引签名),
|
||||
* 因此这里显式声明索引签名,保证类型兼容的同时仍然约束了我们实际用到的键。
|
||||
*/
|
||||
export interface MdxComponentMap {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
img: (props: MdxImageProps) => ReactNode;
|
||||
a: (props: MdxAnchorProps) => ReactNode;
|
||||
pre: typeof CodeBlock;
|
||||
Callout: typeof Callout;
|
||||
}
|
||||
|
||||
export const mdxComponents: MdxComponentMap = {
|
||||
// 文章内图片改用 next/image,alt 缺失时降级为空字符串(装饰性图片)
|
||||
img: ({ src, alt, width, height }) => {
|
||||
if (typeof src !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
width={typeof width === "number" ? width : 1200}
|
||||
height={typeof height === "number" ? height : 630}
|
||||
className="rounded-lg border border-hairline"
|
||||
sizes="(max-width: 768px) 100vw, 720px"
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 外链统一加上安全属性;站内锚点(# 开头)与相对路径保持原样
|
||||
a: ({ href, children, ...props }) => {
|
||||
const isExternal = typeof href === "string" && /^https?:\/\//.test(href);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
pre: CodeBlock,
|
||||
Callout,
|
||||
};
|
||||
Reference in new issue
Block a user