diff --git a/apps/web/package.json b/apps/web/package.json index 952365189..fcb7e9c9a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@base-ui/react": "catalog:web", "@fontsource-variable/inter": "catalog:web", + "@pierre/diffs": "catalog:web", "@tanstack/react-devtools": "catalog:web", "@tanstack/react-form": "catalog:web", "@tanstack/react-query": "catalog:web", diff --git a/apps/web/src/components/layout/sidebar/app-sidebar.tsx b/apps/web/src/components/layout/sidebar/app-sidebar.tsx index 6273b71bc..7bddb3a5c 100644 --- a/apps/web/src/components/layout/sidebar/app-sidebar.tsx +++ b/apps/web/src/components/layout/sidebar/app-sidebar.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from "react"; import { Link, useLoaderData, useMatches } from "@tanstack/react-router"; -import { BookOpen, ExternalLink, Grid3X3, Lightbulb, Search, Type } from "lucide-react"; +import { BookOpen, ExternalLink, FlaskConical, Grid3X3, Lightbulb, Search, Type } from "lucide-react"; import { Sidebar, SidebarContent, @@ -33,6 +33,7 @@ const VERSION_ITEMS = [ const TOOLS_ITEMS = [ { to: "/file-explorer/$", params: { _splat: "" }, icon: BookOpen, label: "File Explorer" }, + { to: "/diffs-playground", icon: FlaskConical, label: "Diffs Playground" }, ] as const; export function AppSidebar({ ...props }: ComponentProps) { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c3f4498db..16e0c751d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SearchRouteImport } from './routes/search' +import { Route as DiffsPlaygroundRouteImport } from './routes/diffs-playground' import { Route as CodepointInspectorRouteImport } from './routes/codepoint-inspector' import { Route as FileExplorerRouteRouteImport } from './routes/file-explorer/route' import { Route as IndexRouteImport } from './routes/index' @@ -34,6 +35,11 @@ const SearchRoute = SearchRouteImport.update({ path: '/search', getParentRoute: () => rootRouteImport, } as any) +const DiffsPlaygroundRoute = DiffsPlaygroundRouteImport.update({ + id: '/diffs-playground', + path: '/diffs-playground', + getParentRoute: () => rootRouteImport, +} as any) const CodepointInspectorRoute = CodepointInspectorRouteImport.update({ id: '/codepoint-inspector', path: '/codepoint-inspector', @@ -131,6 +137,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/file-explorer': typeof FileExplorerRouteRouteWithChildren '/codepoint-inspector': typeof CodepointInspectorRoute + '/diffs-playground': typeof DiffsPlaygroundRoute '/search': typeof SearchRoute '/v/$version': typeof VVersionRouteRouteWithChildren '/file-explorer/$': typeof FileExplorerSplatRoute @@ -152,6 +159,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/file-explorer': typeof FileExplorerRouteRouteWithChildren '/codepoint-inspector': typeof CodepointInspectorRoute + '/diffs-playground': typeof DiffsPlaygroundRoute '/search': typeof SearchRoute '/file-explorer/$': typeof FileExplorerSplatRoute '/v': typeof VIndexRoute @@ -173,6 +181,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/file-explorer': typeof FileExplorerRouteRouteWithChildren '/codepoint-inspector': typeof CodepointInspectorRoute + '/diffs-playground': typeof DiffsPlaygroundRoute '/search': typeof SearchRoute '/v/$version': typeof VVersionRouteRouteWithChildren '/file-explorer/$': typeof FileExplorerSplatRoute @@ -196,6 +205,7 @@ export interface FileRouteTypes { | '/' | '/file-explorer' | '/codepoint-inspector' + | '/diffs-playground' | '/search' | '/v/$version' | '/file-explorer/$' @@ -217,6 +227,7 @@ export interface FileRouteTypes { | '/' | '/file-explorer' | '/codepoint-inspector' + | '/diffs-playground' | '/search' | '/file-explorer/$' | '/v' @@ -237,6 +248,7 @@ export interface FileRouteTypes { | '/' | '/file-explorer' | '/codepoint-inspector' + | '/diffs-playground' | '/search' | '/v/$version' | '/file-explorer/$' @@ -259,6 +271,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute FileExplorerRouteRoute: typeof FileExplorerRouteRouteWithChildren CodepointInspectorRoute: typeof CodepointInspectorRoute + DiffsPlaygroundRoute: typeof DiffsPlaygroundRoute SearchRoute: typeof SearchRoute VVersionRouteRoute: typeof VVersionRouteRouteWithChildren VIndexRoute: typeof VIndexRoute @@ -276,6 +289,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SearchRouteImport parentRoute: typeof rootRouteImport } + '/diffs-playground': { + id: '/diffs-playground' + path: '/diffs-playground' + fullPath: '/diffs-playground' + preLoaderRoute: typeof DiffsPlaygroundRouteImport + parentRoute: typeof rootRouteImport + } '/codepoint-inspector': { id: '/codepoint-inspector' path: '/codepoint-inspector' @@ -448,6 +468,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, FileExplorerRouteRoute: FileExplorerRouteRouteWithChildren, CodepointInspectorRoute: CodepointInspectorRoute, + DiffsPlaygroundRoute: DiffsPlaygroundRoute, SearchRoute: SearchRoute, VVersionRouteRoute: VVersionRouteRouteWithChildren, VIndexRoute: VIndexRoute, diff --git a/apps/web/src/routes/diffs-playground.tsx b/apps/web/src/routes/diffs-playground.tsx new file mode 100644 index 000000000..22c1ba8cb --- /dev/null +++ b/apps/web/src/routes/diffs-playground.tsx @@ -0,0 +1,541 @@ +import type { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"; +import { MultiFileDiff } from "@pierre/diffs/react"; +import { preloadMultiFileDiff } from "@pierre/diffs/ssr"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Moon, Sun } from "lucide-react"; +import { useState } from "react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +// Example diff data - static, module-level for performance +const DIFF_EXAMPLES = { + "typescript-function": { + label: "TypeScript Function", + description: "A small function body change", + oldFile: { + name: "utils.ts", + contents: `export function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return \`\${year}-\${month}-\${day}\`; +}`, + }, + newFile: { + name: "utils.ts", + contents: `export function formatDate(date: Date, locale = "en-US"): string { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(date); +}`, + }, + }, + "json-config": { + label: "JSON Config", + description: "Configuration file with added/removed keys", + oldFile: { + name: "config.json", + contents: `{ + "name": "my-app", + "version": "1.0.0", + "debug": true, + "api": { + "baseUrl": "http://localhost:3000", + "timeout": 5000 + } +}`, + }, + newFile: { + name: "config.json", + contents: `{ + "name": "my-app", + "version": "1.1.0", + "api": { + "baseUrl": "https://api.example.com", + "timeout": 10000, + "retries": 3 + }, + "features": { + "darkMode": true + } +}`, + }, + }, + "markdown-docs": { + label: "Markdown Docs", + description: "Documentation text changes", + oldFile: { + name: "README.md", + contents: `# My Project + +A simple project for demonstration. + +## Installation + +Run \`npm install\` to install dependencies. + +## Usage + +Import and use the main function.`, + }, + newFile: { + name: "README.md", + contents: `# My Project + +A powerful library for building modern applications. + +## Installation + +\`\`\`bash +npm install my-project +# or +pnpm add my-project +\`\`\` + +## Quick Start + +Import and use the main function: + +\`\`\`typescript +import { main } from "my-project"; + +main(); +\`\`\` + +## License + +MIT`, + }, + }, + "whitespace-only": { + label: "Whitespace Only", + description: "Only whitespace/formatting changes", + oldFile: { + name: "style.css", + contents: `.container{display:flex;justify-content:center;align-items:center;} +.button{padding:8px 16px;border-radius:4px;}`, + }, + newFile: { + name: "style.css", + contents: `.container { + display: flex; + justify-content: center; + align-items: center; +} + +.button { + padding: 8px 16px; + border-radius: 4px; +}`, + }, + }, + "no-changes": { + label: "No Changes", + description: "Identical files (no diff)", + oldFile: { + name: "constants.ts", + contents: `export const API_VERSION = "v1"; +export const MAX_RETRIES = 3; +export const TIMEOUT_MS = 5000;`, + }, + newFile: { + name: "constants.ts", + contents: `export const API_VERSION = "v1"; +export const MAX_RETRIES = 3; +export const TIMEOUT_MS = 5000;`, + }, + }, +} as const; + +type ExampleKey = keyof typeof DIFF_EXAMPLES; +type ThemeMode = "light" | "dark" | "system"; +type DiffStyle = "split" | "unified"; +type Roundedness = "none" | "sm" | "md" | "lg" | "full"; + +// CSS styles for different roundedness levels - injected via unsafeCSS +const ROUNDEDNESS_STYLES: Record = { + none: "", + sm: ` + :host { + border-radius: 4px; + overflow: hidden; + } + [data-diffs-header] { + border-radius: 4px 4px 0 0; + } + [data-separator='line-info'] [data-separator-wrapper] { + border-radius: 4px; + } + `, + md: ` + :host { + border-radius: 8px; + overflow: hidden; + } + [data-diffs-header] { + border-radius: 8px 8px 0 0; + } + [data-separator='line-info'] [data-separator-wrapper] { + border-radius: 6px; + } + `, + lg: ` + :host { + border-radius: 12px; + overflow: hidden; + } + [data-diffs-header] { + border-radius: 12px 12px 0 0; + } + [data-separator='line-info'] [data-separator-wrapper] { + border-radius: 8px; + } + `, + full: ` + :host { + border-radius: 16px; + overflow: hidden; + } + [data-diffs-header] { + border-radius: 16px 16px 0 0; + } + [data-separator='line-info'] [data-separator-wrapper] { + border-radius: 999px; + } + `, +}; + +const ROUNDEDNESS_OPTIONS: { value: Roundedness; label: string; description: string }[] = [ + { value: "none", label: "None", description: "Sharp corners" }, + { value: "sm", label: "Small", description: "4px radius" }, + { value: "md", label: "Medium", description: "8px radius" }, + { value: "lg", label: "Large", description: "12px radius" }, + { value: "full", label: "Full", description: "Maximum radius" }, +]; + +// Serializable subset of PreloadMultiFileDiffResult - only the parts we need for hydration +interface SerializablePreloadResult { + oldFile: { name: string; contents: string }; + newFile: { name: string; contents: string }; + prerenderedHTML: string; + // Only keep serializable options + options: { + theme: { light: string; dark: string }; + diffStyle: "split" | "unified"; + }; +} + +type PreloadedExamples = Record< + ExampleKey, + Record +>; + +// Extract only serializable parts from preload result +function toSerializable( + result: PreloadMultiFileDiffResult, + diffStyle: DiffStyle, +): SerializablePreloadResult { + return { + oldFile: { name: result.oldFile.name, contents: result.oldFile.contents }, + newFile: { name: result.newFile.name, contents: result.newFile.contents }, + prerenderedHTML: result.prerenderedHTML, + options: { + theme: { light: "pierre-light", dark: "pierre-dark" }, + diffStyle, + }, + }; +} + +// Preload all examples for all theme/style combinations on the server +async function preloadAllExamples(): Promise { + const examples = {} as PreloadedExamples; + + for (const [key, example] of Object.entries(DIFF_EXAMPLES)) { + const exampleKey = key as ExampleKey; + + const splitResult = await preloadMultiFileDiff({ + oldFile: example.oldFile, + newFile: example.newFile, + options: { + theme: { light: "pierre-light", dark: "pierre-dark" }, + diffStyle: "split", + }, + }); + + const unifiedResult = await preloadMultiFileDiff({ + oldFile: example.oldFile, + newFile: example.newFile, + options: { + theme: { light: "pierre-light", dark: "pierre-dark" }, + diffStyle: "unified", + }, + }); + + examples[exampleKey] = { + split: toSerializable(splitResult, "split"), + unified: toSerializable(unifiedResult, "unified"), + }; + } + + return examples; +} + +export const Route = createFileRoute("/diffs-playground")({ + component: DiffsPlayground, + loader: async () => { + const preloadedExamples = await preloadAllExamples(); + return { preloadedExamples }; + }, + head: () => ({ + meta: [ + { title: "Diffs Playground | UCD.js" }, + { name: "description", content: "Test and explore the @pierre/diffs library with SSR support, theme switching, and roundedness controls." }, + ], + }), +}); + +function DiffsPlayground() { + const { preloadedExamples } = Route.useLoaderData(); + + const [selectedExample, setSelectedExample] = useState("typescript-function"); + const [themeMode, setThemeMode] = useState("system"); + const [diffStyle, setDiffStyle] = useState("split"); + const [roundedness, setRoundedness] = useState("md"); + + const currentExample = DIFF_EXAMPLES[selectedExample]; + const preloadedDiff = preloadedExamples[selectedExample][diffStyle]; + + return ( + <> +
+
+ + + + + + Home} /> + + + + Diffs Playground + + + +
+
+ +
+ {/* Header */} +
+

