399 lines · 12.5 KB · TypeScript
RawDownload
import { useCallback, useMemo, useState, type CSSProperties } from "react";import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki";import { toast } from "sonner";import { cn } from "../lib/utils";import { copyToClipboard } from "../lib/clipboard";import { Button } from "./button";import type { ThemedToken } from "shiki/core"; // ---------------------------------------------------------------------------// Types// --------------------------------------------------------------------------- type Definition = {  readonly name: string;  /** The full type body string, e.g. `{ login: string; id: number }` */  readonly code: string;}; // ---------------------------------------------------------------------------// Simple TypeScript formatter — expands single-line types into readable form// --------------------------------------------------------------------------- const formatTypeScript = (code: string): string => {  let result = "";  let indent = 0;  let i = 0;  const len = code.length;   // Track brace depth so we only break `|` and `&` at the top level of a union  let braceDepth = 0;   while (i < len) {    const ch = code[i]!;     if (ch === "{") {      braceDepth++;      indent++;      result += "{\n" + "  ".repeat(indent);      i++;      while (i < len && code[i] === " ") i++;    } else if (ch === "}") {      braceDepth = Math.max(0, braceDepth - 1);      indent = Math.max(0, indent - 1);      result += "\n" + "  ".repeat(indent) + "}";      i++;    } else if (ch === ";" && i + 1 < len && code[i + 1] === " ") {      result += ";\n" + "  ".repeat(indent);      i += 2;    } else if (ch === ";" && indent > 0) {      result += ";\n" + "  ".repeat(indent);      i++;      while (i < len && code[i] === " ") i++;    } else if (ch === "|" && i > 0 && code[i - 1] === " " && i + 1 < len && code[i + 1] === " ") {      // Union operator ` | ` — break onto new line      result += "\n" + "  ".repeat(indent) + "| ";      i += 2; // skip the operator and trailing space    } else {      result += ch;      i++;    }  }   return result;}; // ---------------------------------------------------------------------------// Copy icons// --------------------------------------------------------------------------- const CopyIcon = () => (  <svg viewBox="0 0 16 16" className="size-3">    <rect      x="5"      y="5"      width="8"      height="8"      rx="1"      fill="none"      stroke="currentColor"      strokeWidth="1.2"    />    <path d="M3 11V3h8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />  </svg>); const CheckIcon = () => (  <svg viewBox="0 0 16 16" className="size-3">    <path      d="M3 8l3 3 7-7"      fill="none"      stroke="currentColor"      strokeWidth="1.4"      strokeLinecap="round"      strokeLinejoin="round"    />  </svg>); // ---------------------------------------------------------------------------// Shiki tokenization hook — non-blocking// --------------------------------------------------------------------------- function useTokens(code: string, theme?: ShikiThemeProp): ThemedToken[][] {  const highlighter = getHighlighter();  // Dual-theme light-dark() colors (token.htmlStyle): correct in both color  // schemes from the first frame — no JS dark-mode probe to catch up to.  const result = highlighter.codeToTokens(code, {    lang: "typescript",    ...dualThemeOptions(theme),  });  return result.tokens;} /** A dual-theme token's inline style (light-dark color + per-theme CSS vars). */const tokenStyle = (token: Pick<ThemedToken, "color" | "htmlStyle">): CSSProperties | undefined =>  (token.htmlStyle as CSSProperties | undefined) ??  (token.color ? { color: token.color } : undefined); // ---------------------------------------------------------------------------// Inline-expand logic//// When a user clicks a ref name like `NonEmptyTrimmedString`, we replace// that name with the resolved body inline. So `firstName: NonEmptyTrimmedString`// becomes `firstName: string`.//// We rebuild the full code string with expansions applied, then re-tokenize.// --------------------------------------------------------------------------- /** * Given a code string, a set of ref names to expand, and their definitions, * produce a new code string with each expanded ref replaced by its body. * Applies recursively — if an expanded body itself contains expanded refs, * those get replaced too. Tracks ancestors to prevent infinite loops. */const applyExpansions = (  code: string,  expanded: ReadonlySet<string>,  definitions: ReadonlyMap<string, string>,  ancestors: ReadonlySet<string>,): string => {  if (expanded.size === 0) return code;   let result = "";  let i = 0;   // Sort expanded names longest-first to match greedily  const sortedNames = [...expanded]    .filter((n) => !ancestors.has(n))    .sort((a, b) => b.length - a.length);   while (i < code.length) {    let matched = false;     for (const name of sortedNames) {      if (code.startsWith(name, i)) {        const body = definitions.get(name);        if (body) {          // Recursively expand the body, adding this name to ancestors          const childAncestors = new Set(ancestors);          childAncestors.add(name);          result += applyExpansions(body, expanded, definitions, childAncestors);          i += name.length;          matched = true;          break;        }      }    }     if (!matched) {      result += code[i];      i++;    }  }   return result;}; // ---------------------------------------------------------------------------// Split tokens to find ref names that can be clicked// --------------------------------------------------------------------------- type RenderToken =  | { kind: "text"; content: string; style?: CSSProperties }  | { kind: "ref"; name: string; style?: CSSProperties }; const splitToken = (token: ThemedToken, clickableNames: ReadonlySet<string>): RenderToken[] => {  const style = tokenStyle(token);  if (clickableNames.size === 0) {    return [{ kind: "text", content: token.content, style }];  }   const text = token.content;  const results: RenderToken[] = [];  let remaining = text;   while (remaining.length > 0) {    let earliest = -1;    let matchedName = "";    for (const name of clickableNames) {      const idx = remaining.indexOf(name);      if (        idx !== -1 &&        (earliest === -1 ||          idx < earliest ||          (idx === earliest && name.length > matchedName.length))      ) {        earliest = idx;        matchedName = name;      }    }     if (earliest === -1) {      results.push({ kind: "text", content: remaining, style });      break;    }     if (earliest > 0) {      results.push({ kind: "text", content: remaining.slice(0, earliest), style });    }    results.push({ kind: "ref", name: matchedName, style });    remaining = remaining.slice(earliest + matchedName.length);  }   return results;}; // ---------------------------------------------------------------------------// HighlightedCode — renders tokenized code with clickable unexpanded refs// --------------------------------------------------------------------------- function HighlightedCode(props: {  tokens: ThemedToken[][];  /** Ref names that are NOT yet expanded and can be clicked */  clickableNames: ReadonlySet<string>;  onToggle: (name: string) => void;  expanded: ReadonlySet<string>;}) {  const { tokens, clickableNames, onToggle, expanded } = props;   return (    <>      {tokens.map((line, li) => (        <span key={li}>          {li > 0 && "\n"}          {line.map((token, ti) => {            const parts = splitToken(token, clickableNames);            return parts.map((part, pi) => {              if (part.kind === "text") {                return (                  <span key={`${ti}-${pi}`} style={part.style}>                    {part.content}                  </span>                );              }               const isExpanded = expanded.has(part.name);               return (                <span                  key={`${ti}-${pi}`}                  role="button"                  tabIndex={0}                  onClick={(e) => {                    e.stopPropagation();                    onToggle(part.name);                  }}                  onKeyDown={(e) => {                    if (e.key === "Enter" || e.key === " ") {                      e.preventDefault();                      onToggle(part.name);                    }                  }}                  className={cn(                    "cursor-pointer underline underline-offset-2 hover:opacity-80",                    isExpanded ? "decoration-current/50" : "decoration-current/30",                  )}                  style={part.style}                  title={isExpanded ? `Collapse ${part.name}` : `Expand ${part.name}`}                >                  {part.name}                </span>              );            });          })}        </span>      ))}    </>  );} // ---------------------------------------------------------------------------// ExpandableCodeBlock — main export// --------------------------------------------------------------------------- export function ExpandableCodeBlock(props: {  code: string;  definitions?: readonly Definition[];  className?: string;  theme?: ShikiThemeProp;}) {  const { code, definitions = [], className, theme } = props;  // Auto-expand trivial aliases (primitives, simple unions, string literals)  const trivialNames = useMemo(() => {    const trivial = new Set<string>();    const trivialPattern =      /^(?:string|number|boolean|null|undefined|void|never|unknown|any|true|false|(?:"[^"]*"(?:\s*\|\s*"[^"]*")*))$/;    for (const d of definitions) {      const body = d.code.trim();      if (trivialPattern.test(body)) {        trivial.add(d.name);      }    }    return trivial;  }, [definitions]);   const [expanded, setExpanded] = useState<Set<string>>(() => new Set());  const [copied, setCopied] = useState(false);   const definitionMap = useMemo(    () => new Map(definitions.map((d) => [d.name, d.code])),    [definitions],  );   const definitionNames = useMemo(() => new Set(definitions.map((d) => d.name)), [definitions]);   // Names that can be clicked — exclude trivial aliases (already inlined)  const clickableNames = useMemo(() => {    const names = new Set(definitionNames);    for (const name of trivialNames) names.delete(name);    return names;  }, [definitionNames, trivialNames]);   const emptyAncestors = useMemo(() => new Set<string>(), []);   // Merge user-expanded + trivial auto-expanded  const allExpanded = useMemo(() => {    const merged = new Set(expanded);    for (const name of trivialNames) merged.add(name);    return merged;  }, [expanded, trivialNames]);   // Build the display code: start with raw code, apply expansions, then format  const displayCode = useMemo(() => {    const withExpansions = applyExpansions(code, allExpanded, definitionMap, emptyAncestors);    return formatTypeScript(withExpansions);  }, [code, allExpanded, definitionMap, emptyAncestors]);   const tokens = useTokens(displayCode, theme);   const handleToggle = useCallback((name: string) => {    setExpanded((prev) => {      const next = new Set(prev);      if (next.has(name)) {        next.delete(name);      } else {        next.add(name);      }      return next;    });  }, []);   const handleCopy = useCallback(() => {    void copyToClipboard(displayCode).then((ok) => {      if (!ok) {        toast.error("Failed to copy to clipboard");        return;      }      setCopied(true);      setTimeout(() => setCopied(false), 1500);    });  }, [displayCode]);   return (    <div className={cn("rounded-lg border border-border/40 bg-card/60 overflow-hidden", className)}>      <div className="group relative">        <Button          variant="ghost"          size="icon-xs"          onClick={handleCopy}          className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 p-1.5 text-muted-foreground opacity-0 backdrop-blur-sm hover:text-foreground group-hover:opacity-100 transition-opacity"          title="Copy to clipboard"        >          {copied ? <CheckIcon /> : <CopyIcon />}        </Button>         <pre className="overflow-auto p-3 font-mono text-xs leading-6 !bg-transparent">          <code>            <HighlightedCode              tokens={tokens}              clickableNames={clickableNames}              onToggle={handleToggle}              expanded={allExpanded}            />          </code>        </pre>      </div>    </div>  );}