From 221ae0242b8b2936ff5ec0eedb99fe459088fe7b Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Wed, 14 Jan 2026 07:29:09 +0800 Subject: [PATCH 1/6] Add package loading and Get Started panel to docs site Introduces the ability to load PureScript packages from the registry into the playground. Packages are fetched, decompressed, and registered with the WASM engine to enable type checking against library code. - Add Get Started panel with example code snippets - Add Packages panel with dependency resolution and progress tracking - Implement package fetching with pako decompression - Add cache layer for package set and loaded packages - Extend WASM engine with register_module and clear_packages Co-Authored-By: Claude Opus 4.5 --- docs/package.json | 2 + docs/pnpm-lock.yaml | 20 +- docs/src/App.tsx | 125 +++++++++- docs/src/components/GetStartedPanel.tsx | 39 ++++ docs/src/components/PackagePanel.tsx | 175 ++++++++++++++ docs/src/components/Tabs.tsx | 4 +- docs/src/lib/examples.ts | 297 ++++++++++++++++++++++++ docs/src/lib/index.ts | 12 +- docs/src/lib/packages/cache.ts | 60 +++++ docs/src/lib/packages/fetcher.ts | 129 ++++++++++ docs/src/lib/packages/index.ts | 4 + docs/src/lib/packages/resolver.ts | 48 ++++ docs/src/lib/packages/types.ts | 34 +++ docs/src/lib/types.ts | 12 +- docs/src/lib/worker/docs-lib.ts | 19 -- docs/src/wasm/src/engine.rs | 48 ++++ docs/src/wasm/src/lib.rs | 16 ++ docs/src/worker/docs-lib.ts | 93 ++++++++ 18 files changed, 1101 insertions(+), 36 deletions(-) create mode 100644 docs/src/components/GetStartedPanel.tsx create mode 100644 docs/src/components/PackagePanel.tsx create mode 100644 docs/src/lib/examples.ts create mode 100644 docs/src/lib/packages/cache.ts create mode 100644 docs/src/lib/packages/fetcher.ts create mode 100644 docs/src/lib/packages/index.ts create mode 100644 docs/src/lib/packages/resolver.ts create mode 100644 docs/src/lib/packages/types.ts delete mode 100644 docs/src/lib/worker/docs-lib.ts diff --git a/docs/package.json b/docs/package.json index fa48e5f5..dc705662 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,11 +17,13 @@ "@fontsource/manrope": "^5.2.8", "comlink": "^4.4.2", "monaco-editor": "^0.52.0", + "pako": "^2.1.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", + "@types/pako": "^2.0.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index f2e8b951..7d05ca3c 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: monaco-editor: specifier: ^0.52.0 version: 0.52.2 + pako: + specifier: ^2.1.0 + version: 2.1.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -30,6 +33,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.0 version: 4.1.18(vite@5.4.21(lightningcss@1.30.2)) + '@types/pako': + specifier: ^2.0.3 + version: 2.0.4 '@types/react': specifier: ^18.3.12 version: 18.3.27 @@ -55,10 +61,6 @@ importers: specifier: ^5.4.19 version: 5.4.21(lightningcss@1.30.2) - ../tests-package-set: {} - - ../vscode: {} - src/wasm/pkg: {} packages: @@ -524,6 +526,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -714,6 +719,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1172,6 +1180,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/pako@2.0.4': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -1338,6 +1348,8 @@ snapshots: node-releases@2.0.27: {} + pako@2.1.0: {} + picocolors@1.1.1: {} postcss@8.5.6: diff --git a/docs/src/App.tsx b/docs/src/App.tsx index 12df049a..66d2c31c 100644 --- a/docs/src/App.tsx +++ b/docs/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from "react"; +import * as Comlink from "comlink"; import { useDocsLib } from "./hooks/useDocsLib"; import { useDebounce } from "./hooks/useDebounce"; import { MonacoEditor } from "./components/Editor/MonacoEditor"; @@ -7,7 +8,17 @@ import { CstPanel } from "./components/CstPanel"; import { TypeCheckerPanel } from "./components/TypeCheckerPanel"; import { PerformanceBar } from "./components/PerformanceBar"; import { ThemeSwitcher } from "./components/ThemeSwitcher"; +import { PackagePanel } from "./components/PackagePanel"; +import { GetStartedPanel } from "./components/GetStartedPanel"; import type { ParseResult, CheckResult, Mode, Timing } from "./lib/types"; +import type { PackageSet, PackageLoadProgress } from "./lib/packages/types"; +import { + loadCachedPackageSet, + savePackageSetToCache, + loadCachedPackages, + saveCachedPackages, + clearCachedPackages, +} from "./lib/packages/cache"; const DEFAULT_SOURCE = `module Main where @@ -28,11 +39,17 @@ solveUnion = { deriveUnion, deriveUnionLeft } export default function App() { const docsLib = useDocsLib(); const [source, setSource] = useState(DEFAULT_SOURCE); - const [mode, setMode] = useState("cst"); + const [mode, setMode] = useState("getstarted"); const [parseResult, setParseResult] = useState(null); const [checkResult, setCheckResult] = useState(null); const [timing, setTiming] = useState(null); + // Package state + const [packageSet, setPackageSet] = useState(null); + const [loadProgress, setLoadProgress] = useState(null); + const [loadedPackages, setLoadedPackages] = useState([]); + const [isLoadingPackages, setIsLoadingPackages] = useState(false); + const debouncedSource = useDebounce(source, 150); const runAnalysis = useCallback(async () => { @@ -74,6 +91,101 @@ export default function App() { runAnalysis(); }, [runAnalysis]); + // Load package set on mount + useEffect(() => { + if (docsLib.status !== "ready") return; + const lib = docsLib.lib; + + const loadPackageSet = async () => { + // Try cache first + const cached = loadCachedPackageSet(); + if (cached) { + setPackageSet(cached); + return; + } + + try { + const ps = await lib.fetchPackageSet(); + setPackageSet(ps); + savePackageSetToCache(ps); + } catch (e) { + console.error("Failed to fetch package set:", e); + } + }; + + loadPackageSet(); + }, [docsLib]); + + // Restore cached packages on mount + useEffect(() => { + if (docsLib.status !== "ready" || !packageSet) return; + const lib = docsLib.lib; + + const restoreCachedPackages = async () => { + const cached = loadCachedPackages(); + if (cached.size === 0) return; + + setIsLoadingPackages(true); + const packageNames = Array.from(cached.keys()); + + try { + const loaded = await lib.loadPackages( + packageSet, + packageNames, + Comlink.proxy((progress) => setLoadProgress(progress)) + ); + setLoadedPackages(loaded.map((p) => p.name)); + } catch (e) { + console.error("Failed to restore cached packages:", e); + } finally { + setIsLoadingPackages(false); + } + }; + + restoreCachedPackages(); + }, [docsLib, packageSet]); + + // Package handlers + const handleAddPackage = useCallback( + async (packageName: string) => { + if (!packageSet || docsLib.status !== "ready" || isLoadingPackages) return; + + setIsLoadingPackages(true); + const packagesToLoad = [...loadedPackages, packageName]; + + try { + const loaded = await docsLib.lib.loadPackages( + packageSet, + packagesToLoad, + Comlink.proxy((progress) => setLoadProgress(progress)) + ); + + const loadedNames = loaded.map((p) => p.name); + setLoadedPackages(loadedNames); + saveCachedPackages(new Map(loaded.map((p) => [p.name, p]))); + } catch (e) { + console.error("Failed to load package:", e); + } finally { + setIsLoadingPackages(false); + } + }, + [packageSet, loadedPackages, docsLib, isLoadingPackages] + ); + + const handleClearPackages = useCallback(async () => { + if (docsLib.status !== "ready") return; + + await docsLib.lib.clearPackages(); + setLoadedPackages([]); + setLoadProgress(null); + clearCachedPackages(); + }, [docsLib]); + + const handleSelectExample = useCallback((source: string) => { + setSource(source); + setMode("typechecker"); + }, []); + if (docsLib.status === "loading") { return (
@@ -108,8 +220,19 @@ export default function App() {
+ {mode === "getstarted" && } {mode === "cst" && } {mode === "typechecker" && } + {mode === "packages" && ( + + )}
diff --git a/docs/src/components/GetStartedPanel.tsx b/docs/src/components/GetStartedPanel.tsx new file mode 100644 index 00000000..54a0b4e8 --- /dev/null +++ b/docs/src/components/GetStartedPanel.tsx @@ -0,0 +1,39 @@ +import { EXAMPLES, CATEGORIES } from "../lib/examples"; + +interface Props { + onSelectExample: (source: string) => void; +} + +export function GetStartedPanel({ onSelectExample }: Props) { + return ( +
+
+

Welcome to PureScript Analyzer

+

+ Explore PureScript's type system interactively. Select an example below to load it into + the editor, then switch to the Type Checker tab to see inferred types. +

+
+ + {CATEGORIES.map((category) => ( +
+

+ {category} +

+
+ {EXAMPLES.filter((e) => e.category === category).map((example) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/docs/src/components/PackagePanel.tsx b/docs/src/components/PackagePanel.tsx new file mode 100644 index 00000000..3003cb1b --- /dev/null +++ b/docs/src/components/PackagePanel.tsx @@ -0,0 +1,175 @@ +import { useState, useCallback } from "react"; +import type { PackageSet, PackageLoadProgress, PackageStatus } from "../lib/packages/types"; + +interface Props { + packageSet: PackageSet | null; + loadProgress: PackageLoadProgress | null; + loadedPackages: string[]; + onAddPackage: (name: string) => void; + onClearPackages: () => void; + isLoading: boolean; +} + +function StatusBadge({ status }: { status: PackageStatus }) { + switch (status.state) { + case "pending": + return Pending; + case "downloading": + return {Math.round(status.progress * 100)}%; + case "extracting": + return Extracting...; + case "ready": + return ( + + {status.moduleCount} module{status.moduleCount !== 1 ? "s" : ""} + + ); + case "error": + return ( + + Error + + ); + } +} + +export function PackagePanel({ + packageSet, + loadProgress, + loadedPackages, + onAddPackage, + onClearPackages, + isLoading, +}: Props) { + const [input, setInput] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + + const handleInputChange = useCallback( + (value: string) => { + setInput(value); + if (packageSet && value.length > 1) { + const matches = Object.keys(packageSet) + .filter((p) => p.toLowerCase().includes(value.toLowerCase())) + .filter((p) => !loadedPackages.includes(p)) + .slice(0, 8); + setSuggestions(matches); + setShowSuggestions(matches.length > 0); + } else { + setSuggestions([]); + setShowSuggestions(false); + } + }, + [packageSet, loadedPackages] + ); + + const handleAddPackage = useCallback( + (pkg: string) => { + if (pkg && packageSet && packageSet[pkg]) { + onAddPackage(pkg); + setInput(""); + setSuggestions([]); + setShowSuggestions(false); + } + }, + [packageSet, onAddPackage] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && input) { + handleAddPackage(input); + } else if (e.key === "Escape") { + setShowSuggestions(false); + } + }, + [input, handleAddPackage] + ); + + return ( +
+

Packages

+ + {/* Search/Add Package */} +
+ handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + placeholder="Add package (e.g., prelude)" + className="w-full rounded bg-bg-lighter px-3 py-2 text-sm text-fg placeholder-fg-subtle outline-none focus:ring-1 focus:ring-teal-400/50" + disabled={!packageSet || isLoading} + /> + {showSuggestions && ( +
+ {suggestions.map((pkg) => ( + + ))} +
+ )} +
+ + {/* Progress - only show while actively loading */} + {isLoading && loadProgress && loadProgress.totalPackages > 0 && ( +
+
+ Loading {loadProgress.completedPackages}/{loadProgress.totalPackages} packages +
+
+
+
+
+ )} + + {/* Loaded Packages List */} + {loadProgress && loadProgress.packages.size > 0 && ( +
+
+ {loadProgress.packages.size} package{loadProgress.packages.size !== 1 ? "s" : ""} loaded +
+
+ {Array.from(loadProgress.packages.entries()).map(([name, status]) => ( +
+ {name} + +
+ ))} +
+
+ )} + + {/* Clear Button */} + {loadedPackages.length > 0 && !isLoading && ( + + )} + + {/* Empty state */} + {loadedPackages.length === 0 && !isLoading && ( +
+ No packages loaded. Add packages to import modules from the registry. +
+ )} +
+ ); +} diff --git a/docs/src/components/Tabs.tsx b/docs/src/components/Tabs.tsx index bfcf68b1..c3a77e21 100644 --- a/docs/src/components/Tabs.tsx +++ b/docs/src/components/Tabs.tsx @@ -6,8 +6,10 @@ interface Props { } const tabs: { id: Mode; label: string }[] = [ - { id: "cst", label: "CST Preview" }, + { id: "getstarted", label: "Get Started" }, { id: "typechecker", label: "Type Checker" }, + { id: "cst", label: "CST Preview" }, + { id: "packages", label: "Packages" }, ]; export function Tabs({ activeTab, onTabChange }: Props) { diff --git a/docs/src/lib/examples.ts b/docs/src/lib/examples.ts new file mode 100644 index 00000000..8f33a18b --- /dev/null +++ b/docs/src/lib/examples.ts @@ -0,0 +1,297 @@ +export interface Example { + id: string; + title: string; + description: string; + category: string; + source: string; +} + +export const EXAMPLES: Example[] = [ + // Type-Level Programming + { + id: "row-union", + title: "Row Union Solving", + description: "Demonstrates bidirectional Row.Union constraint solving for extensible records.", + category: "Type-Level Programming", + source: `module Main where + +import Prim.Row as Row + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +deriveUnion :: forall u. Row.Union (a :: Int) (b :: String) u => Proxy u +deriveUnion = Proxy + +deriveUnionLeft :: forall l. Row.Union l (b :: String) (a :: Int, b :: String) => Proxy l +deriveUnionLeft = Proxy + +deriveUnionRight :: forall r. Row.Union (a :: Int) r (a :: Int, b :: String) => Proxy r +deriveUnionRight = Proxy + +solveUnion = { deriveUnion, deriveUnionLeft, deriveUnionRight } +`, + }, + { + id: "row-cons", + title: "Row.Cons Operations", + description: "Shows how Row.Cons constructs and deconstructs row types at the type level.", + category: "Type-Level Programming", + source: `module Main where + +import Prim.Row as Row + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +-- Derive the full row from label, type, and tail +deriveCons :: forall row. Row.Cons "name" String () row => Proxy row +deriveCons = Proxy + +-- Derive the tail from the full row +deriveTail :: forall tail. Row.Cons "name" String tail (name :: String, age :: Int) => Proxy tail +deriveTail = Proxy + +-- Derive the field type from the full row +deriveType :: forall t. Row.Cons "name" t () (name :: String) => Proxy t +deriveType = Proxy + +solveCons = { deriveCons, deriveTail, deriveType } +`, + }, + { + id: "rowlist", + title: "RowToList Conversion", + description: "Converts row types to type-level lists for type-level iteration.", + category: "Type-Level Programming", + source: `module Main where + +import Prim.RowList as RL + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +rowToListSimple :: forall list. RL.RowToList (a :: Int) list => Proxy list +rowToListSimple = Proxy + +rowToListMultiple :: forall list. RL.RowToList (b :: String, a :: Int) list => Proxy list +rowToListMultiple = Proxy + +rowToListEmpty :: forall list. RL.RowToList () list => Proxy list +rowToListEmpty = Proxy + +solveRowToList = { rowToListSimple, rowToListMultiple, rowToListEmpty } +`, + }, + { + id: "symbol-ops", + title: "Symbol Operations", + description: "Type-level string operations: append, compare, and cons.", + category: "Type-Level Programming", + source: `module Main where + +import Prim.Symbol (class Append, class Compare, class Cons) +import Prim.Ordering (Ordering, LT, EQ, GT) + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +-- Append: Derive appended from left and right +deriveAppended :: forall appended. Append "Hello" "World" appended => Proxy appended +deriveAppended = Proxy + +-- Append: Derive left from right and appended +deriveLeft :: forall left. Append left "World" "HelloWorld" => Proxy left +deriveLeft = Proxy + +-- Compare symbols +compareLT :: forall ord. Compare "a" "b" ord => Proxy ord +compareLT = Proxy + +compareEQ :: forall ord. Compare "hello" "hello" ord => Proxy ord +compareEQ = Proxy + +-- Cons: Derive symbol from head and tail +deriveCons :: forall symbol. Cons "H" "ello" symbol => Proxy symbol +deriveCons = Proxy + +forceSolve = { deriveAppended, deriveLeft, compareLT, compareEQ, deriveCons } +`, + }, + { + id: "int-ops", + title: "Type-Level Integers", + description: "Compile-time integer arithmetic: add, multiply, compare.", + category: "Type-Level Programming", + source: `module Main where + +import Prim.Int (class Add, class Mul, class Compare, class ToString) +import Prim.Ordering (Ordering, LT, EQ, GT) + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +-- Add: Derive sum from operands +deriveSum :: forall sum. Add 1 2 sum => Proxy sum +deriveSum = Proxy + +-- Add: Derive right operand from left and sum +deriveRight :: forall right. Add 1 right 3 => Proxy right +deriveRight = Proxy + +-- Mul: Derive product from operands +deriveMul :: forall product. Mul 3 4 product => Proxy product +deriveMul = Proxy + +-- Compare integers +compareLT :: forall ord. Compare 1 2 ord => Proxy ord +compareLT = Proxy + +compareGT :: forall ord. Compare 10 3 ord => Proxy ord +compareGT = Proxy + +-- ToString: Convert integer to symbol +deriveString :: forall s. ToString 42 s => Proxy s +deriveString = Proxy + +forceSolve = { deriveSum, deriveRight, deriveMul, compareLT, compareGT, deriveString } +`, + }, + + // Generic Deriving + { + id: "derive-generic", + title: "Generic Deriving", + description: "Derive Generic instances to get type-level representations of data types.", + category: "Generic Deriving", + source: `module Main where + +import Data.Generic.Rep (class Generic) + +data Void + +data MyUnit = MyUnit + +data Either a b = Left a | Right b + +data Tuple a b = Tuple a b + +newtype Wrapper a = Wrapper a + +derive instance Generic Void _ +derive instance Generic MyUnit _ +derive instance Generic (Either a b) _ +derive instance Generic (Tuple a b) _ +derive instance Generic (Wrapper a) _ + +-- Use Proxy to force solving and emit Rep types +data Proxy a = Proxy + +getVoid :: forall rep. Generic Void rep => Proxy rep +getVoid = Proxy + +getMyUnit :: forall rep. Generic MyUnit rep => Proxy rep +getMyUnit = Proxy + +getEither :: forall a b rep. Generic (Either a b) rep => Proxy rep +getEither = Proxy + +forceSolve = { getVoid, getMyUnit, getEither } +`, + }, + { + id: "derive-newtype", + title: "Newtype Deriving", + description: "Derive Newtype instances for wrapper types to enable coercions.", + category: "Generic Deriving", + source: `module Main where + +import Data.Newtype (class Newtype) + +newtype UserId = UserId Int + +newtype Email = Email String + +newtype Wrapper a = Wrapper a + +derive instance Newtype UserId _ +derive instance Newtype Email _ +derive instance Newtype (Wrapper a) _ +`, + }, + + // Type Classes + { + id: "class-functor", + title: "Functor Class", + description: "Higher-kinded type class for mappable containers.", + category: "Type Classes", + source: `module Main where + +class Functor f where + map :: forall a b. (a -> b) -> f a -> f b + +data Maybe a = Nothing | Just a + +instance Functor Maybe where + map _ Nothing = Nothing + map f (Just a) = Just (f a) + +data List a = Nil | Cons a (List a) + +instance Functor List where + map _ Nil = Nil + map f (Cons x xs) = Cons (f x) (map f xs) +`, + }, + { + id: "fundep", + title: "Functional Dependencies", + description: "Use functional dependencies to guide type inference.", + category: "Type Classes", + source: `module Main where + +-- Class with functional dependency: knowing 'a' determines 'b' +class Convert a b | a -> b where + convert :: a -> b + +instance Convert Int String where + convert _ = "int" + +instance Convert Boolean String where + convert _ = "bool" + +-- The fundep allows inferring the return type from the input type +testInt = convert 42 +testBool = convert true +`, + }, + { + id: "instance-chains", + title: "Instance Chains", + description: "Use 'else' to create overlapping instances with fallback behavior.", + category: "Type Classes", + source: `module Main where + +import Prim.Boolean (True, False) + +data Proxy a = Proxy + +class TypeEq a b r | a b -> r + +instance TypeEq a a True +else instance TypeEq a b False + +testSame :: forall r. TypeEq Int Int r => Proxy r +testSame = Proxy + +testDiff :: forall r. TypeEq Int String r => Proxy r +testDiff = Proxy + +-- Force instantiation to verify resolved types +test = { testSame, testDiff } +`, + }, +]; + +export const CATEGORIES = [...new Set(EXAMPLES.map((e) => e.category))]; diff --git a/docs/src/lib/index.ts b/docs/src/lib/index.ts index 824ddc19..eac85473 100644 --- a/docs/src/lib/index.ts +++ b/docs/src/lib/index.ts @@ -1,10 +1,2 @@ -import * as Comlink from "comlink"; -import type { Lib } from "./worker/docs-lib"; - -export async function createDocsLib() { - const module = await import("./worker/docs-lib?worker"); - const worker = new module.default(); - const remote = Comlink.wrap(worker); - await remote.init(); - return remote; -} +// Re-export from worker.ts +export { createDocsLib } from "./worker"; diff --git a/docs/src/lib/packages/cache.ts b/docs/src/lib/packages/cache.ts new file mode 100644 index 00000000..8021039f --- /dev/null +++ b/docs/src/lib/packages/cache.ts @@ -0,0 +1,60 @@ +import type { LoadedPackage, PackageSet } from "./types"; + +const STORAGE_KEY_PACKAGES = "purescript-analyzer:packages"; +const STORAGE_KEY_PACKAGE_SET = "purescript-analyzer:package-set"; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +interface CachedPackageSet { + data: PackageSet; + fetchedAt: number; +} + +interface CachedPackages { + packages: Record; +} + +export function loadCachedPackageSet(): PackageSet | null { + try { + const raw = localStorage.getItem(STORAGE_KEY_PACKAGE_SET); + if (!raw) return null; + + const cached: CachedPackageSet = JSON.parse(raw); + if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) { + return null; // Expired + } + return cached.data; + } catch { + return null; + } +} + +export function savePackageSetToCache(packageSet: PackageSet): void { + const cached: CachedPackageSet = { + data: packageSet, + fetchedAt: Date.now(), + }; + localStorage.setItem(STORAGE_KEY_PACKAGE_SET, JSON.stringify(cached)); +} + +export function loadCachedPackages(): Map { + try { + const raw = localStorage.getItem(STORAGE_KEY_PACKAGES); + if (!raw) return new Map(); + + const cached: CachedPackages = JSON.parse(raw); + return new Map(Object.entries(cached.packages)); + } catch { + return new Map(); + } +} + +export function saveCachedPackages(packages: Map): void { + const cached: CachedPackages = { + packages: Object.fromEntries(packages), + }; + localStorage.setItem(STORAGE_KEY_PACKAGES, JSON.stringify(cached)); +} + +export function clearCachedPackages(): void { + localStorage.removeItem(STORAGE_KEY_PACKAGES); +} diff --git a/docs/src/lib/packages/fetcher.ts b/docs/src/lib/packages/fetcher.ts new file mode 100644 index 00000000..4798ca73 --- /dev/null +++ b/docs/src/lib/packages/fetcher.ts @@ -0,0 +1,129 @@ +import pako from "pako"; +import type { PackageModule, PackageSet } from "./types"; + +const REGISTRY_URL = "https://packages.registry.purescript.org"; +const PACKAGE_SET_URL = + "https://raw.githubusercontent.com/purescript/package-sets/master/packages.json"; + +/** + * Parse tar archive and extract .purs files. + * Tar format: 512-byte headers followed by file content (padded to 512). + */ +function parseTar(data: Uint8Array): Map { + const files = new Map(); + const decoder = new TextDecoder("utf-8"); + let offset = 0; + + while (offset < data.length - 512) { + // Read header (512 bytes) + const header = data.slice(offset, offset + 512); + + // Check for empty block (end of archive) + if (header.every((b) => b === 0)) break; + + // Parse filename (bytes 0-99, null-terminated) + const nameBytes = header.slice(0, 100); + const nameEnd = nameBytes.indexOf(0); + const name = decoder.decode(nameBytes.slice(0, nameEnd > 0 ? nameEnd : 100)); + + // Parse file size (bytes 124-135, octal string) + const sizeStr = decoder.decode(header.slice(124, 136)).replace(/\0/g, "").trim(); + const size = parseInt(sizeStr, 8) || 0; + + // Parse type flag (byte 156): '0' or '\0' = regular file + const typeFlag = header[156]; + const isFile = typeFlag === 0 || typeFlag === 48; // 48 = '0' + + offset += 512; // Move past header + + if (isFile && size > 0) { + const content = data.slice(offset, offset + size); + + // Only extract .purs files from src/ directory + if (name.endsWith(".purs") && name.includes("/src/")) { + const source = decoder.decode(content); + files.set(name, source); + } + } + + // Move to next header (content padded to 512-byte boundary) + offset += Math.ceil(size / 512) * 512; + } + + return files; +} + +/** + * Extract module name from PureScript source. + * Parses the "module X.Y.Z where" declaration. + */ +function extractModuleName(source: string): string | null { + const match = source.match(/^\s*module\s+([\w.]+)/m); + return match ? match[1] : null; +} + +export async function fetchPackage( + packageName: string, + version: string, + onProgress?: (progress: number) => void +): Promise { + // Strip 'v' prefix from version for registry URL + const versionNum = version.startsWith("v") ? version.slice(1) : version; + const url = `${REGISTRY_URL}/${packageName}/${versionNum}.tar.gz`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${packageName}@${version}: ${response.status}`); + } + + const contentLength = response.headers.get("content-length"); + const total = contentLength ? parseInt(contentLength, 10) : 0; + + // Read with progress tracking + const reader = response.body!.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (total && onProgress) { + onProgress(received / total); + } + } + + // Combine chunks + const compressed = new Uint8Array(received); + let position = 0; + for (const chunk of chunks) { + compressed.set(chunk, position); + position += chunk.length; + } + + // Decompress gzip + const tarData = pako.ungzip(compressed); + + // Extract .purs files + const files = parseTar(tarData); + + // Convert to modules + const modules: PackageModule[] = []; + for (const [, source] of files) { + const moduleName = extractModuleName(source); + if (moduleName) { + modules.push({ name: moduleName, source }); + } + } + + return modules; +} + +export async function fetchPackageSet(): Promise { + const response = await fetch(PACKAGE_SET_URL); + if (!response.ok) { + throw new Error(`Failed to fetch package set: ${response.status}`); + } + return response.json(); +} diff --git a/docs/src/lib/packages/index.ts b/docs/src/lib/packages/index.ts new file mode 100644 index 00000000..248dbc86 --- /dev/null +++ b/docs/src/lib/packages/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./resolver"; +export * from "./fetcher"; +export * from "./cache"; diff --git a/docs/src/lib/packages/resolver.ts b/docs/src/lib/packages/resolver.ts new file mode 100644 index 00000000..08922f9a --- /dev/null +++ b/docs/src/lib/packages/resolver.ts @@ -0,0 +1,48 @@ +import type { PackageSet } from "./types"; + +export interface DependencyTree { + direct: string[]; + transitive: string[]; + all: string[]; // direct + transitive, topologically sorted +} + +/** + * Compute transitive closure of dependencies. + * Returns packages in topological order (dependencies before dependents). + */ +export function resolveTransitiveDependencies( + packageSet: PackageSet, + requestedPackages: string[] +): DependencyTree { + const visited = new Set(); + const result: string[] = []; + + function visit(pkg: string) { + if (visited.has(pkg)) return; + visited.add(pkg); + + const entry = packageSet[pkg]; + if (!entry) { + console.warn(`Package not found in set: ${pkg}`); + return; + } + + // Visit dependencies first (topological sort) + for (const dep of entry.dependencies) { + visit(dep); + } + + result.push(pkg); + } + + for (const pkg of requestedPackages) { + visit(pkg); + } + + const directSet = new Set(requestedPackages); + return { + direct: requestedPackages.filter((p) => packageSet[p]), + transitive: result.filter((p) => !directSet.has(p)), + all: result, + }; +} diff --git a/docs/src/lib/packages/types.ts b/docs/src/lib/packages/types.ts new file mode 100644 index 00000000..a5292a9a --- /dev/null +++ b/docs/src/lib/packages/types.ts @@ -0,0 +1,34 @@ +// Package set format from purescript/package-sets +export interface PackageSetEntry { + version: string; + repo: string; + dependencies: string[]; +} + +export type PackageSet = Record; + +// Internal state +export interface PackageModule { + name: string; // e.g., "Data.Maybe" + source: string; // PureScript source code +} + +export interface LoadedPackage { + name: string; + version: string; + modules: PackageModule[]; + loadedAt: number; // timestamp for cache validation +} + +export type PackageStatus = + | { state: "pending" } + | { state: "downloading"; progress: number } + | { state: "extracting" } + | { state: "ready"; moduleCount: number } + | { state: "error"; message: string }; + +export interface PackageLoadProgress { + packages: Map; + totalPackages: number; + completedPackages: number; +} diff --git a/docs/src/lib/types.ts b/docs/src/lib/types.ts index b008ad63..a2b73243 100644 --- a/docs/src/lib/types.ts +++ b/docs/src/lib/types.ts @@ -1,4 +1,6 @@ -export type Mode = "cst" | "typechecker"; +import type { PackageSet, LoadedPackage, PackageLoadProgress } from "./packages/types"; + +export type Mode = "cst" | "typechecker" | "getstarted" | "packages"; export interface ParseResult { output: string; @@ -57,4 +59,12 @@ export interface Lib { init(): Promise; parse(source: string): Promise; check(source: string): Promise; + fetchPackageSet(): Promise; + loadPackages( + packageSet: PackageSet, + packageNames: string[], + onProgress: (progress: PackageLoadProgress) => void + ): Promise; + clearPackages(): Promise; + registerModule(moduleName: string, source: string): Promise; } diff --git a/docs/src/lib/worker/docs-lib.ts b/docs/src/lib/worker/docs-lib.ts deleted file mode 100644 index 0ef1afcc..00000000 --- a/docs/src/lib/worker/docs-lib.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Comlink from "comlink"; -import init, * as docsLib from "docs-lib"; - -const lib = { - async init() { - await init(); - }, - async parse(source: string) { - let { output, lex, layout, parse } = docsLib.parse(source); - return { output, lex, layout, parse }; - }, -}; - -export interface Lib { - init(): Promise; - parse(source: string): Promise<{ output: string; lex: number; layout: number; parse: number }>; -} - -Comlink.expose(lib); diff --git a/docs/src/wasm/src/engine.rs b/docs/src/wasm/src/engine.rs index 0c1ca698..70e26ee7 100644 --- a/docs/src/wasm/src/engine.rs +++ b/docs/src/wasm/src/engine.rs @@ -48,6 +48,8 @@ pub struct WasmQueryEngine { prim_id: FileId, user_id: Option, + /// FileIds of external (package) modules, for cleanup + external_ids: Vec, } impl WasmQueryEngine { @@ -78,6 +80,52 @@ impl WasmQueryEngine { interned: RefCell::new(interned), prim_id: prim_id.expect("invariant violated: Prim must exist"), user_id: None, + external_ids: Vec::new(), + } + } + + /// Register an external module (from a package). + /// Returns the FileId for the module. + pub fn register_external_module(&mut self, module_name: &str, source: &str) -> FileId { + let path = format!("pkg://registry/{module_name}.purs"); + let id = self.files.borrow_mut().insert(path.as_str(), source); + + self.input.borrow_mut().content.insert(id, Arc::from(source)); + + let name_id = self.interned.borrow_mut().module.intern(module_name); + self.input.borrow_mut().module.insert(name_id, id); + + self.external_ids.push(id); + id + } + + /// Clear all external modules (packages), keeping Prim and user modules. + pub fn clear_external_modules(&mut self) { + let mut derived = self.derived.borrow_mut(); + let mut input = self.input.borrow_mut(); + + for id in self.external_ids.drain(..) { + input.content.remove(&id); + derived.parsed.remove(&id); + derived.stabilized.remove(&id); + derived.indexed.remove(&id); + derived.lowered.remove(&id); + derived.resolved.remove(&id); + derived.bracketed.remove(&id); + derived.sectioned.remove(&id); + derived.checked.remove(&id); + } + + // Also clear caches for user module since imports may have changed + if let Some(user_id) = self.user_id { + derived.parsed.remove(&user_id); + derived.stabilized.remove(&user_id); + derived.indexed.remove(&user_id); + derived.lowered.remove(&user_id); + derived.resolved.remove(&user_id); + derived.bracketed.remove(&user_id); + derived.sectioned.remove(&user_id); + derived.checked.remove(&user_id); } } diff --git a/docs/src/wasm/src/lib.rs b/docs/src/wasm/src/lib.rs index 0e455b48..3eaf0593 100644 --- a/docs/src/wasm/src/lib.rs +++ b/docs/src/wasm/src/lib.rs @@ -313,3 +313,19 @@ pub fn check(source: &str) -> JsValue { serde_wasm_bindgen::to_value(&result).unwrap() } + +/// Register an external module (from a package) with the engine. +#[wasm_bindgen] +pub fn register_module(module_name: &str, source: &str) { + ENGINE.with_borrow_mut(|engine| { + engine.register_external_module(module_name, source); + }); +} + +/// Clear all external modules (packages), keeping Prim and user modules. +#[wasm_bindgen] +pub fn clear_packages() { + ENGINE.with_borrow_mut(|engine| { + engine.clear_external_modules(); + }); +} diff --git a/docs/src/worker/docs-lib.ts b/docs/src/worker/docs-lib.ts index 21cd0b00..4c56c488 100644 --- a/docs/src/worker/docs-lib.ts +++ b/docs/src/worker/docs-lib.ts @@ -1,6 +1,14 @@ import * as Comlink from "comlink"; import init, * as docsLib from "docs-lib"; import type { ParseResult, CheckResult } from "../lib/types"; +import type { + PackageSet, + LoadedPackage, + PackageLoadProgress, + PackageStatus, +} from "../lib/packages/types"; +import { fetchPackage, fetchPackageSet } from "../lib/packages/fetcher"; +import { resolveTransitiveDependencies } from "../lib/packages/resolver"; const lib = { async init() { @@ -15,6 +23,91 @@ const lib = { async check(source: string): Promise { return docsLib.check(source) as CheckResult; }, + + async fetchPackageSet(): Promise { + return fetchPackageSet(); + }, + + async loadPackages( + packageSet: PackageSet, + packageNames: string[], + onProgress: (progress: PackageLoadProgress) => void + ): Promise { + const deps = resolveTransitiveDependencies(packageSet, packageNames); + const progress: PackageLoadProgress = { + packages: new Map(), + totalPackages: deps.all.length, + completedPackages: 0, + }; + + // Initialize all as pending + for (const pkg of deps.all) { + progress.packages.set(pkg, { state: "pending" }); + } + onProgress(progress); + + const loaded: LoadedPackage[] = []; + + // Fetch in batches (parallel within batch, serial between batches) + const BATCH_SIZE = 4; + for (let i = 0; i < deps.all.length; i += BATCH_SIZE) { + const batch = deps.all.slice(i, i + BATCH_SIZE); + + const results = await Promise.all( + batch.map(async (pkgName) => { + const entry = packageSet[pkgName]; + progress.packages.set(pkgName, { state: "downloading", progress: 0 }); + onProgress({ ...progress, packages: new Map(progress.packages) }); + + try { + const modules = await fetchPackage(pkgName, entry.version, (p) => { + progress.packages.set(pkgName, { state: "downloading", progress: p }); + onProgress({ ...progress, packages: new Map(progress.packages) }); + }); + + progress.packages.set(pkgName, { state: "extracting" }); + onProgress({ ...progress, packages: new Map(progress.packages) }); + + // Register modules with WASM engine + for (const mod of modules) { + docsLib.register_module(mod.name, mod.source); + } + + progress.packages.set(pkgName, { state: "ready", moduleCount: modules.length }); + progress.completedPackages++; + onProgress({ ...progress, packages: new Map(progress.packages) }); + + return { + name: pkgName, + version: entry.version, + modules, + loadedAt: Date.now(), + }; + } catch (e) { + const status: PackageStatus = { + state: "error", + message: e instanceof Error ? e.message : "Unknown error", + }; + progress.packages.set(pkgName, status); + onProgress({ ...progress, packages: new Map(progress.packages) }); + return null; + } + }) + ); + + loaded.push(...results.filter((r): r is LoadedPackage => r !== null)); + } + + return loaded; + }, + + async clearPackages(): Promise { + docsLib.clear_packages(); + }, + + async registerModule(moduleName: string, source: string): Promise { + docsLib.register_module(moduleName, source); + }, }; Comlink.expose(lib); From 3656fa060babd19ba8df8ec7a3b62e094a22dc6a Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Mon, 19 Jan 2026 03:36:49 +0800 Subject: [PATCH 2/6] Refactor docs site with workspace hooks and examples - Add reusable Workspace component with integrated editor state hooks - Add useEditorState hook for managing editor state - Reorganize package loading and Get Started panel Co-Authored-By: Claude Opus 4.5 --- docs/CLAUDE.md | 80 ++++ docs/package.json | 7 +- docs/pnpm-lock.yaml | 509 +++++++++++++++++++++++ docs/src/App.tsx | 171 +++----- docs/src/components/GetStartedPanel.tsx | 76 +++- docs/src/components/PackagePanel.tsx | 34 +- docs/src/components/Tabs.tsx | 49 ++- docs/src/components/TypeCheckerPanel.tsx | 14 +- docs/src/components/Workspace.tsx | 94 +++++ docs/src/hooks/useEditorState.ts | 279 +++++++++++++ docs/src/icons.d.ts | 5 + docs/src/lib/examples.ts | 132 +++++- docs/src/main.tsx | 12 +- docs/vite.config.ts | 3 +- 14 files changed, 1301 insertions(+), 164 deletions(-) create mode 100644 docs/CLAUDE.md create mode 100644 docs/src/components/Workspace.tsx create mode 100644 docs/src/hooks/useEditorState.ts create mode 100644 docs/src/icons.d.ts diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 00000000..845ae3c7 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +This is the interactive documentation site for purescript-analyzer. It provides a browser-based playground for exploring PureScript type checking, CST parsing, and package loading via a WASM-compiled version of the compiler core. + +## Commands + +```bash +# Install dependencies +pnpm install + +# Development (builds WASM + starts Vite dev server with HMR) +pnpm dev + +# Production build (optimized WASM + minified JS) +pnpm build + +# Type checking +pnpm typecheck + +# Format code +pnpm format + +# Preview production build +pnpm preview +``` + +## Architecture + +``` +User Input (MonacoEditor) + ↓ +App.tsx (state management) + ↓ +useDocsLib hook (Comlink worker proxy) + ↓ +worker/docs-lib.ts (Web Worker, isolated thread) + ↓ +WASM engine (src/wasm/src/lib.rs) + ↓ +Compiler Core crates (../compiler-core/*) + ↓ +Results → Panel components +``` + +**Key architectural decisions:** + +- **Thread isolation**: All compiler operations run in a Web Worker via Comlink, preventing UI blocking +- **WASM integration**: The `src/wasm/` crate compiles to WebAssembly and exposes `parse()`, `check()`, `register_module()`, and `clear_packages()` functions +- **Package system**: Fetches from `packages.registry.purescript.org`, decompresses tar.gz with pako, caches in localStorage, resolves transitive dependencies topologically + +## Key Directories + +- `src/components/` - React UI components including Monaco editor integration +- `src/components/Editor/purescript.ts` - PureScript language registration for Monaco +- `src/hooks/` - Custom hooks (`useDocsLib` for WASM worker, `useDebounce`) +- `src/lib/packages/` - Package fetching, caching, and dependency resolution +- `src/worker/` - Comlink-exposed Web Worker that loads WASM +- `src/wasm/` - Rust crate that compiles to WASM, links all compiler-core crates + +## Stack + +- React 18 + TypeScript + Vite +- Tailwind CSS 4 with Catppuccin theme (Macchiato dark, Latte light) +- Monaco Editor for code editing +- Comlink for type-safe worker communication +- wasm-pack for WASM compilation + +## WASM Crate + +The `src/wasm/` directory contains a Rust crate that: +- Links to 13 compiler-core crates via relative paths +- Exposes functions via `wasm-bindgen` +- Uses `serde-wasm-bindgen` for JS interop +- Tracks performance timing via `web-sys::Performance` + +Rebuild WASM manually: `cd src/wasm && wasm-pack build --target web` diff --git a/docs/package.json b/docs/package.json index dc705662..dd30f665 100644 --- a/docs/package.json +++ b/docs/package.json @@ -19,9 +19,13 @@ "monaco-editor": "^0.52.0", "pako": "^2.1.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "wouter": "^3.9.0" }, "devDependencies": { + "@iconify-json/ri": "^1.2.7", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", "@tailwindcss/vite": "^4.1.0", "@types/pako": "^2.0.3", "@types/react": "^18.3.12", @@ -31,6 +35,7 @@ "prettier": "^3.7.4", "tailwindcss": "^4.1.0", "typescript": "^5.9.2", + "unplugin-icons": "^22.5.0", "vite": "^5.4.19" }, "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 7d05ca3c..0659952e 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -29,7 +29,19 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + wouter: + specifier: ^3.9.0 + version: 3.9.0(react@18.3.1) devDependencies: + '@iconify-json/ri': + specifier: ^1.2.7 + version: 1.2.7 + '@svgr/core': + specifier: ^8.1.0 + version: 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': + specifier: ^8.1.0 + version: 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) '@tailwindcss/vite': specifier: ^4.1.0 version: 4.1.18(vite@5.4.21(lightningcss@1.30.2)) @@ -57,6 +69,9 @@ importers: typescript: specifier: ^5.9.2 version: 5.9.3 + unplugin-icons: + specifier: ^22.5.0 + version: 22.5.0(@svgr/core@8.1.0(typescript@5.9.3)) vite: specifier: ^5.4.19 version: 5.4.21(lightningcss@1.30.2) @@ -65,6 +80,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -292,6 +310,15 @@ packages: '@fontsource/manrope@5.2.8': resolution: {integrity: sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA==} + '@iconify-json/ri@1.2.7': + resolution: {integrity: sha512-j/Fkb8GlWY5y/zLj1BGxWRtDzuJFrI7562zLw+iQVEykieBgew43+r8qAvtSajvb75MfUIHjsNOYQPRD8FfLfw==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -421,6 +448,74 @@ packages: cpu: [x64] os: [win32] + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -546,6 +641,14 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true @@ -555,15 +658,38 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} comlink@4.4.2: resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -580,6 +706,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -587,6 +716,13 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -596,6 +732,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -608,6 +747,13 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -615,11 +761,18 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -695,16 +848,32 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + monaco-editor@0.52.2: resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} @@ -716,15 +885,46 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -734,6 +934,9 @@ packages: engines: {node: '>=14'} hasBin: true + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -747,6 +950,14 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + rollup@4.54.0: resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -759,10 +970,16 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -770,17 +987,59 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.2: + resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} + + unplugin-icons@22.5.0: + resolution: {integrity: sha512-MBlMtT5RuMYZy4TZgqUL2OTtOdTUVsS1Mhj6G1pEzMlFJlEnq6mhUfoIt45gBWxHcsOdXJDWLg3pRZ+YmvAVWQ==} + peerDependencies: + '@svgr/core': '>=7.0.0' + '@svgx/core': ^1.0.1 + '@vue/compiler-sfc': ^3.0.2 || ^2.7.0 + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + vue-template-compiler: ^2.6.12 + vue-template-es2015-compiler: ^1.9.0 + peerDependenciesMeta: + '@svgr/core': + optional: true + '@svgx/core': + optional: true + '@vue/compiler-sfc': + optional: true + svelte: + optional: true + vue-template-compiler: + optional: true + vue-template-es2015-compiler: + optional: true + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -812,11 +1071,24 @@ packages: terser: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + wouter@3.9.0: + resolution: {integrity: sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==} + peerDependencies: + react: '>=16.8.0' + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1002,6 +1274,18 @@ snapshots: '@fontsource/manrope@5.2.8': {} + '@iconify-json/ri@1.2.7': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1089,6 +1373,76 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-preset@8.1.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.28.5) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.28.5) + + '@svgr/core@8.1.0(typescript@5.9.3)': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.9.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.28.5 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1205,6 +1559,10 @@ snapshots: transitivePeerDependencies: - supports-color + acorn@8.15.0: {} + + argparse@2.0.1: {} + baseline-browser-mapping@2.9.11: {} browserslist@4.28.1: @@ -1215,12 +1573,29 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + callsites@3.1.0: {} + + camelcase@6.3.0: {} + caniuse-lite@1.0.30001762: {} comlink@4.4.2: {} + confbox@0.1.8: {} + + confbox@0.2.2: {} + convert-source-map@2.0.0: {} + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + csstype@3.2.3: {} debug@4.4.3: @@ -1229,6 +1604,11 @@ snapshots: detect-libc@2.1.2: {} + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + electron-to-chromium@1.5.267: {} enhanced-resolve@5.18.4: @@ -1236,6 +1616,12 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1264,6 +1650,8 @@ snapshots: escalade@3.2.0: {} + exsolve@1.0.8: {} + fsevents@2.3.3: optional: true @@ -1271,12 +1659,25 @@ snapshots: graceful-fs@4.2.11: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + is-arrayish@0.2.1: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json5@2.2.3: {} lightningcss-android-arm64@1.30.2: @@ -1328,10 +1729,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lines-and-columns@1.2.4: {} + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -1340,18 +1753,63 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mitt@3.0.1: {} + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.2 + monaco-editor@0.52.2: {} ms@2.1.3: {} nanoid@3.3.11: {} + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + node-releases@2.0.27: {} + package-manager-detector@1.6.0: {} + pako@2.1.0: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@4.0.3: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -1360,6 +1818,8 @@ snapshots: prettier@3.7.4: {} + quansync@0.2.11: {} + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -1372,6 +1832,10 @@ snapshots: dependencies: loose-envify: 1.4.0 + regexparam@3.0.0: {} + + resolve-from@4.0.0: {} + rollup@4.54.0: dependencies: '@types/estree': 1.0.8 @@ -1406,20 +1870,56 @@ snapshots: semver@6.3.1: {} + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + source-map-js@1.2.1: {} + svg-parser@2.0.4: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} + tinyexec@1.0.2: {} + + tslib@2.8.1: {} + typescript@5.9.3: {} + ufo@1.6.2: {} + + unplugin-icons@22.5.0(@svgr/core@8.1.0(typescript@5.9.3)): + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/utils': 3.1.0 + debug: 4.4.3 + local-pkg: 1.1.2 + unplugin: 2.3.11 + optionalDependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.15.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + vite@5.4.21(lightningcss@1.30.2): dependencies: esbuild: 0.21.5 @@ -1429,4 +1929,13 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.2 + webpack-virtual-modules@0.6.2: {} + + wouter@3.9.0(react@18.3.1): + dependencies: + mitt: 3.0.1 + react: 18.3.1 + regexparam: 3.0.0 + use-sync-external-store: 1.6.0(react@18.3.1) + yallist@3.1.1: {} diff --git a/docs/src/App.tsx b/docs/src/App.tsx index 66d2c31c..cf2bc678 100644 --- a/docs/src/App.tsx +++ b/docs/src/App.tsx @@ -1,16 +1,11 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useRoute } from "wouter"; import * as Comlink from "comlink"; import { useDocsLib } from "./hooks/useDocsLib"; -import { useDebounce } from "./hooks/useDebounce"; -import { MonacoEditor } from "./components/Editor/MonacoEditor"; -import { Tabs } from "./components/Tabs"; -import { CstPanel } from "./components/CstPanel"; -import { TypeCheckerPanel } from "./components/TypeCheckerPanel"; import { PerformanceBar } from "./components/PerformanceBar"; import { ThemeSwitcher } from "./components/ThemeSwitcher"; -import { PackagePanel } from "./components/PackagePanel"; -import { GetStartedPanel } from "./components/GetStartedPanel"; -import type { ParseResult, CheckResult, Mode, Timing } from "./lib/types"; +import { Workspace } from "./components/Workspace"; +import type { Mode, Timing } from "./lib/types"; import type { PackageSet, PackageLoadProgress } from "./lib/packages/types"; import { loadCachedPackageSet, @@ -20,76 +15,41 @@ import { clearCachedPackages, } from "./lib/packages/cache"; -const DEFAULT_SOURCE = `module Main where - -import Prim.Row as Row - -data Proxy :: forall k. k -> Type -data Proxy a = Proxy - -deriveUnion :: forall u. Row.Union (a :: Int) (b :: String) u => Proxy u -deriveUnion = Proxy - -deriveUnionLeft :: forall l. Row.Union l (b :: String) (a :: Int, b :: String) => Proxy l -deriveUnionLeft = Proxy - -solveUnion = { deriveUnion, deriveUnionLeft } -`; - export default function App() { const docsLib = useDocsLib(); - const [source, setSource] = useState(DEFAULT_SOURCE); - const [mode, setMode] = useState("getstarted"); - const [parseResult, setParseResult] = useState(null); - const [checkResult, setCheckResult] = useState(null); + + // Derive mode and exampleId from routes + const [isHome] = useRoute("/"); + const [isCst] = useRoute("/cst"); + const [isCstExample, cstParams] = useRoute("/cst/:exampleId"); + const [isTypes] = useRoute("/types"); + const [isTypesExample, typesParams] = useRoute("/types/:exampleId"); + const [isPackages] = useRoute("/packages"); + + const mode: Mode = isHome + ? "getstarted" + : isCst || isCstExample + ? "cst" + : isTypes || isTypesExample + ? "typechecker" + : isPackages + ? "packages" + : "getstarted"; + + const exampleId = cstParams?.exampleId || typesParams?.exampleId; + + // Timing state (displayed in header, updated by Workspace) const [timing, setTiming] = useState(null); - // Package state + // Package state (global, persists across example changes) const [packageSet, setPackageSet] = useState(null); const [loadProgress, setLoadProgress] = useState(null); const [loadedPackages, setLoadedPackages] = useState([]); const [isLoadingPackages, setIsLoadingPackages] = useState(false); + const [packageError, setPackageError] = useState(null); - const debouncedSource = useDebounce(source, 150); - - const runAnalysis = useCallback(async () => { - if (docsLib.status !== "ready") return; - - try { - // Always parse for CST mode - const parsed = await docsLib.lib.parse(debouncedSource); - setParseResult(parsed); - setTiming({ - lex: parsed.lex, - layout: parsed.layout, - parse: parsed.parse, - total: parsed.lex + parsed.layout + parsed.parse, - }); - - // Run type checker if in that mode - if (mode === "typechecker") { - const checked = await docsLib.lib.check(debouncedSource); - setCheckResult(checked); - setTiming({ - lex: checked.timing.lex, - layout: checked.timing.layout, - parse: checked.timing.parse, - stabilize: checked.timing.stabilize, - index: checked.timing.index, - resolve: checked.timing.resolve, - lower: checked.timing.lower, - check: checked.timing.check, - total: checked.timing.total, - }); - } - } catch (err) { - console.error("Analysis error:", err); - } - }, [docsLib, debouncedSource, mode]); - - useEffect(() => { - runAnalysis(); - }, [runAnalysis]); + const loadingRef = useRef(false); + const hasRestoredRef = useRef(false); // Load package set on mount useEffect(() => { @@ -97,7 +57,6 @@ export default function App() { const lib = docsLib.lib; const loadPackageSet = async () => { - // Try cache first const cached = loadCachedPackageSet(); if (cached) { setPackageSet(cached); @@ -118,7 +77,8 @@ export default function App() { // Restore cached packages on mount useEffect(() => { - if (docsLib.status !== "ready" || !packageSet) return; + if (docsLib.status !== "ready" || !packageSet || hasRestoredRef.current) return; + hasRestoredRef.current = true; const lib = docsLib.lib; const restoreCachedPackages = async () => { @@ -127,16 +87,16 @@ export default function App() { setIsLoadingPackages(true); const packageNames = Array.from(cached.keys()); + const progressProxy = Comlink.proxy((progress: PackageLoadProgress) => + setLoadProgress(progress) + ); try { - const loaded = await lib.loadPackages( - packageSet, - packageNames, - Comlink.proxy((progress) => setLoadProgress(progress)) - ); + const loaded = await lib.loadPackages(packageSet, packageNames, progressProxy); setLoadedPackages(loaded.map((p) => p.name)); } catch (e) { console.error("Failed to restore cached packages:", e); + setPackageError("Failed to restore cached packages"); } finally { setIsLoadingPackages(false); } @@ -145,19 +105,23 @@ export default function App() { restoreCachedPackages(); }, [docsLib, packageSet]); - // Package handlers const handleAddPackage = useCallback( async (packageName: string) => { - if (!packageSet || docsLib.status !== "ready" || isLoadingPackages) return; + if (!packageSet || docsLib.status !== "ready" || loadingRef.current) return; + loadingRef.current = true; setIsLoadingPackages(true); + setPackageError(null); const packagesToLoad = [...loadedPackages, packageName]; + const progressProxy = Comlink.proxy((progress: PackageLoadProgress) => + setLoadProgress(progress) + ); try { const loaded = await docsLib.lib.loadPackages( packageSet, packagesToLoad, - Comlink.proxy((progress) => setLoadProgress(progress)) + progressProxy ); const loadedNames = loaded.map((p) => p.name); @@ -165,11 +129,13 @@ export default function App() { saveCachedPackages(new Map(loaded.map((p) => [p.name, p]))); } catch (e) { console.error("Failed to load package:", e); + setPackageError(e instanceof Error ? e.message : "Failed to load package"); } finally { + loadingRef.current = false; setIsLoadingPackages(false); } }, - [packageSet, loadedPackages, docsLib, isLoadingPackages] + [packageSet, loadedPackages, docsLib] ); const handleClearPackages = useCallback(async () => { @@ -181,11 +147,6 @@ export default function App() { clearCachedPackages(); }, [docsLib]); - const handleSelectExample = useCallback((source: string) => { - setSource(source); - setMode("typechecker"); - }, []); - if (docsLib.status === "loading") { return (
@@ -212,30 +173,20 @@ export default function App() {
-
-
- -
- -
- -
- {mode === "getstarted" && } - {mode === "cst" && } - {mode === "typechecker" && } - {mode === "packages" && ( - - )} -
-
-
+ setPackageError(null)} + />
); } diff --git a/docs/src/components/GetStartedPanel.tsx b/docs/src/components/GetStartedPanel.tsx index 54a0b4e8..7a9e96f4 100644 --- a/docs/src/components/GetStartedPanel.tsx +++ b/docs/src/components/GetStartedPanel.tsx @@ -1,15 +1,62 @@ -import { EXAMPLES, CATEGORIES } from "../lib/examples"; +import RiMergeLine from "~icons/ri/git-merge-line"; +import RiStackLine from "~icons/ri/stack-line"; +import RiListCheck2 from "~icons/ri/list-check-2"; +import RiText from "~icons/ri/text"; +import RiCalculatorLine from "~icons/ri/calculator-line"; +import RiDnaLine from "~icons/ri/dna-line"; +import RiBox3Line from "~icons/ri/box-3-line"; +import RiShapesLine from "~icons/ri/shapes-line"; +import RiArrowRightLine from "~icons/ri/arrow-right-line"; +import RiLinksLine from "~icons/ri/links-line"; +import { EXAMPLES, CATEGORIES, type Example } from "../lib/examples"; -interface Props { - onSelectExample: (source: string) => void; +const ICONS: Record> = { + merge: RiMergeLine, + layers: RiStackLine, + list: RiListCheck2, + "text-fields": RiText, + calculator: RiCalculatorLine, + dna: RiDnaLine, + box: RiBox3Line, + cube: RiShapesLine, + arrow: RiArrowRightLine, + link: RiLinksLine, +}; + +interface ExampleCardProps { + example: Example; + onSelect: (exampleId: string) => void; +} + +function ExampleCard({ example, onSelect }: ExampleCardProps) { + const Icon = ICONS[example.icon]; + + return ( + + ); +} + +interface GetStartedPanelProps { + onSelectExample: (exampleId: string) => void; } -export function GetStartedPanel({ onSelectExample }: Props) { +export function GetStartedPanel({ onSelectExample }: GetStartedPanelProps) { return ( -
-
-

Welcome to PureScript Analyzer

-

+

+
+

Welcome to PureScript Analyzer

+

Explore PureScript's type system interactively. Select an example below to load it into the editor, then switch to the Type Checker tab to see inferred types.

@@ -17,19 +64,12 @@ export function GetStartedPanel({ onSelectExample }: Props) { {CATEGORIES.map((category) => (
-

+

{category}

-
+
{EXAMPLES.filter((e) => e.category === category).map((example) => ( - + ))}
diff --git a/docs/src/components/PackagePanel.tsx b/docs/src/components/PackagePanel.tsx index 3003cb1b..eb113cc8 100644 --- a/docs/src/components/PackagePanel.tsx +++ b/docs/src/components/PackagePanel.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import type { PackageSet, PackageLoadProgress, PackageStatus } from "../lib/packages/types"; interface Props { @@ -8,6 +8,8 @@ interface Props { onAddPackage: (name: string) => void; onClearPackages: () => void; isLoading: boolean; + error: string | null; + onDismissError: () => void; } function StatusBadge({ status }: { status: PackageStatus }) { @@ -40,10 +42,27 @@ export function PackagePanel({ onAddPackage, onClearPackages, isLoading, + error, + onDismissError, }: Props) { const [input, setInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); + const containerRef = useRef(null); + + // Click-outside handler for suggestions dropdown + useEffect(() => { + if (!showSuggestions) return; + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowSuggestions(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showSuggestions]); const handleInputChange = useCallback( (value: string) => { @@ -90,15 +109,24 @@ export function PackagePanel({

Packages

+ {/* Error display */} + {error && ( +
+ {error} + +
+ )} + {/* Search/Add Package */} -
+
handleInputChange(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} - onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} placeholder="Add package (e.g., prelude)" className="w-full rounded bg-bg-lighter px-3 py-2 text-sm text-fg placeholder-fg-subtle outline-none focus:ring-1 focus:ring-teal-400/50" disabled={!packageSet || isLoading} diff --git a/docs/src/components/Tabs.tsx b/docs/src/components/Tabs.tsx index c3a77e21..6ad37971 100644 --- a/docs/src/components/Tabs.tsx +++ b/docs/src/components/Tabs.tsx @@ -1,33 +1,42 @@ +import { Link } from "wouter"; import type { Mode } from "../lib/types"; interface Props { activeTab: Mode; - onTabChange: (tab: Mode) => void; + exampleId?: string; } -const tabs: { id: Mode; label: string }[] = [ - { id: "getstarted", label: "Get Started" }, - { id: "typechecker", label: "Type Checker" }, - { id: "cst", label: "CST Preview" }, - { id: "packages", label: "Packages" }, +const tabs: { id: Mode; label: string; path: string }[] = [ + { id: "getstarted", label: "Get Started", path: "/" }, + { id: "typechecker", label: "Type Checker", path: "/types" }, + { id: "cst", label: "CST Preview", path: "/cst" }, + { id: "packages", label: "Packages", path: "/packages" }, ]; -export function Tabs({ activeTab, onTabChange }: Props) { +export function Tabs({ activeTab, exampleId }: Props) { return (
- {tabs.map((tab) => ( - - ))} + {tabs.map((tab) => { + // Preserve exampleId for CST and Type Checker tabs + const path = + exampleId && (tab.id === "cst" || tab.id === "typechecker") + ? `${tab.path}/${exampleId}` + : tab.path; + + return ( + + {tab.label} + + ); + })}
); } diff --git a/docs/src/components/TypeCheckerPanel.tsx b/docs/src/components/TypeCheckerPanel.tsx index f1f3a3a6..fbe8dc5c 100644 --- a/docs/src/components/TypeCheckerPanel.tsx +++ b/docs/src/components/TypeCheckerPanel.tsx @@ -1,19 +1,27 @@ +import RiLoader4Line from "~icons/ri/loader-4-line"; import type { CheckResult } from "../lib/types"; import { HighlightedCode } from "./HighlightedCode"; interface Props { data: CheckResult | null; + loading?: boolean; } -export function TypeCheckerPanel({ data }: Props) { - if (!data) { +export function TypeCheckerPanel({ data, loading }: Props) { + // Only show spinner when explicitly loading (after delay threshold) + // No data + not loading = waiting quietly, show nothing + if (loading) { return (
- Enter PureScript code to see type information +
); } + if (!data) { + return null; + } + return (
{data.terms.length > 0 && ( diff --git a/docs/src/components/Workspace.tsx b/docs/src/components/Workspace.tsx new file mode 100644 index 00000000..7eb0f060 --- /dev/null +++ b/docs/src/components/Workspace.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import type { Remote } from "comlink"; +import { useLocation } from "wouter"; +import { useEditorState } from "../hooks/useEditorState"; +import { MonacoEditor } from "./Editor/MonacoEditor"; +import { Tabs } from "./Tabs"; +import { CstPanel } from "./CstPanel"; +import { TypeCheckerPanel } from "./TypeCheckerPanel"; +import { GetStartedPanel } from "./GetStartedPanel"; +import { PackagePanel } from "./PackagePanel"; +import type { Lib, Mode, Timing } from "../lib/types"; +import type { PackageSet, PackageLoadProgress } from "../lib/packages/types"; + +interface Props { + mode: Mode; + exampleId: string | undefined; + docsLib: Remote; + onTimingChange: (timing: Timing | null) => void; + // Package props + packageSet: PackageSet | null; + loadProgress: PackageLoadProgress | null; + loadedPackages: string[]; + onAddPackage: (name: string) => void; + onClearPackages: () => void; + isLoadingPackages: boolean; + packageError: string | null; + onDismissPackageError: () => void; +} + +export function Workspace({ + mode, + exampleId, + docsLib, + onTimingChange, + packageSet, + loadProgress, + loadedPackages, + onAddPackage, + onClearPackages, + isLoadingPackages, + packageError, + onDismissPackageError, +}: Props) { + const [, navigate] = useLocation(); + + const { source, cst, typeChecker, timing, selectExample, pendingNavigation } = useEditorState({ + exampleId, + docsLib, + mode, + }); + + // Sync timing to parent for header display + useEffect(() => { + onTimingChange(timing); + }, [timing, onTimingChange]); + + // Handle pending navigation from prefetch + useEffect(() => { + if (pendingNavigation) { + navigate(`/types/${pendingNavigation.exampleId}`); + } + }, [pendingNavigation, navigate]); + + return ( +
+
+ +
+ +
+ +
+ {mode === "getstarted" && } + {mode === "cst" && } + {mode === "typechecker" && ( + + )} + {mode === "packages" && ( + + )} +
+
+
+ ); +} diff --git a/docs/src/hooks/useEditorState.ts b/docs/src/hooks/useEditorState.ts new file mode 100644 index 00000000..c2ac0e5b --- /dev/null +++ b/docs/src/hooks/useEditorState.ts @@ -0,0 +1,279 @@ +import { useReducer, useEffect, useCallback, useRef } from "react"; +import type { Remote } from "comlink"; +import { useDebounce } from "./useDebounce"; +import { EXAMPLES } from "../lib/examples"; +import type { Lib, ParseResult, CheckResult, Timing } from "../lib/types"; + +const DEFAULT_SOURCE = `module Main where + +import Prim.Row as Row + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +deriveUnion :: forall u. Row.Union (a :: Int) (b :: String) u => Proxy u +deriveUnion = Proxy + +deriveUnionLeft :: forall l. Row.Union l (b :: String) (a :: Int, b :: String) => Proxy l +deriveUnionLeft = Proxy + +solveUnion = { deriveUnion, deriveUnionLeft } +`; + +// How long to wait before navigating without results (shows spinner) +const PREFETCH_TIMEOUT_MS = 100; +// Debounce delay for user typing +const TYPING_DEBOUNCE_MS = 150; + +interface Results { + cst: ParseResult; + typeChecker: CheckResult | null; + timing: Timing; +} + +// State machine for proactive navigation +type State = + | { status: "idle"; source: string; results: Results } + | { status: "prefetching"; source: string; results: Results | null; targetExampleId: string } + | { status: "loading"; source: string; results: Results | null } + | { status: "stale"; source: string; results: Results }; + +type Action = + | { type: "SELECT_EXAMPLE"; exampleId: string; source: string } + | { type: "PREFETCH_TIMEOUT" } + | { type: "SOURCE_EDITED"; source: string } + | { type: "ANALYSIS_COMPLETE"; source: string; results: Results } + | { type: "URL_LOADED"; source: string }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "SELECT_EXAMPLE": + // Start prefetching - keep existing results while we fetch new ones + return { + status: "prefetching", + source: action.source, + results: state.status === "idle" || state.status === "stale" ? state.results : null, + targetExampleId: action.exampleId, + }; + + case "PREFETCH_TIMEOUT": + // Only handle if we're still prefetching + if (state.status !== "prefetching") return state; + // Timeout hit - transition to loading (will show spinner) + return { status: "loading", source: state.source, results: state.results }; + + case "SOURCE_EDITED": + if (action.source === state.source) return state; + if (state.status === "idle" || state.status === "stale") { + return { status: "stale", source: action.source, results: state.results }; + } + return { ...state, source: action.source }; + + case "ANALYSIS_COMPLETE": + if (action.source !== state.source) return state; + // Transitions from any state (prefetching, loading, stale) to idle + return { status: "idle", source: state.source, results: action.results }; + + case "URL_LOADED": + // Direct URL access (back/forward, shared links) - can't prefetch + if (action.source === state.source && state.status === "idle") return state; + return { status: "loading", source: action.source, results: null }; + } +} + +// Hook options and return types +interface UseEditorStateOptions { + exampleId: string | undefined; + docsLib: Remote; + mode: "cst" | "typechecker" | "getstarted" | "packages"; +} + +interface SourceState { + value: string; + set: (value: string) => void; +} + +interface AnalysisState { + isLoading: boolean; + result: T | null; +} + +interface EditorState { + source: SourceState; + cst: AnalysisState; + typeChecker: AnalysisState; + timing: Timing | null; + selectExample: (exampleId: string) => void; + pendingNavigation: { exampleId: string } | null; +} + +export function useEditorState({ + exampleId, + docsLib, + mode, +}: UseEditorStateOptions): EditorState { + const getSourceForExample = useCallback( + (id: string | undefined) => + id ? (EXAMPLES.find((e) => e.id === id)?.source ?? DEFAULT_SOURCE) : DEFAULT_SOURCE, + [] + ); + + // Initialise with loading state + const [state, dispatch] = useReducer(reducer, null, () => ({ + status: "loading" as const, + source: getSourceForExample(exampleId), + results: null, + })); + + const loadedExampleRef = useRef(exampleId); + const prefetchTimeoutRef = useRef(null); + const pendingAnalysisRef = useRef<{ source: string; cancelled: boolean } | null>(null); + + // Handle URL changes (direct navigation: back/forward, shared links, typed URL) + // vs in-app navigation (selectExample sets source first, then navigate happens) + useEffect(() => { + if (exampleId !== loadedExampleRef.current) { + const expectedSource = getSourceForExample(exampleId); + // If source doesn't match what the URL expects, this is direct navigation + if (expectedSource !== state.source) { + dispatch({ type: "URL_LOADED", source: expectedSource }); + } + // Update ref regardless - URL now matches + loadedExampleRef.current = exampleId; + } + }, [exampleId, getSourceForExample, state.source]); + + // Debounced source for typing - only used when in stale state + const debouncedSource = useDebounce(state.source, TYPING_DEBOUNCE_MS); + + // Determine which source to use for analysis: + // - Immediate (no debounce) for prefetching/loading/initial + // - Debounced for stale (user is typing) + const analysisSource = state.status === "stale" ? debouncedSource : state.source; + + // Run analysis when source changes + useEffect(() => { + const sourceSnapshot = analysisSource; + + // Cancel any pending analysis + if (pendingAnalysisRef.current) { + pendingAnalysisRef.current.cancelled = true; + } + + const analysisContext = { source: sourceSnapshot, cancelled: false }; + pendingAnalysisRef.current = analysisContext; + + const runAnalysis = async () => { + try { + const cst = await docsLib.parse(sourceSnapshot); + if (analysisContext.cancelled) return; + + let timing: Timing = { + lex: cst.lex, + layout: cst.layout, + parse: cst.parse, + total: cst.lex + cst.layout + cst.parse, + }; + + let typeChecker: CheckResult | null = null; + if (mode === "typechecker") { + typeChecker = await docsLib.check(sourceSnapshot); + if (analysisContext.cancelled) return; + + timing = { + lex: typeChecker.timing.lex, + layout: typeChecker.timing.layout, + parse: typeChecker.timing.parse, + stabilize: typeChecker.timing.stabilize, + index: typeChecker.timing.index, + resolve: typeChecker.timing.resolve, + lower: typeChecker.timing.lower, + check: typeChecker.timing.check, + total: typeChecker.timing.total, + }; + } + + if (analysisContext.cancelled) return; + + dispatch({ + type: "ANALYSIS_COMPLETE", + source: sourceSnapshot, + results: { cst, typeChecker, timing }, + }); + } catch (err) { + console.error("Analysis error:", err); + } + }; + + runAnalysis(); + + return () => { + analysisContext.cancelled = true; + }; + }, [docsLib, analysisSource, mode]); + + // Handle prefetch timeout + useEffect(() => { + if (state.status === "prefetching") { + prefetchTimeoutRef.current = window.setTimeout(() => { + dispatch({ type: "PREFETCH_TIMEOUT" }); + }, PREFETCH_TIMEOUT_MS); + + return () => { + if (prefetchTimeoutRef.current) { + clearTimeout(prefetchTimeoutRef.current); + prefetchTimeoutRef.current = null; + } + }; + } + }, [state.status]); + + const setSource = useCallback((newSource: string) => { + dispatch({ type: "SOURCE_EDITED", source: newSource }); + }, []); + + const selectExample = useCallback( + (targetExampleId: string) => { + const source = getSourceForExample(targetExampleId); + dispatch({ type: "SELECT_EXAMPLE", exampleId: targetExampleId, source }); + }, + [getSourceForExample] + ); + + // Determine pending navigation + // Navigate when we're no longer prefetching and URL doesn't match the current source + const pendingNavigation = (() => { + // Still prefetching - don't navigate yet + if (state.status === "prefetching") return null; + + // Check if current source corresponds to an example that differs from URL + const currentExample = EXAMPLES.find((e) => e.source === state.source); + if (currentExample && currentExample.id !== loadedExampleRef.current) { + return { exampleId: currentExample.id }; + } + + return null; + })(); + + // Determine loading state - only show spinner for loading state (not prefetching) + const isLoading = state.status === "loading"; + const results = state.status === "idle" || state.status === "stale" ? state.results : null; + + return { + source: { + value: state.source, + set: setSource, + }, + cst: { + isLoading, + result: results?.cst ?? null, + }, + typeChecker: { + isLoading, + result: results?.typeChecker ?? null, + }, + timing: results?.timing ?? null, + selectExample, + pendingNavigation, + }; +} diff --git a/docs/src/icons.d.ts b/docs/src/icons.d.ts new file mode 100644 index 00000000..c41df45f --- /dev/null +++ b/docs/src/icons.d.ts @@ -0,0 +1,5 @@ +declare module "~icons/*" { + import type { ComponentType, SVGProps } from "react"; + const component: ComponentType>; + export default component; +} diff --git a/docs/src/lib/examples.ts b/docs/src/lib/examples.ts index 8f33a18b..fe7cbb7e 100644 --- a/docs/src/lib/examples.ts +++ b/docs/src/lib/examples.ts @@ -3,6 +3,7 @@ export interface Example { title: string; description: string; category: string; + icon: string; source: string; } @@ -11,8 +12,9 @@ export const EXAMPLES: Example[] = [ { id: "row-union", title: "Row Union Solving", - description: "Demonstrates bidirectional Row.Union constraint solving for extensible records.", + description: "Bidirectional Row.Union constraint solving for extensible records.", category: "Type-Level Programming", + icon: "merge", source: `module Main where import Prim.Row as Row @@ -35,8 +37,9 @@ solveUnion = { deriveUnion, deriveUnionLeft, deriveUnionRight } { id: "row-cons", title: "Row.Cons Operations", - description: "Shows how Row.Cons constructs and deconstructs row types at the type level.", + description: "Construct and deconstruct row types at the type level.", category: "Type-Level Programming", + icon: "layers", source: `module Main where import Prim.Row as Row @@ -62,8 +65,9 @@ solveCons = { deriveCons, deriveTail, deriveType } { id: "rowlist", title: "RowToList Conversion", - description: "Converts row types to type-level lists for type-level iteration.", + description: "Convert row types to type-level lists for iteration.", category: "Type-Level Programming", + icon: "list", source: `module Main where import Prim.RowList as RL @@ -88,6 +92,7 @@ solveRowToList = { rowToListSimple, rowToListMultiple, rowToListEmpty } title: "Symbol Operations", description: "Type-level string operations: append, compare, and cons.", category: "Type-Level Programming", + icon: "text-fields", source: `module Main where import Prim.Symbol (class Append, class Compare, class Cons) @@ -123,6 +128,7 @@ forceSolve = { deriveAppended, deriveLeft, compareLT, compareEQ, deriveCons } title: "Type-Level Integers", description: "Compile-time integer arithmetic: add, multiply, compare.", category: "Type-Level Programming", + icon: "calculator", source: `module Main where import Prim.Int (class Add, class Mul, class Compare, class ToString) @@ -155,6 +161,47 @@ deriveString :: forall s. ToString 42 s => Proxy s deriveString = Proxy forceSolve = { deriveSum, deriveRight, deriveMul, compareLT, compareGT, deriveString } +`, + }, + { + id: "int-compare-proofs", + title: "Comparison Proofs", + description: "Type-level proofs of integer comparison transitivity and symmetry.", + category: "Type-Level Programming", + icon: "link", + source: `module Main where + +import Prim.Int (class Compare) +import Prim.Ordering (LT, EQ, GT) + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +-- Assertion helpers using row types to capture comparison results +assertLesser :: forall l r. Compare l r LT => Proxy ( left :: l, right :: r ) +assertLesser = Proxy + +assertGreater :: forall l r. Compare l r GT => Proxy ( left :: l, right :: r ) +assertGreater = Proxy + +assertEqual :: forall l r. Compare l r EQ => Proxy ( left :: l, right :: r ) +assertEqual = Proxy + +-- Symmetry: if m > n then n < m +symmLt :: forall m n. Compare m n GT => Proxy ( left :: n, right :: m ) +symmLt = assertLesser + +-- Reflexivity: n == n for any integer +reflEq :: forall (n :: Int). Proxy ( left :: n, right :: n ) +reflEq = assertEqual + +-- Transitivity: if m < n and n < p, then m < p +transLt :: forall m n p. Compare m n LT => Compare n p LT => Proxy n -> Proxy ( left :: m, right :: p ) +transLt _ = assertLesser + +-- Concrete proof: 1 < 5 < 10 implies 1 < 10 +proof1Lt10 :: Proxy ( left :: 1, right :: 10 ) +proof1Lt10 = transLt (Proxy :: Proxy 5) `, }, @@ -162,8 +209,9 @@ forceSolve = { deriveSum, deriveRight, deriveMul, compareLT, compareGT, deriveSt { id: "derive-generic", title: "Generic Deriving", - description: "Derive Generic instances to get type-level representations of data types.", + description: "Derive Generic instances to get type-level representations.", category: "Generic Deriving", + icon: "dna", source: `module Main where import Data.Generic.Rep (class Generic) @@ -204,6 +252,7 @@ forceSolve = { getVoid, getMyUnit, getEither } title: "Newtype Deriving", description: "Derive Newtype instances for wrapper types to enable coercions.", category: "Generic Deriving", + icon: "box", source: `module Main where import Data.Newtype (class Newtype) @@ -217,6 +266,41 @@ newtype Wrapper a = Wrapper a derive instance Newtype UserId _ derive instance Newtype Email _ derive instance Newtype (Wrapper a) _ +`, + }, + { + id: "safe-coerce", + title: "Safe Coerce", + description: "Zero-cost conversions between representationally equal types.", + category: "Generic Deriving", + icon: "arrow", + source: `module Main where + +import Safe.Coerce (coerce) + +-- Newtypes have zero runtime overhead +newtype Age = Age Int +newtype Years = Years Age + +-- Wrap and unwrap with no runtime cost +wrapAge :: Int -> Age +wrapAge = coerce + +unwrapAge :: Age -> Int +unwrapAge = coerce + +-- Coerce through containers (representational role) +data Maybe a = Nothing | Just a + +coerceMaybe :: Maybe Age -> Maybe Int +coerceMaybe = coerce + +-- Transitive coercion: Int -> Age -> Years +coerceTransitive :: Int -> Years +coerceTransitive = coerce + +unwrapTransitive :: Years -> Int +unwrapTransitive = coerce `, }, @@ -226,6 +310,7 @@ derive instance Newtype (Wrapper a) _ title: "Functor Class", description: "Higher-kinded type class for mappable containers.", category: "Type Classes", + icon: "cube", source: `module Main where class Functor f where @@ -249,6 +334,7 @@ instance Functor List where title: "Functional Dependencies", description: "Use functional dependencies to guide type inference.", category: "Type Classes", + icon: "arrow", source: `module Main where -- Class with functional dependency: knowing 'a' determines 'b' @@ -269,8 +355,9 @@ testBool = convert true { id: "instance-chains", title: "Instance Chains", - description: "Use 'else' to create overlapping instances with fallback behavior.", + description: "Use 'else' to create overlapping instances with fallback.", category: "Type Classes", + icon: "link", source: `module Main where import Prim.Boolean (True, False) @@ -290,6 +377,41 @@ testDiff = Proxy -- Force instantiation to verify resolved types test = { testSame, testDiff } +`, + }, + { + id: "recursive-constraints", + title: "Recursive Constraints", + description: "Build row types recursively using type-level integers and symbols.", + category: "Type Classes", + icon: "calculator", + source: `module Main where + +import Prim.Int (class Add, class ToString) +import Prim.Row (class Cons) +import Prim.Symbol (class Append) + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +-- Recursively build a row type from an integer +-- Build 3 r => r ~ (n1 :: 1, n2 :: 2, n3 :: 3) +class Build n r | n -> r + +instance Build 0 () +else instance + ( Add minusOne 1 currentId + , ToString currentId labelId + , Append "n" labelId actualLabel + , Build minusOne minusOneResult + , Cons actualLabel currentId minusOneResult finalResult + ) => Build currentId finalResult + +build :: forall n r. Build n r => Proxy n -> Proxy r +build _ = Proxy + +-- Builds: (n1 :: 1, n2 :: 2, n3 :: 3, n4 :: 4, n5 :: 5) +test = build (Proxy :: Proxy 5) `, }, ]; diff --git a/docs/src/main.tsx b/docs/src/main.tsx index ad5e0035..0399d449 100644 --- a/docs/src/main.tsx +++ b/docs/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { Router } from "wouter"; import "@fontsource/manrope/400.css"; import "@fontsource/manrope/500.css"; import "@fontsource/manrope/600.css"; @@ -10,10 +11,15 @@ import "./index.css"; import App from "./App"; import { ThemeProvider } from "./contexts/ThemeContext"; +// Use Vite's base URL for the router +const base = import.meta.env.BASE_URL.replace(/\/$/, ""); + createRoot(document.getElementById("root")!).render( - - - + + + + + ); diff --git a/docs/vite.config.ts b/docs/vite.config.ts index 3c7206c9..5af5ce0c 100644 --- a/docs/vite.config.ts +++ b/docs/vite.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import Icons from "unplugin-icons/vite"; export default defineConfig({ base: "/purescript-analyzer/", - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), Icons({ compiler: "jsx", jsx: "react" })], server: { headers: { "Cross-Origin-Opener-Policy": "same-origin", From 54aef4a1156db42f5d38ff665f14a9101d8e5233 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Mon, 19 Jan 2026 04:54:17 +0800 Subject: [PATCH 3/6] Update icons and examples --- docs/src/components/GetStartedPanel.tsx | 8 + docs/src/lib/examples.ts | 434 ++++++++---------------- 2 files changed, 146 insertions(+), 296 deletions(-) diff --git a/docs/src/components/GetStartedPanel.tsx b/docs/src/components/GetStartedPanel.tsx index 7a9e96f4..f1caf476 100644 --- a/docs/src/components/GetStartedPanel.tsx +++ b/docs/src/components/GetStartedPanel.tsx @@ -8,6 +8,10 @@ import RiBox3Line from "~icons/ri/box-3-line"; import RiShapesLine from "~icons/ri/shapes-line"; import RiArrowRightLine from "~icons/ri/arrow-right-line"; import RiLinksLine from "~icons/ri/links-line"; +import RiMagicLine from "~icons/ri/magic-line"; +import RiSparklingLine from "~icons/ri/sparkling-line"; +import RiScales3Line from "~icons/ri/scales-3-line"; +import RiLoopLeftLine from "~icons/ri/loop-left-line"; import { EXAMPLES, CATEGORIES, type Example } from "../lib/examples"; const ICONS: Record> = { @@ -21,6 +25,10 @@ const ICONS: Record> = { cube: RiShapesLine, arrow: RiArrowRightLine, link: RiLinksLine, + wand: RiMagicLine, + sparkles: RiSparklingLine, + scale: RiScales3Line, + loop: RiLoopLeftLine, }; interface ExampleCardProps { diff --git a/docs/src/lib/examples.ts b/docs/src/lib/examples.ts index fe7cbb7e..d8ae0ac5 100644 --- a/docs/src/lib/examples.ts +++ b/docs/src/lib/examples.ts @@ -8,167 +8,181 @@ export interface Example { } export const EXAMPLES: Example[] = [ - // Type-Level Programming + // Basics - proving compiler capabilities { - id: "row-union", - title: "Row Union Solving", - description: "Bidirectional Row.Union constraint solving for extensible records.", - category: "Type-Level Programming", - icon: "merge", + id: "constraint-generalisation", + title: "Constraint Generalisation", + description: "Infer type class constraints from usage in untyped bindings.", + category: "Basics", + icon: "sparkles", source: `module Main where -import Prim.Row as Row +class Functor f where + map :: forall a b. (a -> b) -> f a -> f b -data Proxy :: forall k. k -> Type -data Proxy a = Proxy +class Functor f <= Apply f where + apply :: forall a b. f (a -> b) -> f a -> f b -deriveUnion :: forall u. Row.Union (a :: Int) (b :: String) u => Proxy u -deriveUnion = Proxy +class Apply m <= Bind m where + bind :: forall a b. m a -> (a -> m b) -> m b + +class Semigroup a where + append :: a -> a -> a -deriveUnionLeft :: forall l. Row.Union l (b :: String) (a :: Int, b :: String) => Proxy l -deriveUnionLeft = Proxy +-- No type signature: the compiler infers Functor f constraint +-- Hover to see: forall f a b. Functor f => (a -> b) -> f a -> f b +inferredMap f xs = map f xs -deriveUnionRight :: forall r. Row.Union (a :: Int) r (a :: Int, b :: String) => Proxy r -deriveUnionRight = Proxy +-- Infers Apply constraint from usage of apply +-- Hover to see: forall f a b. Apply f => f (a -> b) -> f a -> f b +inferredApply ff fa = apply ff fa -solveUnion = { deriveUnion, deriveUnionLeft, deriveUnionRight } +-- Infers multiple constraints: Bind m, Semigroup a +-- Hover to see: forall m a. Bind m => Semigroup a => m a -> m a -> m a +inferredBindAppend ma mb = bind ma (\\a -> map (append a) mb) `, }, { - id: "row-cons", - title: "Row.Cons Operations", - description: "Construct and deconstruct row types at the type level.", - category: "Type-Level Programming", - icon: "layers", + id: "instance-deriving", + title: "Instance Deriving", + description: "Derive Generic and Newtype instances for data declarations.", + category: "Basics", + icon: "wand", source: `module Main where -import Prim.Row as Row - -data Proxy :: forall k. k -> Type -data Proxy a = Proxy +import Data.Generic.Rep (class Generic) +import Data.Newtype (class Newtype) --- Derive the full row from label, type, and tail -deriveCons :: forall row. Row.Cons "name" String () row => Proxy row -deriveCons = Proxy +-- Algebraic data types derive Generic +data Maybe a = Nothing | Just a +data Either a b = Left a | Right b +data Tree a = Leaf | Branch (Tree a) a (Tree a) --- Derive the tail from the full row -deriveTail :: forall tail. Row.Cons "name" String tail (name :: String, age :: Int) => Proxy tail -deriveTail = Proxy +derive instance Generic (Maybe a) _ +derive instance Generic (Either a b) _ +derive instance Generic (Tree a) _ --- Derive the field type from the full row -deriveType :: forall t. Row.Cons "name" t () (name :: String) => Proxy t -deriveType = Proxy +-- Newtypes derive both Generic and Newtype +newtype UserId = UserId Int +newtype Email = Email String +newtype Wrapper a = Wrapper a -solveCons = { deriveCons, deriveTail, deriveType } -`, - }, - { - id: "rowlist", - title: "RowToList Conversion", - description: "Convert row types to type-level lists for iteration.", - category: "Type-Level Programming", - icon: "list", - source: `module Main where +derive instance Generic UserId _ +derive instance Generic Email _ +derive instance Generic (Wrapper a) _ -import Prim.RowList as RL +derive instance Newtype UserId _ +derive instance Newtype Email _ +derive instance Newtype (Wrapper a) _ -data Proxy :: forall k. k -> Type +-- Force Generic solving to see Rep types data Proxy a = Proxy -rowToListSimple :: forall list. RL.RowToList (a :: Int) list => Proxy list -rowToListSimple = Proxy - -rowToListMultiple :: forall list. RL.RowToList (b :: String, a :: Int) list => Proxy list -rowToListMultiple = Proxy +getTreeRep :: forall a rep. Generic (Tree a) rep => Proxy rep +getTreeRep = Proxy -rowToListEmpty :: forall list. RL.RowToList () list => Proxy list -rowToListEmpty = Proxy +getMaybeRep :: forall a rep. Generic (Maybe a) rep => Proxy rep +getMaybeRep = Proxy -solveRowToList = { rowToListSimple, rowToListMultiple, rowToListEmpty } +forceSolve = { getTreeRep, getMaybeRep } `, }, { - id: "symbol-ops", - title: "Symbol Operations", - description: "Type-level string operations: append, compare, and cons.", - category: "Type-Level Programming", - icon: "text-fields", + id: "type-classes", + title: "Type Classes", + description: "Type classes, functional dependencies, and instance chains.", + category: "Basics", + icon: "layers", source: `module Main where -import Prim.Symbol (class Append, class Compare, class Cons) -import Prim.Ordering (Ordering, LT, EQ, GT) +import Prim.Boolean (True, False) -data Proxy :: forall k. k -> Type data Proxy a = Proxy --- Append: Derive appended from left and right -deriveAppended :: forall appended. Append "Hello" "World" appended => Proxy appended -deriveAppended = Proxy +-- Basic type class with functional dependency +-- Knowing 'a' determines 'b' +class Convert a b | a -> b where + convert :: a -> b --- Append: Derive left from right and appended -deriveLeft :: forall left. Append left "World" "HelloWorld" => Proxy left -deriveLeft = Proxy +instance Convert Int String where + convert _ = "int" --- Compare symbols -compareLT :: forall ord. Compare "a" "b" ord => Proxy ord -compareLT = Proxy +instance Convert Boolean String where + convert _ = "bool" + +-- Fundep guides inference: no type annotation needed +testConvert = convert 42 -compareEQ :: forall ord. Compare "hello" "hello" ord => Proxy ord -compareEQ = Proxy +-- Instance chains with 'else' for overlapping instances +class TypeEq a b (result :: Boolean) | a b -> result --- Cons: Derive symbol from head and tail -deriveCons :: forall symbol. Cons "H" "ello" symbol => Proxy symbol -deriveCons = Proxy +instance TypeEq a a True +else instance TypeEq a b False -forceSolve = { deriveAppended, deriveLeft, compareLT, compareEQ, deriveCons } +-- Multi-parameter class with two fundeps +class Combine a b c | a b -> c, c -> a b where + combine :: a -> b -> c + split :: c -> { fst :: a, snd :: b } + +instance Combine Int String { int :: Int, str :: String } where + combine i s = { int: i, str: s } + split r = { fst: r.int, snd: r.str } + +-- Force solving to verify inferred types +eqSame :: forall r. TypeEq Int Int r => Proxy r +eqSame = Proxy + +eqDiff :: forall r. TypeEq Int String r => Proxy r +eqDiff = Proxy + +test = { eqSame, eqDiff, testConvert } `, }, + + // Type-Level Programming { - id: "int-ops", - title: "Type-Level Integers", - description: "Compile-time integer arithmetic: add, multiply, compare.", + id: "row-union", + title: "Row Union", + description: "Bidirectional Row.Union constraint solving for extensible records.", category: "Type-Level Programming", - icon: "calculator", + icon: "merge", source: `module Main where -import Prim.Int (class Add, class Mul, class Compare, class ToString) -import Prim.Ordering (Ordering, LT, EQ, GT) +import Prim.Row as Row data Proxy :: forall k. k -> Type data Proxy a = Proxy --- Add: Derive sum from operands -deriveSum :: forall sum. Add 1 2 sum => Proxy sum -deriveSum = Proxy - --- Add: Derive right operand from left and sum -deriveRight :: forall right. Add 1 right 3 => Proxy right -deriveRight = Proxy +-- Derive the union from left and right +deriveUnion :: forall u. Row.Union (a :: Int) (b :: String) u => Proxy u +deriveUnion = Proxy --- Mul: Derive product from operands -deriveMul :: forall product. Mul 3 4 product => Proxy product -deriveMul = Proxy +-- Derive the left row from right and union +deriveLeft :: forall l. Row.Union l (b :: String) (a :: Int, b :: String) => Proxy l +deriveLeft = Proxy --- Compare integers -compareLT :: forall ord. Compare 1 2 ord => Proxy ord -compareLT = Proxy +-- Derive the right row from left and union +deriveRight :: forall r. Row.Union (a :: Int) r (a :: Int, b :: String) => Proxy r +deriveRight = Proxy -compareGT :: forall ord. Compare 10 3 ord => Proxy ord -compareGT = Proxy +-- Practical example: extensible record functions +merge :: forall left right union. + Row.Union left right union => + Row.Nub union union => + Record left -> Record right -> Record union +merge l r = unsafeMerge l r --- ToString: Convert integer to symbol -deriveString :: forall s. ToString 42 s => Proxy s -deriveString = Proxy +foreign import unsafeMerge :: forall a b c. a -> b -> c -forceSolve = { deriveSum, deriveRight, deriveMul, compareLT, compareGT, deriveString } +test = merge { a: 1 } { b: "hello" } `, }, { - id: "int-compare-proofs", + id: "int-compare", title: "Comparison Proofs", description: "Type-level proofs of integer comparison transitivity and symmetry.", category: "Type-Level Programming", - icon: "link", + icon: "scale", source: `module Main where import Prim.Int (class Compare) @@ -177,214 +191,42 @@ import Prim.Ordering (LT, EQ, GT) data Proxy :: forall k. k -> Type data Proxy a = Proxy --- Assertion helpers using row types to capture comparison results -assertLesser :: forall l r. Compare l r LT => Proxy ( left :: l, right :: r ) -assertLesser = Proxy +-- Assertion helpers capture comparison results in row types +assertLT :: forall l r. Compare l r LT => Proxy (left :: l, right :: r) +assertLT = Proxy -assertGreater :: forall l r. Compare l r GT => Proxy ( left :: l, right :: r ) -assertGreater = Proxy +assertGT :: forall l r. Compare l r GT => Proxy (left :: l, right :: r) +assertGT = Proxy -assertEqual :: forall l r. Compare l r EQ => Proxy ( left :: l, right :: r ) -assertEqual = Proxy +assertEQ :: forall l r. Compare l r EQ => Proxy (left :: l, right :: r) +assertEQ = Proxy -- Symmetry: if m > n then n < m -symmLt :: forall m n. Compare m n GT => Proxy ( left :: n, right :: m ) -symmLt = assertLesser +symmLT :: forall m n. Compare m n GT => Proxy (left :: n, right :: m) +symmLT = assertLT -- Reflexivity: n == n for any integer -reflEq :: forall (n :: Int). Proxy ( left :: n, right :: n ) -reflEq = assertEqual +reflEQ :: forall (n :: Int). Proxy (left :: n, right :: n) +reflEQ = assertEQ -- Transitivity: if m < n and n < p, then m < p -transLt :: forall m n p. Compare m n LT => Compare n p LT => Proxy n -> Proxy ( left :: m, right :: p ) -transLt _ = assertLesser +transLT :: forall m n p. + Compare m n LT => + Compare n p LT => + Proxy n -> Proxy (left :: m, right :: p) +transLT _ = assertLT -- Concrete proof: 1 < 5 < 10 implies 1 < 10 -proof1Lt10 :: Proxy ( left :: 1, right :: 10 ) -proof1Lt10 = transLt (Proxy :: Proxy 5) -`, - }, - - // Generic Deriving - { - id: "derive-generic", - title: "Generic Deriving", - description: "Derive Generic instances to get type-level representations.", - category: "Generic Deriving", - icon: "dna", - source: `module Main where - -import Data.Generic.Rep (class Generic) - -data Void - -data MyUnit = MyUnit - -data Either a b = Left a | Right b - -data Tuple a b = Tuple a b - -newtype Wrapper a = Wrapper a - -derive instance Generic Void _ -derive instance Generic MyUnit _ -derive instance Generic (Either a b) _ -derive instance Generic (Tuple a b) _ -derive instance Generic (Wrapper a) _ - --- Use Proxy to force solving and emit Rep types -data Proxy a = Proxy - -getVoid :: forall rep. Generic Void rep => Proxy rep -getVoid = Proxy - -getMyUnit :: forall rep. Generic MyUnit rep => Proxy rep -getMyUnit = Proxy - -getEither :: forall a b rep. Generic (Either a b) rep => Proxy rep -getEither = Proxy - -forceSolve = { getVoid, getMyUnit, getEither } -`, - }, - { - id: "derive-newtype", - title: "Newtype Deriving", - description: "Derive Newtype instances for wrapper types to enable coercions.", - category: "Generic Deriving", - icon: "box", - source: `module Main where - -import Data.Newtype (class Newtype) - -newtype UserId = UserId Int - -newtype Email = Email String - -newtype Wrapper a = Wrapper a - -derive instance Newtype UserId _ -derive instance Newtype Email _ -derive instance Newtype (Wrapper a) _ -`, - }, - { - id: "safe-coerce", - title: "Safe Coerce", - description: "Zero-cost conversions between representationally equal types.", - category: "Generic Deriving", - icon: "arrow", - source: `module Main where - -import Safe.Coerce (coerce) - --- Newtypes have zero runtime overhead -newtype Age = Age Int -newtype Years = Years Age - --- Wrap and unwrap with no runtime cost -wrapAge :: Int -> Age -wrapAge = coerce - -unwrapAge :: Age -> Int -unwrapAge = coerce - --- Coerce through containers (representational role) -data Maybe a = Nothing | Just a - -coerceMaybe :: Maybe Age -> Maybe Int -coerceMaybe = coerce - --- Transitive coercion: Int -> Age -> Years -coerceTransitive :: Int -> Years -coerceTransitive = coerce - -unwrapTransitive :: Years -> Int -unwrapTransitive = coerce -`, - }, - - // Type Classes - { - id: "class-functor", - title: "Functor Class", - description: "Higher-kinded type class for mappable containers.", - category: "Type Classes", - icon: "cube", - source: `module Main where - -class Functor f where - map :: forall a b. (a -> b) -> f a -> f b - -data Maybe a = Nothing | Just a - -instance Functor Maybe where - map _ Nothing = Nothing - map f (Just a) = Just (f a) - -data List a = Nil | Cons a (List a) - -instance Functor List where - map _ Nil = Nil - map f (Cons x xs) = Cons (f x) (map f xs) -`, - }, - { - id: "fundep", - title: "Functional Dependencies", - description: "Use functional dependencies to guide type inference.", - category: "Type Classes", - icon: "arrow", - source: `module Main where - --- Class with functional dependency: knowing 'a' determines 'b' -class Convert a b | a -> b where - convert :: a -> b - -instance Convert Int String where - convert _ = "int" - -instance Convert Boolean String where - convert _ = "bool" - --- The fundep allows inferring the return type from the input type -testInt = convert 42 -testBool = convert true -`, - }, - { - id: "instance-chains", - title: "Instance Chains", - description: "Use 'else' to create overlapping instances with fallback.", - category: "Type Classes", - icon: "link", - source: `module Main where - -import Prim.Boolean (True, False) - -data Proxy a = Proxy - -class TypeEq a b r | a b -> r - -instance TypeEq a a True -else instance TypeEq a b False - -testSame :: forall r. TypeEq Int Int r => Proxy r -testSame = Proxy - -testDiff :: forall r. TypeEq Int String r => Proxy r -testDiff = Proxy - --- Force instantiation to verify resolved types -test = { testSame, testDiff } +proof1LT10 :: Proxy (left :: 1, right :: 10) +proof1LT10 = transLT (Proxy :: Proxy 5) `, }, { id: "recursive-constraints", title: "Recursive Constraints", description: "Build row types recursively using type-level integers and symbols.", - category: "Type Classes", - icon: "calculator", + category: "Type-Level Programming", + icon: "loop", source: `module Main where import Prim.Int (class Add, class ToString) From 9685969c48707e4eb9e5a450e18964ed1320a8af Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Mon, 19 Jan 2026 12:05:50 +0800 Subject: [PATCH 4/6] Refactor useEditorState to use callbacks instead of derived values Replace timing/pendingNavigation return values with onTimingChange and onNavigate callbacks. This eliminates two useEffect patterns in Workspace that were syncing derived state, making the flow more predictable by calling callbacks at the point of action. Co-Authored-By: Claude Opus 4.5 --- docs/src/components/Workspace.tsx | 21 ++++------- docs/src/hooks/useEditorState.ts | 62 +++++++++++++++++++------------ 2 files changed, 46 insertions(+), 37 deletions(-) diff --git a/docs/src/components/Workspace.tsx b/docs/src/components/Workspace.tsx index 7eb0f060..687cc7a5 100644 --- a/docs/src/components/Workspace.tsx +++ b/docs/src/components/Workspace.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useCallback } from "react"; import type { Remote } from "comlink"; import { useLocation } from "wouter"; import { useEditorState } from "../hooks/useEditorState"; @@ -43,24 +43,17 @@ export function Workspace({ }: Props) { const [, navigate] = useLocation(); - const { source, cst, typeChecker, timing, selectExample, pendingNavigation } = useEditorState({ + const { source, cst, typeChecker, selectExample } = useEditorState({ exampleId, docsLib, mode, + onTimingChange, + onNavigate: useCallback( + (exampleId: string) => navigate(`/types/${exampleId}`), + [navigate] + ), }); - // Sync timing to parent for header display - useEffect(() => { - onTimingChange(timing); - }, [timing, onTimingChange]); - - // Handle pending navigation from prefetch - useEffect(() => { - if (pendingNavigation) { - navigate(`/types/${pendingNavigation.exampleId}`); - } - }, [pendingNavigation, navigate]); - return (
diff --git a/docs/src/hooks/useEditorState.ts b/docs/src/hooks/useEditorState.ts index c2ac0e5b..cc6f98fd 100644 --- a/docs/src/hooks/useEditorState.ts +++ b/docs/src/hooks/useEditorState.ts @@ -86,6 +86,8 @@ interface UseEditorStateOptions { exampleId: string | undefined; docsLib: Remote; mode: "cst" | "typechecker" | "getstarted" | "packages"; + onTimingChange?: (timing: Timing) => void; + onNavigate?: (exampleId: string) => void; } interface SourceState { @@ -102,15 +104,15 @@ interface EditorState { source: SourceState; cst: AnalysisState; typeChecker: AnalysisState; - timing: Timing | null; selectExample: (exampleId: string) => void; - pendingNavigation: { exampleId: string } | null; } export function useEditorState({ exampleId, docsLib, mode, + onTimingChange, + onNavigate, }: UseEditorStateOptions): EditorState { const getSourceForExample = useCallback( (id: string | undefined) => @@ -129,6 +131,17 @@ export function useEditorState({ const prefetchTimeoutRef = useRef(null); const pendingAnalysisRef = useRef<{ source: string; cancelled: boolean } | null>(null); + // Refs for callbacks (prevents stale closures across async boundaries) + const onTimingChangeRef = useRef(onTimingChange); + const onNavigateRef = useRef(onNavigate); + const targetExampleIdRef = useRef(null); + + // Keep callback refs in sync + useEffect(() => { + onTimingChangeRef.current = onTimingChange; + onNavigateRef.current = onNavigate; + }); + // Handle URL changes (direct navigation: back/forward, shared links, typed URL) // vs in-app navigation (selectExample sets source first, then navigate happens) useEffect(() => { @@ -155,6 +168,9 @@ export function useEditorState({ useEffect(() => { const sourceSnapshot = analysisSource; + // Capture prefetching state BEFORE async work + const wasPrefetching = state.status === "prefetching"; + // Cancel any pending analysis if (pendingAnalysisRef.current) { pendingAnalysisRef.current.cancelled = true; @@ -200,6 +216,15 @@ export function useEditorState({ source: sourceSnapshot, results: { cst, typeChecker, timing }, }); + + // Call timing callback + onTimingChangeRef.current?.(timing); + + // Navigate if we were prefetching + if (wasPrefetching && targetExampleIdRef.current) { + onNavigateRef.current?.(targetExampleIdRef.current); + targetExampleIdRef.current = null; + } } catch (err) { console.error("Analysis error:", err); } @@ -210,13 +235,20 @@ export function useEditorState({ return () => { analysisContext.cancelled = true; }; - }, [docsLib, analysisSource, mode]); + }, [docsLib, analysisSource, mode, state.status]); // Handle prefetch timeout useEffect(() => { if (state.status === "prefetching") { + const targetId = targetExampleIdRef.current; // Capture before timeout + prefetchTimeoutRef.current = window.setTimeout(() => { dispatch({ type: "PREFETCH_TIMEOUT" }); + // Navigate on timeout (analysis didn't complete in time) + if (targetId) { + onNavigateRef.current?.(targetId); + targetExampleIdRef.current = null; + } }, PREFETCH_TIMEOUT_MS); return () => { @@ -233,28 +265,14 @@ export function useEditorState({ }, []); const selectExample = useCallback( - (targetExampleId: string) => { - const source = getSourceForExample(targetExampleId); - dispatch({ type: "SELECT_EXAMPLE", exampleId: targetExampleId, source }); + (exampleId: string) => { + targetExampleIdRef.current = exampleId; // Store before dispatch + const source = getSourceForExample(exampleId); + dispatch({ type: "SELECT_EXAMPLE", exampleId, source }); }, [getSourceForExample] ); - // Determine pending navigation - // Navigate when we're no longer prefetching and URL doesn't match the current source - const pendingNavigation = (() => { - // Still prefetching - don't navigate yet - if (state.status === "prefetching") return null; - - // Check if current source corresponds to an example that differs from URL - const currentExample = EXAMPLES.find((e) => e.source === state.source); - if (currentExample && currentExample.id !== loadedExampleRef.current) { - return { exampleId: currentExample.id }; - } - - return null; - })(); - // Determine loading state - only show spinner for loading state (not prefetching) const isLoading = state.status === "loading"; const results = state.status === "idle" || state.status === "stale" ? state.results : null; @@ -272,8 +290,6 @@ export function useEditorState({ isLoading, result: results?.typeChecker ?? null, }, - timing: results?.timing ?? null, selectExample, - pendingNavigation, }; } From 9de0de89dbab36b7c6831b7b352f1dd0a8935fcf Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Mon, 19 Jan 2026 12:10:15 +0800 Subject: [PATCH 5/6] Preserve selected example when navigating to Get Started Previously, navigating back to the Get Started tab would reset the editor to the default source because the URL change effect dispatched URL_LOADED whenever exampleId changed. Now we only update the source when navigating TO an example, not when leaving one. Co-Authored-By: Claude Opus 4.5 --- docs/src/components/GetStartedPanel.tsx | 10 ++++++---- docs/src/hooks/useEditorState.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/src/components/GetStartedPanel.tsx b/docs/src/components/GetStartedPanel.tsx index f1caf476..b198a2e4 100644 --- a/docs/src/components/GetStartedPanel.tsx +++ b/docs/src/components/GetStartedPanel.tsx @@ -63,10 +63,12 @@ export function GetStartedPanel({ onSelectExample }: GetStartedPanelProps) { return (
-

Welcome to PureScript Analyzer

-

- Explore PureScript's type system interactively. Select an example below to load it into - the editor, then switch to the Type Checker tab to see inferred types. +

PureScript Analyzer Playground

+

+ Select an example below to load it into the editor. +

+

+ You can also load registry packages in the packages tab.

diff --git a/docs/src/hooks/useEditorState.ts b/docs/src/hooks/useEditorState.ts index cc6f98fd..259a6f92 100644 --- a/docs/src/hooks/useEditorState.ts +++ b/docs/src/hooks/useEditorState.ts @@ -146,10 +146,13 @@ export function useEditorState({ // vs in-app navigation (selectExample sets source first, then navigate happens) useEffect(() => { if (exampleId !== loadedExampleRef.current) { - const expectedSource = getSourceForExample(exampleId); - // If source doesn't match what the URL expects, this is direct navigation - if (expectedSource !== state.source) { - dispatch({ type: "URL_LOADED", source: expectedSource }); + // Only update source when navigating TO an example, not when leaving + if (exampleId) { + const expectedSource = getSourceForExample(exampleId); + // If source doesn't match what the URL expects, this is direct navigation + if (expectedSource !== state.source) { + dispatch({ type: "URL_LOADED", source: expectedSource }); + } } // Update ref regardless - URL now matches loadedExampleRef.current = exampleId; From fbcbc44e82e549296822d395c2f07671bf142463 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Mon, 19 Jan 2026 12:50:05 +0800 Subject: [PATCH 6/6] Move module name parsing from JS regex to WASM parser The previous approach used a fragile regex to extract module names JS-side before registration. This refactors to pass the tar path and source to WASM, where the actual PureScript parser extracts the module name robustly. - Add RawModule type (path + source before parsing) - Add path field to PackageModule as stable identifier - Replace register_module with register_source in WASM - register_source returns parsed module name or None on failure Co-Authored-By: Claude Opus 4.5 --- docs/src/lib/packages/fetcher.ts | 24 ++++++------------------ docs/src/lib/packages/types.ts | 11 +++++++++-- docs/src/lib/types.ts | 2 +- docs/src/wasm/src/engine.rs | 27 +++++++++++++++++++-------- docs/src/wasm/src/lib.rs | 9 ++++----- docs/src/worker/docs-lib.ts | 17 +++++++++++------ 6 files changed, 50 insertions(+), 40 deletions(-) diff --git a/docs/src/lib/packages/fetcher.ts b/docs/src/lib/packages/fetcher.ts index 4798ca73..8162873a 100644 --- a/docs/src/lib/packages/fetcher.ts +++ b/docs/src/lib/packages/fetcher.ts @@ -1,5 +1,5 @@ import pako from "pako"; -import type { PackageModule, PackageSet } from "./types"; +import type { RawModule, PackageSet } from "./types"; const REGISTRY_URL = "https://packages.registry.purescript.org"; const PACKAGE_SET_URL = @@ -53,20 +53,11 @@ function parseTar(data: Uint8Array): Map { return files; } -/** - * Extract module name from PureScript source. - * Parses the "module X.Y.Z where" declaration. - */ -function extractModuleName(source: string): string | null { - const match = source.match(/^\s*module\s+([\w.]+)/m); - return match ? match[1] : null; -} - export async function fetchPackage( packageName: string, version: string, onProgress?: (progress: number) => void -): Promise { +): Promise { // Strip 'v' prefix from version for registry URL const versionNum = version.startsWith("v") ? version.slice(1) : version; const url = `${REGISTRY_URL}/${packageName}/${versionNum}.tar.gz`; @@ -108,13 +99,10 @@ export async function fetchPackage( // Extract .purs files const files = parseTar(tarData); - // Convert to modules - const modules: PackageModule[] = []; - for (const [, source] of files) { - const moduleName = extractModuleName(source); - if (moduleName) { - modules.push({ name: moduleName, source }); - } + // Convert to raw modules (path + source, no module name parsing) + const modules: RawModule[] = []; + for (const [path, source] of files) { + modules.push({ path, source }); } return modules; diff --git a/docs/src/lib/packages/types.ts b/docs/src/lib/packages/types.ts index a5292a9a..6b54e833 100644 --- a/docs/src/lib/packages/types.ts +++ b/docs/src/lib/packages/types.ts @@ -7,9 +7,16 @@ export interface PackageSetEntry { export type PackageSet = Record; -// Internal state +// Raw module data from tar extraction (before WASM parsing) +export interface RawModule { + path: string; // tar path, e.g., "prelude-6.0.1/src/Data/Maybe.purs" + source: string; // PureScript source code +} + +// Internal state (after WASM parsing extracts module name) export interface PackageModule { - name: string; // e.g., "Data.Maybe" + path: string; // tar path, e.g., "prelude-6.0.1/src/Data/Maybe.purs" + name: string; // module name returned from WASM, e.g., "Data.Maybe" source: string; // PureScript source code } diff --git a/docs/src/lib/types.ts b/docs/src/lib/types.ts index a2b73243..799be90c 100644 --- a/docs/src/lib/types.ts +++ b/docs/src/lib/types.ts @@ -66,5 +66,5 @@ export interface Lib { onProgress: (progress: PackageLoadProgress) => void ): Promise; clearPackages(): Promise; - registerModule(moduleName: string, source: string): Promise; + registerModule(path: string, source: string): Promise; } diff --git a/docs/src/wasm/src/engine.rs b/docs/src/wasm/src/engine.rs index 70e26ee7..843770f3 100644 --- a/docs/src/wasm/src/engine.rs +++ b/docs/src/wasm/src/engine.rs @@ -84,19 +84,30 @@ impl WasmQueryEngine { } } - /// Register an external module (from a package). - /// Returns the FileId for the module. - pub fn register_external_module(&mut self, module_name: &str, source: &str) -> FileId { - let path = format!("pkg://registry/{module_name}.purs"); - let id = self.files.borrow_mut().insert(path.as_str(), source); - + /// Register an external module source, parsing the module name from source. + /// Returns the parsed module name on success, or None if parsing fails. + pub fn register_external_source(&mut self, path: &str, source: &str) -> Option { + // 1. Insert file into VFS → FileId + let virtual_path = format!("pkg://registry/{path}"); + let id = self.files.borrow_mut().insert(virtual_path.as_str(), source); + + // 2. Set content in input storage self.input.borrow_mut().content.insert(id, Arc::from(source)); - let name_id = self.interned.borrow_mut().module.intern(module_name); + // 3. Parse (using cached query infrastructure) + let (parsed, _) = self.parsed(id).ok()?; + + // 4. Extract module name + let module_name = parsed.module_name()?; + + // 5. Register module name → FileId mapping + let name_id = self.interned.borrow_mut().module.intern(&module_name); self.input.borrow_mut().module.insert(name_id, id); + // Track for cleanup self.external_ids.push(id); - id + + Some(module_name.to_string()) } /// Clear all external modules (packages), keeping Prim and user modules. diff --git a/docs/src/wasm/src/lib.rs b/docs/src/wasm/src/lib.rs index 3eaf0593..a61260a7 100644 --- a/docs/src/wasm/src/lib.rs +++ b/docs/src/wasm/src/lib.rs @@ -314,12 +314,11 @@ pub fn check(source: &str) -> JsValue { serde_wasm_bindgen::to_value(&result).unwrap() } -/// Register an external module (from a package) with the engine. +/// Register an external module source, parsing the module name from source. +/// Returns the parsed module name on success, or undefined if parsing fails. #[wasm_bindgen] -pub fn register_module(module_name: &str, source: &str) { - ENGINE.with_borrow_mut(|engine| { - engine.register_external_module(module_name, source); - }); +pub fn register_source(path: &str, source: &str) -> Option { + ENGINE.with_borrow_mut(|engine| engine.register_external_source(path, source)) } /// Clear all external modules (packages), keeping Prim and user modules. diff --git a/docs/src/worker/docs-lib.ts b/docs/src/worker/docs-lib.ts index 4c56c488..347cf855 100644 --- a/docs/src/worker/docs-lib.ts +++ b/docs/src/worker/docs-lib.ts @@ -3,6 +3,7 @@ import init, * as docsLib from "docs-lib"; import type { ParseResult, CheckResult } from "../lib/types"; import type { PackageSet, + PackageModule, LoadedPackage, PackageLoadProgress, PackageStatus, @@ -60,7 +61,7 @@ const lib = { onProgress({ ...progress, packages: new Map(progress.packages) }); try { - const modules = await fetchPackage(pkgName, entry.version, (p) => { + const rawModules = await fetchPackage(pkgName, entry.version, (p) => { progress.packages.set(pkgName, { state: "downloading", progress: p }); onProgress({ ...progress, packages: new Map(progress.packages) }); }); @@ -68,9 +69,13 @@ const lib = { progress.packages.set(pkgName, { state: "extracting" }); onProgress({ ...progress, packages: new Map(progress.packages) }); - // Register modules with WASM engine - for (const mod of modules) { - docsLib.register_module(mod.name, mod.source); + // Register modules with WASM engine (parses module name from source) + const modules: PackageModule[] = []; + for (const raw of rawModules) { + const moduleName = docsLib.register_source(raw.path, raw.source); + if (moduleName) { + modules.push({ path: raw.path, name: moduleName, source: raw.source }); + } } progress.packages.set(pkgName, { state: "ready", moduleCount: modules.length }); @@ -105,8 +110,8 @@ const lib = { docsLib.clear_packages(); }, - async registerModule(moduleName: string, source: string): Promise { - docsLib.register_module(moduleName, source); + async registerModule(path: string, source: string): Promise { + return docsLib.register_source(path, source); }, };