Diffs Playground

+

+ Test the @pierre/diffs library with SSR support, theme switching, and roundedness controls for file explorer integration. +

+
+ + {/* Controls */} + + + Controls + Configure the diff viewer settings + + +
+ {/* Example Selector */} +
+ + +
+ + {/* Theme Toggle */} +
+ +
+ + + +
+
+ + {/* Diff Style Toggle */} +
+ +
+ + +
+
+ + {/* Roundedness Control */} +
+ +
+ {ROUNDEDNESS_OPTIONS.map((option) => ( + + ))} +
+
+
+
+
+ + {/* Example Description */} +
+ + {currentExample.label} + : + + {" "} + {currentExample.description} +
+ + {/* Diff Viewer */} + + +
+ +
+
+
+ + {/* CSS Output Preview */} + {roundedness !== "none" && ( + + + Generated CSS + + CSS injected via + {" "} + unsafeCSS + {" "} + option + + + +
+                {ROUNDEDNESS_STYLES[roundedness].trim()}
+              
+
+
+ )} + + {/* Future Plans Note */} + + + Future Plans + + +

+ TextMate Integration: + {" "} + Custom syntax highlighting using TextMate grammars + for languages not covered by Shiki's built-in themes. +

+

+ File Explorer Integration: + {" "} + Embed diff views in the file explorer component + for comparing Unicode data files across versions. +

