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 fa48e5f5..dd30f665 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,11 +17,17 @@ "@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" + "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", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -29,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 f2e8b951..0659952e 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -20,16 +20,34 @@ 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 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)) + '@types/pako': + specifier: ^2.0.3 + version: 2.0.4 '@types/react': specifier: ^18.3.12 version: 18.3.27 @@ -51,18 +69,20 @@ 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) - ../tests-package-set: {} - - ../vscode: {} - src/wasm/pkg: {} 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'} @@ -290,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==} @@ -419,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==} @@ -524,6 +621,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==} @@ -541,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 @@ -550,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==} @@ -575,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==} @@ -582,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'} @@ -591,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} @@ -603,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 @@ -610,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'} @@ -690,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==} @@ -711,12 +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} @@ -726,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: @@ -739,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'} @@ -751,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==} @@ -762,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} @@ -804,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 @@ -994,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 @@ -1081,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 @@ -1172,6 +1534,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)': @@ -1195,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: @@ -1205,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: @@ -1219,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: @@ -1226,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 @@ -1254,6 +1650,8 @@ snapshots: escalade@3.2.0: {} + exsolve@1.0.8: {} + fsevents@2.3.3: optional: true @@ -1261,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: @@ -1318,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 @@ -1330,16 +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 @@ -1348,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 @@ -1360,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 @@ -1394,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 @@ -1417,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 12df049a..cf2bc678 100644 --- a/docs/src/App.tsx +++ b/docs/src/App.tsx @@ -1,78 +1,151 @@ -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 type { ParseResult, CheckResult, Mode, 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 } -`; +import { Workspace } from "./components/Workspace"; +import type { Mode, Timing } from "./lib/types"; +import type { PackageSet, PackageLoadProgress } from "./lib/packages/types"; +import { + loadCachedPackageSet, + savePackageSetToCache, + loadCachedPackages, + saveCachedPackages, + clearCachedPackages, +} from "./lib/packages/cache"; export default function App() { const docsLib = useDocsLib(); - const [source, setSource] = useState(DEFAULT_SOURCE); - const [mode, setMode] = useState("cst"); - 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); - const debouncedSource = useDebounce(source, 150); + // 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 loadingRef = useRef(false); + const hasRestoredRef = useRef(false); - const runAnalysis = useCallback(async () => { + // Load package set on mount + useEffect(() => { if (docsLib.status !== "ready") return; + const lib = docsLib.lib; + + const loadPackageSet = async () => { + const cached = loadCachedPackageSet(); + if (cached) { + setPackageSet(cached); + 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, - }); + try { + const ps = await lib.fetchPackageSet(); + setPackageSet(ps); + savePackageSetToCache(ps); + } catch (e) { + console.error("Failed to fetch package set:", e); } - } catch (err) { - console.error("Analysis error:", err); - } - }, [docsLib, debouncedSource, mode]); + }; + loadPackageSet(); + }, [docsLib]); + + // Restore cached packages on mount useEffect(() => { - runAnalysis(); - }, [runAnalysis]); + if (docsLib.status !== "ready" || !packageSet || hasRestoredRef.current) return; + hasRestoredRef.current = true; + const lib = docsLib.lib; + + const restoreCachedPackages = async () => { + const cached = loadCachedPackages(); + if (cached.size === 0) return; + + setIsLoadingPackages(true); + const packageNames = Array.from(cached.keys()); + const progressProxy = Comlink.proxy((progress: PackageLoadProgress) => + setLoadProgress(progress) + ); + + try { + 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); + } + }; + + restoreCachedPackages(); + }, [docsLib, packageSet]); + + const handleAddPackage = useCallback( + async (packageName: string) => { + 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, + progressProxy + ); + + 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); + setPackageError(e instanceof Error ? e.message : "Failed to load package"); + } finally { + loadingRef.current = false; + setIsLoadingPackages(false); + } + }, + [packageSet, loadedPackages, docsLib] + ); + + const handleClearPackages = useCallback(async () => { + if (docsLib.status !== "ready") return; + + await docsLib.lib.clearPackages(); + setLoadedPackages([]); + setLoadProgress(null); + clearCachedPackages(); + }, [docsLib]); if (docsLib.status === "loading") { return ( @@ -100,19 +173,20 @@ export default function App() { -
-
- -
- -
- -
- {mode === "cst" && } - {mode === "typechecker" && } -
-
-
+ setPackageError(null)} + /> ); } diff --git a/docs/src/components/GetStartedPanel.tsx b/docs/src/components/GetStartedPanel.tsx new file mode 100644 index 00000000..b198a2e4 --- /dev/null +++ b/docs/src/components/GetStartedPanel.tsx @@ -0,0 +1,89 @@ +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 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> = { + merge: RiMergeLine, + layers: RiStackLine, + list: RiListCheck2, + "text-fields": RiText, + calculator: RiCalculatorLine, + dna: RiDnaLine, + box: RiBox3Line, + cube: RiShapesLine, + arrow: RiArrowRightLine, + link: RiLinksLine, + wand: RiMagicLine, + sparkles: RiSparklingLine, + scale: RiScales3Line, + loop: RiLoopLeftLine, +}; + +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 }: GetStartedPanelProps) { + return ( +
+
+

PureScript Analyzer Playground

+

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

+

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

+
+ + {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..eb113cc8 --- /dev/null +++ b/docs/src/components/PackagePanel.tsx @@ -0,0 +1,203 @@ +import { useState, useCallback, useRef, useEffect } 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; + error: string | null; + onDismissError: () => void; +} + +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, + 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) => { + 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

+ + {/* Error display */} + {error && ( +
+ {error} + +
+ )} + + {/* Search/Add Package */} +
+ handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} + 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..6ad37971 100644 --- a/docs/src/components/Tabs.tsx +++ b/docs/src/components/Tabs.tsx @@ -1,31 +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: "cst", label: "CST Preview" }, - { id: "typechecker", label: "Type Checker" }, +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..687cc7a5 --- /dev/null +++ b/docs/src/components/Workspace.tsx @@ -0,0 +1,87 @@ +import { useCallback } 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, selectExample } = useEditorState({ + exampleId, + docsLib, + mode, + onTimingChange, + onNavigate: useCallback( + (exampleId: string) => navigate(`/types/${exampleId}`), + [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..259a6f92 --- /dev/null +++ b/docs/src/hooks/useEditorState.ts @@ -0,0 +1,298 @@ +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"; + onTimingChange?: (timing: Timing) => void; + onNavigate?: (exampleId: string) => void; +} + +interface SourceState { + value: string; + set: (value: string) => void; +} + +interface AnalysisState { + isLoading: boolean; + result: T | null; +} + +interface EditorState { + source: SourceState; + cst: AnalysisState; + typeChecker: AnalysisState; + selectExample: (exampleId: string) => void; +} + +export function useEditorState({ + exampleId, + docsLib, + mode, + onTimingChange, + onNavigate, +}: 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); + + // 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(() => { + if (exampleId !== loadedExampleRef.current) { + // 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; + } + }, [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; + + // Capture prefetching state BEFORE async work + const wasPrefetching = state.status === "prefetching"; + + // 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 }, + }); + + // 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); + } + }; + + runAnalysis(); + + return () => { + analysisContext.cancelled = true; + }; + }, [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 () => { + 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( + (exampleId: string) => { + targetExampleIdRef.current = exampleId; // Store before dispatch + const source = getSourceForExample(exampleId); + dispatch({ type: "SELECT_EXAMPLE", exampleId, source }); + }, + [getSourceForExample] + ); + + // 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, + }, + selectExample, + }; +} 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 new file mode 100644 index 00000000..d8ae0ac5 --- /dev/null +++ b/docs/src/lib/examples.ts @@ -0,0 +1,261 @@ +export interface Example { + id: string; + title: string; + description: string; + category: string; + icon: string; + source: string; +} + +export const EXAMPLES: Example[] = [ + // Basics - proving compiler capabilities + { + id: "constraint-generalisation", + title: "Constraint Generalisation", + description: "Infer type class constraints from usage in untyped bindings.", + category: "Basics", + icon: "sparkles", + source: `module Main where + +class Functor f where + map :: forall a b. (a -> b) -> f a -> f b + +class Functor f <= Apply f where + apply :: forall a b. f (a -> b) -> f a -> f b + +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 + +-- 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 + +-- 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 + +-- 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: "instance-deriving", + title: "Instance Deriving", + description: "Derive Generic and Newtype instances for data declarations.", + category: "Basics", + icon: "wand", + source: `module Main where + +import Data.Generic.Rep (class Generic) +import Data.Newtype (class Newtype) + +-- 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 instance Generic (Maybe a) _ +derive instance Generic (Either a b) _ +derive instance Generic (Tree a) _ + +-- Newtypes derive both Generic and Newtype +newtype UserId = UserId Int +newtype Email = Email String +newtype Wrapper a = Wrapper a + +derive instance Generic UserId _ +derive instance Generic Email _ +derive instance Generic (Wrapper a) _ + +derive instance Newtype UserId _ +derive instance Newtype Email _ +derive instance Newtype (Wrapper a) _ + +-- Force Generic solving to see Rep types +data Proxy a = Proxy + +getTreeRep :: forall a rep. Generic (Tree a) rep => Proxy rep +getTreeRep = Proxy + +getMaybeRep :: forall a rep. Generic (Maybe a) rep => Proxy rep +getMaybeRep = Proxy + +forceSolve = { getTreeRep, getMaybeRep } +`, + }, + { + id: "type-classes", + title: "Type Classes", + description: "Type classes, functional dependencies, and instance chains.", + category: "Basics", + icon: "layers", + source: `module Main where + +import Prim.Boolean (True, False) + +data Proxy a = Proxy + +-- Basic type 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" + +-- Fundep guides inference: no type annotation needed +testConvert = convert 42 + +-- Instance chains with 'else' for overlapping instances +class TypeEq a b (result :: Boolean) | a b -> result + +instance TypeEq a a True +else instance TypeEq a b False + +-- 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: "row-union", + title: "Row Union", + description: "Bidirectional Row.Union constraint solving for extensible records.", + category: "Type-Level Programming", + icon: "merge", + source: `module Main where + +import Prim.Row as Row + +data Proxy :: forall k. k -> Type +data Proxy a = Proxy + +-- Derive the union from left and right +deriveUnion :: forall u. Row.Union (a :: Int) (b :: String) u => Proxy u +deriveUnion = 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 + +-- Derive the right row from left and union +deriveRight :: forall r. Row.Union (a :: Int) r (a :: Int, b :: String) => Proxy r +deriveRight = 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 + +foreign import unsafeMerge :: forall a b c. a -> b -> c + +test = merge { a: 1 } { b: "hello" } +`, + }, + { + id: "int-compare", + title: "Comparison Proofs", + description: "Type-level proofs of integer comparison transitivity and symmetry.", + category: "Type-Level Programming", + icon: "scale", + 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 capture comparison results in row types +assertLT :: forall l r. Compare l r LT => Proxy (left :: l, right :: r) +assertLT = Proxy + +assertGT :: forall l r. Compare l r GT => Proxy (left :: l, right :: r) +assertGT = 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 = assertLT + +-- Reflexivity: n == n for any integer +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 _ = assertLT + +-- Concrete proof: 1 < 5 < 10 implies 1 < 10 +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-Level Programming", + icon: "loop", + 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) +`, + }, +]; + +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..8162873a --- /dev/null +++ b/docs/src/lib/packages/fetcher.ts @@ -0,0 +1,117 @@ +import pako from "pako"; +import type { RawModule, 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; +} + +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 raw modules (path + source, no module name parsing) + const modules: RawModule[] = []; + for (const [path, source] of files) { + modules.push({ path, 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..6b54e833 --- /dev/null +++ b/docs/src/lib/packages/types.ts @@ -0,0 +1,41 @@ +// Package set format from purescript/package-sets +export interface PackageSetEntry { + version: string; + repo: string; + dependencies: string[]; +} + +export type PackageSet = Record; + +// 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 { + 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 +} + +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..799be90c 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(path: 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/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/src/wasm/src/engine.rs b/docs/src/wasm/src/engine.rs index 0c1ca698..843770f3 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,63 @@ 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 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)); + + // 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); + + Some(module_name.to_string()) + } + + /// 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..a61260a7 100644 --- a/docs/src/wasm/src/lib.rs +++ b/docs/src/wasm/src/lib.rs @@ -313,3 +313,18 @@ pub fn check(source: &str) -> JsValue { serde_wasm_bindgen::to_value(&result).unwrap() } + +/// 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_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. +#[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..347cf855 100644 --- a/docs/src/worker/docs-lib.ts +++ b/docs/src/worker/docs-lib.ts @@ -1,6 +1,15 @@ import * as Comlink from "comlink"; import init, * as docsLib from "docs-lib"; import type { ParseResult, CheckResult } from "../lib/types"; +import type { + PackageSet, + PackageModule, + 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 +24,95 @@ 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 rawModules = 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 (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 }); + 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(path: string, source: string): Promise { + return docsLib.register_source(path, source); + }, }; Comlink.expose(lib); 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",