A design system in a box. hip-ui.tngl.io/docs/introduction
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

copy for llm button

+587 -255
+3 -1
apps/docs/package.json
··· 4 4 "type": "module", 5 5 "scripts": { 6 6 "dev": "vite dev --port 3000", 7 - "build": "vite build", 7 + "build": "vite build && pnpm build:markdown", 8 + "build:markdown": "node ./scripts/export-markdown.mjs", 8 9 "serve": "vite preview", 9 10 "test": "vitest run", 10 11 "lint": "oxlint ." ··· 37 38 "lucide-react": "^0.545.0", 38 39 "magic-string": "^0.30.21", 39 40 "match-container": "^0.1.0", 41 + "node-html-markdown": "^2.0.0", 40 42 "raf-throttle": "^2.0.6", 41 43 "react": "catalog:", 42 44 "react-aria": "^3.47.0",
+113
apps/docs/scripts/export-markdown.mjs
··· 1 + import { glob } from "glob"; 2 + import { JSDOM } from "jsdom"; 3 + import { NodeHtmlMarkdown } from "node-html-markdown"; 4 + import path from "node:path"; 5 + import { readFile, rm, writeFile } from "node:fs/promises"; 6 + 7 + const distClientDir = path.resolve("dist/client"); 8 + 9 + function getLanguage(node) { 10 + const codeNode = node.querySelector("code[class*='language-']"); 11 + const className = codeNode?.getAttribute("class") ?? ""; 12 + const match = className.match(/language-([\w-]+)/); 13 + 14 + return match?.[1] ?? ""; 15 + } 16 + 17 + function escapeTableCellPipes(text) { 18 + return text.replaceAll("|", "\\|"); 19 + } 20 + 21 + function normalizeInlineWhitespace(text) { 22 + return text.replace(/\s+/g, " ").trim(); 23 + } 24 + 25 + function normalizeCodeBlocks(markdownRoot) { 26 + const codeBlocks = markdownRoot.querySelectorAll("pre"); 27 + 28 + for (const codeBlock of codeBlocks) { 29 + const code = codeBlock.textContent?.replace(/\n$/, "") ?? ""; 30 + const language = getLanguage(codeBlock); 31 + const normalizedCodeBlock = markdownRoot.ownerDocument.createElement("pre"); 32 + const normalizedCode = markdownRoot.ownerDocument.createElement("code"); 33 + 34 + if (language) { 35 + normalizedCode.className = `language-${language}`; 36 + } 37 + 38 + normalizedCode.textContent = code; 39 + normalizedCodeBlock.replaceChildren(normalizedCode); 40 + codeBlock.replaceWith(normalizedCodeBlock); 41 + } 42 + } 43 + 44 + function normalizeTables(markdownRoot) { 45 + const cells = markdownRoot.querySelectorAll("th, td"); 46 + 47 + for (const cell of cells) { 48 + const clone = cell.cloneNode(true); 49 + 50 + for (const codeNode of clone.querySelectorAll("pre, code")) { 51 + const codeText = normalizeInlineWhitespace(codeNode.textContent ?? ""); 52 + codeNode.replaceWith(`\`${escapeTableCellPipes(codeText)}\``); 53 + } 54 + 55 + cell.textContent = escapeTableCellPipes( 56 + normalizeInlineWhitespace(clone.textContent ?? ""), 57 + ); 58 + } 59 + } 60 + 61 + function normalizeMarkdown(markdown) { 62 + return markdown 63 + .replace(/\n{3,}/g, "\n\n") 64 + .trim() 65 + .concat("\n"); 66 + } 67 + 68 + const markdownConverter = new NodeHtmlMarkdown({ 69 + bulletMarker: "-", 70 + codeBlockStyle: "fenced", 71 + }); 72 + 73 + async function exportMarkdownFile(htmlFilePath) { 74 + const html = await readFile(htmlFilePath, "utf8"); 75 + const dom = new JSDOM(html); 76 + const markdownRoot = dom.window.document.querySelector( 77 + "[data-markdown-export]", 78 + ); 79 + 80 + if (!markdownRoot) { 81 + throw new Error(`Missing [data-markdown-export] in ${htmlFilePath}`); 82 + } 83 + 84 + normalizeCodeBlocks(markdownRoot); 85 + normalizeTables(markdownRoot); 86 + 87 + const markdown = normalizeMarkdown( 88 + markdownConverter.translate(markdownRoot.innerHTML), 89 + ); 90 + const markdownDirectoryPath = path.dirname(htmlFilePath); 91 + 92 + await rm(markdownDirectoryPath, { force: true, recursive: true }); 93 + await writeFile(markdownDirectoryPath, markdown, "utf8"); 94 + } 95 + 96 + async function main() { 97 + const htmlFilePaths = await glob("**/*.md/index.html", { 98 + absolute: true, 99 + cwd: distClientDir, 100 + }); 101 + 102 + if (htmlFilePaths.length === 0) { 103 + throw new Error(`No markdown HTML exports found in ${distClientDir}`); 104 + } 105 + 106 + await Promise.all( 107 + htmlFilePaths.map((htmlFilePath) => exportMarkdownFile(htmlFilePath)), 108 + ); 109 + 110 + console.log(`Exported ${htmlFilePaths.length} markdown files.`); 111 + } 112 + 113 + await main();
+111
apps/docs/src/lib/CopyForLLM.tsx
··· 1 + "use client"; 2 + 3 + import * as stylex from "@stylexjs/stylex"; 4 + import { useLocation } from "@tanstack/react-router"; 5 + import { Copy } from "lucide-react"; 6 + import { useEffect, useRef, useState } from "react"; 7 + 8 + import type { StyleXComponentProps } from "@/components/theme/types"; 9 + 10 + import { Flex } from "@/components/flex"; 11 + import { IconButton } from "@/components/icon-button"; 12 + 13 + const styles = stylex.create({ 14 + link: { 15 + position: "absolute", 16 + zIndex: -1, 17 + right: 0, 18 + top: 0, 19 + }, 20 + }); 21 + 22 + const defaultIcon = <Copy />; 23 + 24 + /** 25 + * Props for the CopyToClipboardButton component. 26 + */ 27 + export interface CopyToClipboardButtonProps extends StyleXComponentProps<{}> { 28 + /** 29 + * Optional icon to display. Defaults to a Copy icon. 30 + */ 31 + icon?: React.ReactNode; 32 + } 33 + 34 + /** 35 + * A button component that copies text to the clipboard when clicked. 36 + * Displays a tooltip that changes to "Copied ✓" after copying. 37 + */ 38 + export const CopyForLLMButton = ({ 39 + style, 40 + icon = defaultIcon, 41 + }: CopyToClipboardButtonProps) => { 42 + const location = useLocation(); 43 + const url = location.pathname; 44 + const [urlContent, setUrlContent] = useState(url); 45 + 46 + useEffect(() => { 47 + async function fetchUrlContent() { 48 + const res = await fetch(`${globalThis.location.origin}${url}.md`); 49 + const data = await res.text(); 50 + 51 + setUrlContent(data); 52 + } 53 + 54 + fetchUrlContent(); 55 + }, [url]); 56 + 57 + const [tooltipText, setTooltipText] = useState("Copy for LLM"); 58 + const [tooltipOpen, setTooltipOpen] = useState(false); 59 + const timeoutRef = useRef<NodeJS.Timeout | null>(null); 60 + const changeTextTimeoutRef = useRef<NodeJS.Timeout | null>(null); 61 + 62 + const handleCopy = () => { 63 + if (changeTextTimeoutRef.current) { 64 + clearTimeout(changeTextTimeoutRef.current); 65 + changeTextTimeoutRef.current = null; 66 + } 67 + if (timeoutRef.current) { 68 + clearTimeout(timeoutRef.current); 69 + timeoutRef.current = null; 70 + } 71 + void navigator.clipboard.writeText(urlContent); 72 + setTooltipText("Copied ✓"); 73 + setTooltipOpen(true); 74 + timeoutRef.current = setTimeout(() => { 75 + timeoutRef.current = null; 76 + 77 + setTooltipOpen(false); 78 + 79 + changeTextTimeoutRef.current = setTimeout(() => { 80 + setTooltipText("Copy for LLM"); 81 + changeTextTimeoutRef.current = null; 82 + }, 200); 83 + }, 2000); 84 + }; 85 + 86 + return ( 87 + <Flex align="center" justify="center"> 88 + <IconButton 89 + label={tooltipText} 90 + tooltipOpen={tooltipOpen} 91 + onTooltipOpenChange={(isOpen) => 92 + !timeoutRef.current && setTooltipOpen(isOpen) 93 + } 94 + variant="tertiary" 95 + style={style} 96 + size="sm" 97 + onClick={handleCopy} 98 + > 99 + {icon} 100 + </IconButton> 101 + 102 + {/* oxlint-disable-next-line jsx_a11y/anchor-has-content */} 103 + <a 104 + href={`${url}.md`} 105 + target="_blank" 106 + rel="noopener noreferrer" 107 + {...stylex.props(styles.link)} 108 + ></a> 109 + </Flex> 110 + ); 111 + };
+12 -1
apps/docs/src/lib/Example.tsx
··· 1 1 import * as stylex from "@stylexjs/stylex"; 2 - import { useEffect, useRef, useState } from "react"; 2 + import { useContext, useEffect, useRef, useState } from "react"; 3 3 import { Button, Disclosure, DisclosurePanel } from "react-aria-components"; 4 4 import { examples } from "virtual:examples"; 5 5 ··· 17 17 size as sizeSpace, 18 18 verticalSpace, 19 19 } from "../components/theme/semantic-spacing.stylex"; 20 + import { MarkdownExportContext } from "./MarkdownExportContext"; 21 + import { UnShikiCode } from "./UnShiki"; 20 22 21 23 const styles = stylex.create({ 22 24 card: { ··· 101 103 const ref = useRef<HTMLDivElement>(null); 102 104 const [textContent, setTextContent] = useState("error"); 103 105 const [isOpen, setIsOpen] = useState(false); 106 + const { isMarkdown } = useContext(MarkdownExportContext); 104 107 105 108 useEffect(() => { 106 109 // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect, react-hooks/set-state-in-effect 107 110 setTextContent(ref.current?.textContent ?? "error"); 108 111 }, [code]); 112 + 113 + if (isMarkdown && code) { 114 + return ( 115 + <pre> 116 + <UnShikiCode shikiCode={code} className="language-tsx" /> 117 + </pre> 118 + ); 119 + } 109 120 110 121 return ( 111 122 <Card style={styles.card}>
+9
apps/docs/src/lib/MarkdownExportContext.ts
··· 1 + import { createContext } from "react"; 2 + 3 + export const MarkdownExportContext = createContext<{ 4 + isMarkdown: boolean; 5 + docPath: string; 6 + }>({ 7 + isMarkdown: false, 8 + docPath: "", 9 + });
+35
apps/docs/src/lib/UnShiki.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + 3 + export function UnShikiCode({ 4 + shikiCode, 5 + children, 6 + ...props 7 + }: { 8 + shikiCode?: string; 9 + } & React.ComponentProps<"code">) { 10 + const ref = useRef<HTMLDivElement>(null); 11 + const [textContent, setTextContent] = useState("error"); 12 + 13 + useEffect(() => { 14 + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect, react-hooks/set-state-in-effect 15 + setTextContent(ref.current?.textContent ?? "error"); 16 + }, [shikiCode]); 17 + 18 + return ( 19 + <code {...props}> 20 + {textContent === "error" ? ( 21 + <div 22 + ref={ref} 23 + // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml 24 + dangerouslySetInnerHTML={ 25 + children ? undefined : { __html: shikiCode ?? "" } 26 + } 27 + > 28 + {children} 29 + </div> 30 + ) : ( 31 + textContent 32 + )} 33 + </code> 34 + ); 35 + }
+277 -10
apps/docs/src/routes/docs.$.tsx
··· 6 6 createFileRoute, 7 7 createLink, 8 8 useLocation, 9 + useMatches, 9 10 } from "@tanstack/react-router"; 10 11 import { allDocs } from "content-collections"; 12 + import { Copy, Moon, Sun } from "lucide-react"; 13 + import { useEffect, useState } from "react"; 11 14 import { modules, pages } from "virtual:content"; 12 15 13 16 import type { LinkProps } from "@/components/link"; 14 17 15 18 import { Flex } from "@/components/flex"; 19 + import { IconButton } from "@/components/icon-button"; 16 20 import { Link as TypographyLink } from "@/components/link"; 21 + import { 22 + Sidebar, 23 + SidebarGroup, 24 + SidebarHeader, 25 + SidebarItem, 26 + SidebarSection, 27 + } from "@/components/sidebar"; 17 28 import { SidebarLayout } from "@/components/sidebar-layout"; 18 29 import { TableOfContents } from "@/components/table-of-contents"; 19 30 import { ··· 32 43 UnorderedList, 33 44 } from "@/components/typography"; 34 45 import { Text } from "@/components/typography/text"; 46 + import { MarkdownExportContext } from "@/lib/MarkdownExportContext"; 47 + import { ThemePicker } from "@/lib/ThemePicker"; 48 + import { UnShikiCode } from "@/lib/UnShiki"; 35 49 36 50 import { breakpoints } from "../components/theme/breakpoints.stylex"; 37 51 import { 38 52 size as sizeSpace, 39 53 verticalSpace, 40 54 } from "../components/theme/semantic-spacing.stylex"; 55 + import { CopyForLLMButton } from "@/lib/CopyForLLM"; 41 56 42 57 declare global { 43 58 // eslint-disable-next-line @typescript-eslint/no-namespace ··· 49 64 } 50 65 51 66 const TypographyRouterLink = createLink(TypographyLink); 67 + const SidebarItemLink = createLink(SidebarItem); 68 + 69 + interface SidebarLeafEntry { 70 + id: string; 71 + label: string; 72 + to: "/docs/$"; 73 + params: { _splat: string }; 74 + } 75 + 76 + interface SidebarSectionEntry { 77 + id: string; 78 + label: string; 79 + items: Array<SidebarLeafEntry>; 80 + } 81 + 82 + interface SidebarGroupEntry { 83 + id: string; 84 + label: string; 85 + items: Array<SidebarLeafEntry | SidebarSectionEntry>; 86 + } 87 + 88 + type SidebarTopLevelEntry = SidebarLeafEntry | SidebarGroupEntry; 89 + 90 + const componentDocs = allDocs.filter((doc) => 91 + doc._meta.directory.startsWith("components"), 92 + ); 93 + const foundationDocs = allDocs.filter((doc) => 94 + doc._meta.directory.startsWith("foundations"), 95 + ); 96 + 97 + // oxlint-disable-next-line eslint-plugin-unicorn(no-array-reduce) 98 + const componentGroups = componentDocs.reduce( 99 + (acc, doc) => { 100 + const pathParts = doc._meta.path.split("/"); 101 + const folderName = 102 + pathParts.length > 2 && pathParts[1] ? pathParts[1] : "components"; 103 + 104 + if (!acc[folderName]) { 105 + acc[folderName] = []; 106 + } 107 + 108 + acc[folderName]?.push(doc); 109 + 110 + return acc; 111 + }, 112 + {} as Record<string, typeof componentDocs>, 113 + ); 114 + 115 + const componentItems: Array<SidebarSectionEntry> = Object.entries( 116 + componentGroups, 117 + ) 118 + .toSorted(([a], [b]) => a.localeCompare(b)) 119 + .map(([folderName, docs]) => ({ 120 + id: `components-${folderName}`, 121 + label: folderName.charAt(0).toUpperCase() + folderName.slice(1), 122 + items: docs 123 + .toSorted((a, b) => a.title.localeCompare(b.title)) 124 + .map((doc) => ({ 125 + id: doc._meta.path, 126 + label: doc.title, 127 + to: "/docs/$", 128 + params: { _splat: doc._meta.path }, 129 + })), 130 + })); 131 + 132 + const sidebarItems: Array<SidebarTopLevelEntry> = [ 133 + { 134 + id: "introduction", 135 + label: "Introduction", 136 + to: "/docs/$", 137 + params: { _splat: "introduction" }, 138 + }, 139 + { 140 + id: "foundations", 141 + label: "Foundations", 142 + items: foundationDocs.map((doc) => ({ 143 + id: doc._meta.path, 144 + label: doc.title, 145 + to: "/docs/$", 146 + params: { _splat: doc._meta.path }, 147 + })), 148 + }, 149 + { 150 + id: "components", 151 + label: "Components", 152 + items: componentItems, 153 + }, 154 + ]; 155 + 156 + const flatItems: Array<SidebarLeafEntry> = sidebarItems.flatMap((item) => { 157 + if (!("items" in item)) { 158 + return [item]; 159 + } 160 + 161 + return item.items.flatMap((subItem) => { 162 + if ("items" in subItem) { 163 + return subItem.items; 164 + } 165 + 166 + return [subItem]; 167 + }); 168 + }); 52 169 53 170 const styles = stylex.create({ 54 171 header: { ··· 78 195 marginBottom: verticalSpace["7xl"], 79 196 marginTop: verticalSpace["7xl"], 80 197 }, 198 + grow: { 199 + flexGrow: 1, 200 + minWidth: 0, 201 + }, 81 202 }); 82 203 204 + function DarkModeToggle() { 205 + const [colorScheme, setColorScheme] = useState<"light" | "dark">("light"); 206 + 207 + const toggleColorScheme = () => { 208 + const newColorScheme = colorScheme === "light" ? "dark" : "light"; 209 + 210 + setColorScheme(newColorScheme); 211 + localStorage.setItem("hip-ui-color-scheme", newColorScheme); 212 + document.body.style.colorScheme = newColorScheme; 213 + }; 214 + 215 + useEffect(() => { 216 + const localColorScheme = localStorage.getItem("hip-ui-color-scheme"); 217 + 218 + if (localColorScheme) { 219 + setColorScheme(localColorScheme as "light" | "dark"); 220 + } 221 + }, []); 222 + 223 + return ( 224 + <IconButton 225 + variant="secondary" 226 + label="Toggle Dark Mode" 227 + onPress={toggleColorScheme} 228 + > 229 + {colorScheme === "dark" ? <Moon /> : <Sun />} 230 + </IconButton> 231 + ); 232 + } 233 + 83 234 function Link({ href, ...props }: LinkProps) { 84 235 if (href && href.startsWith("/")) { 85 236 const splat = href.split("/").slice(2).join("/"); ··· 145 296 ), 146 297 }; 147 298 299 + function DocSidebar() { 300 + const location = useLocation(); 301 + const matches = useMatches(); 302 + const match = matches.find( 303 + (routeMatch) => routeMatch.pathname === location.pathname, 304 + ); 305 + const currentSplat = 306 + match?.params && 307 + "_splat" in match.params && 308 + typeof match.params._splat === "string" 309 + ? match.params._splat.replace("/docs/", "") 310 + : undefined; 311 + const currentItem = flatItems.find((item) => item.id === currentSplat); 312 + 313 + return ( 314 + <Sidebar> 315 + <SidebarHeader 316 + action={ 317 + <Flex gap="xxs" align="center"> 318 + <ThemePicker /> 319 + <DarkModeToggle /> 320 + </Flex> 321 + } 322 + > 323 + <Text font="title" size="4xl" weight="bold"> 324 + Hip UI 325 + </Text> 326 + </SidebarHeader> 327 + {sidebarItems.map((item) => { 328 + if (!("items" in item)) { 329 + return ( 330 + <SidebarItemLink 331 + key={item.id} 332 + to={item.to} 333 + params={item.params} 334 + isActive={currentItem?.id === item.id} 335 + > 336 + {item.label} 337 + </SidebarItemLink> 338 + ); 339 + } 340 + 341 + return ( 342 + <SidebarGroup title={item.label} key={item.id}> 343 + {item.items.map((subItem) => { 344 + if ("items" in subItem) { 345 + return ( 346 + <SidebarSection key={subItem.id} title={subItem.label}> 347 + {subItem.items.map((leafItem) => ( 348 + <SidebarItemLink 349 + key={leafItem.id} 350 + to={leafItem.to} 351 + params={leafItem.params} 352 + isActive={currentItem?.id === leafItem.id} 353 + > 354 + {leafItem.label} 355 + </SidebarItemLink> 356 + ))} 357 + </SidebarSection> 358 + ); 359 + } 360 + 361 + return ( 362 + <SidebarSection key={subItem.id}> 363 + <SidebarItemLink 364 + to={subItem.to} 365 + params={subItem.params} 366 + isActive={currentItem?.id === subItem.id} 367 + > 368 + {subItem.label} 369 + </SidebarItemLink> 370 + </SidebarSection> 371 + ); 372 + })} 373 + </SidebarGroup> 374 + ); 375 + })} 376 + </Sidebar> 377 + ); 378 + } 379 + 148 380 export const Route = createFileRoute("/docs/$")({ 149 381 component: RouteComponent, 150 382 loader: async ({ location }) => { 383 + const docPath = location.pathname.replace(/\.md$/, ""); 384 + const isMarkdown = location.pathname.endsWith(".md"); 151 385 const doc = allDocs.find((d) => 152 - location.pathname.match(new RegExp(`${d._meta.path}$`)), 386 + docPath.match(new RegExp(`${d._meta.path}$`)), 153 387 ); 154 388 155 389 return { 390 + isMarkdown, 391 + docPath, 156 392 title: doc?.title, 157 393 toc: await modules[location.pathname]?.then((mod) => mod.toc), 158 394 }; ··· 168 404 169 405 function RouteComponent() { 170 406 const { _splat } = Route.useParams(); 171 - const location = useLocation(); 172 - const { toc } = Route.useLoaderData(); 407 + const { toc, isMarkdown, docPath } = Route.useLoaderData(); 173 408 const doc = allDocs.find((d) => 174 - location.pathname.match(new RegExp(`${d._meta.path}$`)), 409 + docPath.match(new RegExp(`${d._meta.path}$`)), 175 410 ); 176 411 177 412 if (!doc) { 178 413 throw new Error(`Doc not found: ${_splat ?? "unknown"}`); 179 414 } 180 415 181 - const Page = pages[location.pathname]; 416 + const Page = pages[docPath]; 182 417 183 418 if (!Page) { 184 - throw new Error(`Content not found: ${location.pathname}`); 419 + throw new Error(`Content not found: ${docPath}`); 185 420 } 186 421 187 - const isShowcase = location.pathname.includes("showcase"); 422 + const isShowcase = docPath.includes("showcase"); 188 423 189 424 if (isShowcase) { 190 425 return <Page components={components} />; 191 426 } 192 427 428 + if (isMarkdown) { 429 + return ( 430 + <MarkdownExportContext.Provider value={{ isMarkdown, docPath }}> 431 + <div data-markdown-export> 432 + <Flex direction="column" gap="7xl" style={styles.header}> 433 + <Heading1>{doc.title}</Heading1> 434 + <Text size="xl" variant="secondary"> 435 + {doc.description} 436 + </Text> 437 + </Flex> 438 + <Page 439 + components={{ 440 + code: (props) => { 441 + if (props.className?.includes("language-")) { 442 + return <UnShikiCode {...props} />; 443 + } 444 + 445 + return <code {...props} />; 446 + }, 447 + }} 448 + /> 449 + </div> 450 + </MarkdownExportContext.Provider> 451 + ); 452 + } 453 + 193 454 return ( 194 - <> 455 + <SidebarLayout.Root> 456 + <SidebarLayout.NavigationSidebar> 457 + <DocSidebar /> 458 + </SidebarLayout.NavigationSidebar> 195 459 <SidebarLayout.Page> 196 460 <Flex direction="column" gap="7xl" style={styles.header}> 197 - <Heading1>{doc.title}</Heading1> 461 + <Flex align="center" gap="xl"> 462 + <Heading1 style={styles.grow}>{doc.title}</Heading1> 463 + <CopyForLLMButton /> 464 + </Flex> 198 465 <Text size="xl" variant="secondary"> 199 466 {doc.description} 200 467 </Text> ··· 206 473 <TableOfContents toc={toc} /> 207 474 </SidebarLayout.InconsequentialSidebar> 208 475 )} 209 - </> 476 + </SidebarLayout.Root> 210 477 ); 211 478 }
+2 -243
apps/docs/src/routes/docs.tsx
··· 1 - import type { LinkProps } from "@tanstack/react-router"; 2 - 3 - import { 4 - Outlet, 5 - createFileRoute, 6 - createLink, 7 - useLocation, 8 - useMatches, 9 - } from "@tanstack/react-router"; 10 - import { allDocs } from "content-collections"; 11 - import { Moon, Sun } from "lucide-react"; 12 - import { useEffect, useState } from "react"; 13 - 14 - import { Flex } from "@/components/flex"; 15 - import { IconButton } from "@/components/icon-button"; 16 - import { 17 - Sidebar, 18 - SidebarGroup, 19 - SidebarHeader, 20 - SidebarItem, 21 - SidebarSection, 22 - } from "@/components/sidebar"; 23 - import { SidebarLayout } from "@/components/sidebar-layout"; 24 - import { Text } from "@/components/typography/text"; 25 - import { ThemePicker } from "@/lib/ThemePicker"; 26 - 27 - const SidebarItemLink = createLink(SidebarItem); 28 - 29 - interface SidebarItem { 30 - id: string; 31 - label: string; 32 - to?: LinkProps["to"]; 33 - params?: LinkProps["params"]; 34 - items?: Array<SidebarItem>; 35 - } 36 - 37 - const componentDocs = allDocs.filter((doc) => 38 - doc._meta.directory.startsWith("components"), 39 - ); 40 - const foundationDocs = allDocs.filter((doc) => 41 - doc._meta.directory.startsWith("foundations"), 42 - ); 43 - // const showcaseDocs = allDocs.filter((doc) => 44 - // doc._meta.directory.startsWith("showcase"), 45 - // ); 46 - 47 - // Group component docs by folder name 48 - // oxlint-disable-next-line eslint-plugin-unicorn(no-array-reduce 49 - const componentGroups = componentDocs.reduce( 50 - (acc, doc) => { 51 - // Extract folder name from path like "components/form/select" -> "form" 52 - const pathParts = doc._meta.path.split("/"); 53 - const folderName = 54 - pathParts.length > 2 && pathParts[1] ? pathParts[1] : "components"; 55 - if (!acc[folderName]) { 56 - acc[folderName] = []; 57 - } 58 - acc[folderName]?.push(doc); 59 - return acc; 60 - }, 61 - {} as Record<string, typeof componentDocs>, 62 - ); 63 - 64 - const componentItems: Array<SidebarItem> = Object.entries(componentGroups) 65 - .toSorted(([a], [b]) => a.localeCompare(b)) 66 - .map(([folderName, docs]) => ({ 67 - id: `components-${folderName}`, 68 - label: folderName.charAt(0).toUpperCase() + folderName.slice(1), 69 - items: docs 70 - .toSorted((a, b) => a.title.localeCompare(b.title)) 71 - .map((doc) => ({ 72 - id: doc._meta.path, 73 - label: doc.title, 74 - to: "/docs/$", 75 - params: { _splat: doc._meta.path }, 76 - })), 77 - })); 78 - 79 - const sidebarItems: Array<SidebarItem> = [ 80 - { 81 - id: "introduction", 82 - label: "Introduction", 83 - to: "/docs/$", 84 - params: { _splat: "introduction" }, 85 - }, 86 - { 87 - id: "foundations", 88 - label: "Foundations", 89 - items: foundationDocs.map((doc) => ({ 90 - id: doc._meta.path, 91 - label: doc.title, 92 - to: "/docs/$", 93 - params: { _splat: doc._meta.path }, 94 - })), 95 - }, 96 - { 97 - id: "components", 98 - label: "Components", 99 - items: componentItems, 100 - }, 101 - // { 102 - // id: "showcases", 103 - // label: "Showcases", 104 - // items: showcaseDocs.map((doc) => ({ 105 - // id: doc._meta.path, 106 - // label: doc.title, 107 - // to: "/docs/$", 108 - // params: { _splat: doc._meta.path }, 109 - // })), 110 - // }, 111 - ]; 112 - 113 - const flatItems = sidebarItems 114 - .flatMap((item) => { 115 - if (!("items" in item) || !item.items) { 116 - return [item]; 117 - } 118 - // Flatten nested items (for components with folder groups) 119 - return item.items.flatMap((subItem) => { 120 - if (subItem.items) { 121 - return subItem.items; 122 - } 123 - return [subItem]; 124 - }); 125 - }) 126 - .filter((item): item is SidebarItem => item !== undefined); 127 - 128 - function DarkModeToggle() { 129 - const [colorScheme, setColorScheme] = useState<"light" | "dark">("light"); 130 - const toggleColorScheme = () => { 131 - const newColorScheme = colorScheme === "light" ? "dark" : "light"; 132 - 133 - setColorScheme(newColorScheme); 134 - localStorage.setItem("hip-ui-color-scheme", newColorScheme); 135 - document.body.style.colorScheme = newColorScheme; 136 - }; 137 - 138 - useEffect(() => { 139 - const localColorScheme = localStorage.getItem("hip-ui-color-scheme"); 140 - 141 - if (localColorScheme) { 142 - setColorScheme(localColorScheme as "light" | "dark"); 143 - } 144 - }, []); 145 - 146 - return ( 147 - <IconButton 148 - variant="secondary" 149 - label="Toggle Dark Mode" 150 - onPress={toggleColorScheme} 151 - > 152 - {colorScheme === "dark" ? <Moon /> : <Sun />} 153 - </IconButton> 154 - ); 155 - } 156 - 157 - function DocSidebar() { 158 - const location = useLocation(); 159 - const matches = useMatches(); 160 - const match = matches.find((m) => m.pathname === location.pathname); 161 - const currentItem = flatItems.find( 162 - (item) => 163 - match?.params && 164 - "_splat" in match.params && 165 - match.params._splat && 166 - item.id === match.params._splat.replace("/docs/", ""), 167 - ); 168 - 169 - return ( 170 - <Sidebar> 171 - <SidebarHeader 172 - action={ 173 - <Flex gap="xxs" align="center"> 174 - <ThemePicker /> 175 - <DarkModeToggle /> 176 - </Flex> 177 - } 178 - > 179 - <Text font="title" size="4xl" weight="bold"> 180 - Hip UI 181 - </Text> 182 - </SidebarHeader> 183 - {sidebarItems.map((item) => { 184 - if (!item.items) { 185 - return ( 186 - <SidebarItemLink 187 - key={item.id} 188 - to={item.to} 189 - params={item.params} 190 - isActive={currentItem?.id === item.id} 191 - > 192 - {item.label} 193 - </SidebarItemLink> 194 - ); 195 - } 196 - 197 - return ( 198 - <SidebarGroup title={item.label} key={item.id}> 199 - {item.items.map((subItem) => { 200 - // If subItem has nested items, it's a group (like component folders) 201 - if (subItem.items) { 202 - return ( 203 - <SidebarSection key={subItem.id} title={subItem.label}> 204 - {subItem.items.map((leafItem) => ( 205 - <SidebarItemLink 206 - key={leafItem.id} 207 - to={leafItem.to} 208 - params={leafItem.params} 209 - isActive={currentItem?.id === leafItem.id} 210 - > 211 - {leafItem.label} 212 - </SidebarItemLink> 213 - ))} 214 - </SidebarSection> 215 - ); 216 - } 217 - // Otherwise, it's a leaf item (like foundation/showcase docs) 218 - return ( 219 - <SidebarSection key={subItem.id}> 220 - <SidebarItemLink 221 - to={subItem.to} 222 - params={subItem.params} 223 - isActive={currentItem?.id === subItem.id} 224 - > 225 - {subItem.label} 226 - </SidebarItemLink> 227 - </SidebarSection> 228 - ); 229 - })} 230 - </SidebarGroup> 231 - ); 232 - })} 233 - </Sidebar> 234 - ); 235 - } 1 + import { Outlet, createFileRoute } from "@tanstack/react-router"; 236 2 237 3 export const Route = createFileRoute("/docs")({ 238 4 component: RouteComponent, 239 5 }); 240 6 241 7 function RouteComponent() { 242 - return ( 243 - <SidebarLayout.Root> 244 - <SidebarLayout.NavigationSidebar> 245 - <DocSidebar /> 246 - </SidebarLayout.NavigationSidebar> 247 - <Outlet /> 248 - </SidebarLayout.Root> 249 - ); 8 + return <Outlet />; 250 9 }
+25
pnpm-lock.yaml
··· 200 200 match-container: 201 201 specifier: ^0.1.0 202 202 version: 0.1.0 203 + node-html-markdown: 204 + specifier: ^2.0.0 205 + version: 2.0.0 203 206 raf-throttle: 204 207 specifier: ^2.0.6 205 208 version: 2.0.6 ··· 5133 5136 hast-util-whitespace@3.0.0: 5134 5137 resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} 5135 5138 5139 + he@1.2.0: 5140 + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} 5141 + hasBin: true 5142 + 5136 5143 hermes-estree@0.25.1: 5137 5144 resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} 5138 5145 ··· 6004 6011 6005 6012 neo-async@2.6.2: 6006 6013 resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} 6014 + 6015 + node-html-markdown@2.0.0: 6016 + resolution: {integrity: sha512-DqUC3GGP7pwSYxS93SwHoP+qCw78xcMP6C6H2DuC8rPD2AweJRjBzQb5SdXpKtDlqAQ7hVotJcfhgU7hU5Gthw==} 6017 + engines: {node: '>=20.0.0'} 6018 + 6019 + node-html-parser@6.1.13: 6020 + resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} 6007 6021 6008 6022 node-releases@2.0.36: 6009 6023 resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} ··· 12972 12986 dependencies: 12973 12987 '@types/hast': 3.0.4 12974 12988 12989 + he@1.2.0: {} 12990 + 12975 12991 hermes-estree@0.25.1: {} 12976 12992 12977 12993 hermes-parser@0.25.1: ··· 14104 14120 natural-orderby@5.0.0: {} 14105 14121 14106 14122 neo-async@2.6.2: {} 14123 + 14124 + node-html-markdown@2.0.0: 14125 + dependencies: 14126 + node-html-parser: 6.1.13 14127 + 14128 + node-html-parser@6.1.13: 14129 + dependencies: 14130 + css-select: 5.2.2 14131 + he: 1.2.0 14107 14132 14108 14133 node-releases@2.0.36: {} 14109 14134