+
+
+
+ + ); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e1faacf56..9de35b7f5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,19 @@ import viteTsConfigPaths from "vite-tsconfig-paths"; import * as MdxConfig from "./source.config"; const config = defineConfig({ + ssr: { + // lru_map is a CJS module that @pierre/diffs imports with named exports. + // We need to bundle both lru_map and @pierre/diffs so Vite handles CJS->ESM conversion. + noExternal: ["lru_map", "@pierre/diffs"], + }, + optimizeDeps: { + // Ensure lru_map is pre-bundled for client-side as well + include: ["lru_map"], + }, + define: { + // lru_map uses `self` in its UMD wrapper which doesn't exist in Node.js SSR context + self: "globalThis", + }, plugins: [ devtools(), mdx(MdxConfig), @@ -20,6 +33,10 @@ const config = defineConfig({ deployConfig: false, nodeCompat: true, }, + // Bundle lru_map and @pierre/diffs to handle CJS->ESM interop + externals: { + inline: ["lru_map", "@pierre/diffs"], + }, }), // this is the plugin that enables path aliases viteTsConfigPaths({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24aa9f0ee..9371d1fbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ catalogs: '@fontsource-variable/inter': specifier: 5.2.8 version: 5.2.8 + '@pierre/diffs': + specifier: 1.0.3 + version: 1.0.3 '@tailwindcss/vite': specifier: 4.1.18 version: 4.1.18 @@ -406,6 +409,9 @@ importers: '@fontsource-variable/inter': specifier: catalog:web version: 5.2.8 + '@pierre/diffs': + specifier: catalog:web + version: 1.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-devtools': specifier: catalog:web version: 0.9.1(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.9) @@ -2927,6 +2933,12 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@pierre/diffs@1.0.3': + resolution: {integrity: sha512-fFlwcBntFFLoPjGhivE82SOoUTXu8zeI10I8AsfGbcHQ9EHcvpZ/GpuyW6xBwkRLqIBKk8GSY2x/g3LIrJwpVA==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6821,6 +6833,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + lucide-react@0.562.0: resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} peerDependencies: @@ -10660,6 +10675,18 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@pierre/diffs@1.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@shikijs/core': 3.20.0 + '@shikijs/engine-javascript': 3.20.0 + '@shikijs/transformers': 3.20.0 + diff: 8.0.2 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + shiki: 3.20.0 + '@pkgr/core@0.2.9': {} '@polka/url@1.0.0-next.29': {} @@ -15206,6 +15233,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru_map@0.4.1: {} + lucide-react@0.562.0(react@19.2.3): dependencies: react: 19.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84f6535ac..1ec9880ca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -122,6 +122,7 @@ catalogs: class-variance-authority: 0.7.1 "@base-ui/react": 1.0.0 "@fontsource-variable/inter": 5.2.8 + "@pierre/diffs": 1.0.3 vscode: vscode-ext-gen: 1.5.1