concept-collection / stan-web-ide
stan web IDE: run Stan sampling in the browser
minwebide app: projects on IndexedDB; .sample YAML files describe runs (stan program, data, output_dir, sampling params) and open as a form view with a run button and per-chain progress bars; models compile on a stan-wasm-server and sample locally via tinystan in a worker (parallel chains through a cross-origin pthread trampoline); outputs land in the project as per-chain CSVs, summary.csv (mcmc-stats), sampling_opts.json, and console.txt; .stan editing gets a Monarch grammar plus diagnostics, hover, completion, and formatting from stan-language-server.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 21efcb65df0b Browse files
35 changed files+5509−0
.github/workflows/deploy.ymladded+81−0View file
@@ -0,0 +1,81 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+concurrency:
14+ group: pages
15+ cancel-in-progress: false
16+
17+jobs:
18+ build:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - name: Checkout stan-web-ide
22+ uses: actions/checkout@v4
23+ with:
24+ path: stan-web-ide
25+
26+ # minwebide is consumed as a sibling checkout (file:../minwebide)
27+ - name: Checkout minwebide
28+ uses: actions/checkout@v4
29+ with:
30+ repository: magland/minwebide
31+ path: minwebide
32+
33+ - uses: actions/setup-node@v4
34+ with:
35+ node-version: 22
36+
37+ # the pinned VS Code source checkout is large; cache it by pinned version
38+ - name: Cache VS Code source
39+ uses: actions/cache@v4
40+ with:
41+ path: minwebide/vendor/vscode
42+ key: vscode-vendor-${{ hashFiles('minwebide/.vscode-version') }}
43+
44+ - name: Install minwebide (fetches VS Code source on postinstall)
45+ working-directory: minwebide
46+ env:
47+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
48+ run: npm ci
49+
50+ - name: Install stan-web-ide
51+ working-directory: stan-web-ide
52+ env:
53+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
54+ run: npm ci
55+
56+ - name: Build
57+ working-directory: stan-web-ide
58+ env:
59+ DEPLOY_BASE: /stan-web-ide/
60+ run: npm run build
61+
62+ - name: Setup Pages
63+ uses: actions/configure-pages@v5
64+ with:
65+ enablement: true
66+
67+ - name: Upload artifact
68+ uses: actions/upload-pages-artifact@v3
69+ with:
70+ path: stan-web-ide/dist
71+
72+ deploy:
73+ needs: build
74+ runs-on: ubuntu-latest
75+ environment:
76+ name: github-pages
77+ url: ${{ steps.deployment.outputs.page_url }}
78+ steps:
79+ - name: Deploy to GitHub Pages
80+ id: deployment
81+ uses: actions/deploy-pages@v4
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+90−0View file
@@ -0,0 +1,90 @@
1+# stan web IDE
2+
3+Run [Stan](https://mc-stan.org) sampling in your browser, inside a VS
4+Code-style IDE built on [minwebide](https://github.com/magland/minwebide).
5+Projects live in your browser's IndexedDB. Models compile on a remote
6+[stan-wasm-server](https://github.com/flatironinstitute/stan-playground/tree/main/backend);
7+sampling itself runs locally in a web worker with
8+[tinystan](https://github.com/WardBrian/tinystan), chains in parallel
9+threads.
10+
11+**Live site:** https://concept-collection.github.io/stan-web-ide/
12+
13+## How it works
14+
15+A project holds `.stan` programs, `.json` data files, and `.sample` files. A
16+`.sample` file is a YAML description of one sampling run:
17+
18+```yaml
19+stan: linear.stan # the Stan program
20+data: data.json # the data
21+output_dir: out/fit # results are written here (replaced on each run)
22+num_chains: 4 # optional; defaults 4 / 1000 / 1000 / 2.0 / random
23+num_warmup: 1000
24+num_samples: 1000
25+init_radius: 2.0
26+seed: 42 # omit for a random seed
27+```
28+
29+Paths are relative to the `.sample` file (leading `/` = project root).
30+Opening a `.sample` file shows a **form view** — file pickers, sampling
31+parameters, a Run button, and per-chain progress bars. The form edits the
32+underlying YAML (tab menu → *Reopen as Text Editor* for the raw file); the
33+tab bar's ▶/⏹ runs and stops the same way. Runs use current editor contents,
34+saved or not.
35+
36+A run compiles the program (server-side, cached by source hash), streams
37+Stan's console output to the **Output** panel, and writes into
38+`output_dir`:
39+
40+- `chain_1.csv` … one CSV per chain, header = parameter names, one row per draw
41+- `summary.csv` — mean, MCSE, sd, 5%/50%/95%, ESS, ESS/s, split-Rhat per
42+ parameter (via [mcmc-stats](https://github.com/flatironinstitute/mcmc-stats.js))
43+- `sampling_opts.json` — the exact configuration used (including the
44+ resolved seed)
45+- `console.txt` — the sampler's console output
46+
47+The `.stan` editor has syntax highlighting plus diagnostics, hover docs,
48+completion, and auto-format from
49+[stan-language-server](https://github.com/tomatitito/stan-language-server)
50+(stanc3 compiled to JS, running in a worker).
51+
52+## The compilation server
53+
54+Compiling Stan to WebAssembly needs a server; everything else is local. The
55+status bar shows the configured server (click it to change; persisted in the
56+browser). The default is `http://localhost:8083` — run one with:
57+
58+```sh
59+docker run -p 8083:8080 -it ghcr.io/flatironinstitute/stan-wasm-server:latest
60+```
61+
62+**CORS**: the server's allowlist must include the page's origin. The stock
63+image allows `http://127.0.0.1:3000` and `http://127.0.0.1:4173`, which
64+match this app's dev and preview ports — open the `127.0.0.1` URL, not
65+`localhost`. To serve other origins (like the live site above), host a
66+server whose allowlist includes them.
67+
68+Threaded sampling requires cross-origin isolation (`SharedArrayBuffer`):
69+dev/preview send COOP/COEP headers; the GitHub Pages deployment uses
70+`coi-serviceworker.js`, injected at build time only.
71+
72+## Development
73+
74+minwebide is consumed as a sibling checkout (`file:../minwebide`):
75+
76+```sh
77+git clone https://github.com/magland/minwebide ../minwebide
78+(cd ../minwebide && npm install) # fetches the pinned VS Code source
79+npm install
80+npm run dev # http://127.0.0.1:3000
81+```
82+
83+- `npm run build` — static bundle in `dist/`
84+- `npm run typecheck` — typechecks app code (vendor diagnostics suppressed)
85+- `npm run smoke` — headless end-to-end test against the built bundle; with
86+ a compile server on `localhost:8083` it also compiles and samples for real
87+- `node scripts/dev-check.mjs` — quick checks against a running dev server
88+
89+CI checks out `magland/minwebide` next to this repo, installs both, builds,
90+and publishes `dist/` to GitHub Pages.
index.htmladded+24−0View file
@@ -0,0 +1,24 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230078d4'/%3E%3C/svg%3E" />
7+ <title>stan-web-ide</title>
8+ <style>
9+ html,
10+ body {
11+ height: 100%;
12+ margin: 0;
13+ padding: 0;
14+ }
15+ #app {
16+ height: 100%;
17+ }
18+ </style>
19+ </head>
20+ <body>
21+ <div id="app"></div>
22+ <script type="module" src="/src/main.ts"></script>
23+ </body>
24+</html>
package-lock.jsonadded+1400−0View file
@@ -0,0 +1,1400 @@
1+{
2+ "name": "stan-web-ide",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "stan-web-ide",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "mcmc-stats": "^0.0.1",
12+ "minwebide": "file:../minwebide",
13+ "stan-language-server": "^0.4.9",
14+ "tinystan": "^0.3.3",
15+ "vscode-languageserver": "^9.0.1",
16+ "yaml": "^2.8.0"
17+ },
18+ "devDependencies": {
19+ "playwright": "^1.61.1",
20+ "typescript": "^5.9.0",
21+ "vite": "^7.0.0"
22+ }
23+ },
24+ "../minwebide": {
25+ "version": "0.1.0",
26+ "hasInstallScript": true,
27+ "license": "MIT",
28+ "dependencies": {
29+ "@vscode/codicons": "^0.0.46-21",
30+ "vscode-oniguruma": "1.7.0",
31+ "vscode-textmate": "^9.3.2"
32+ },
33+ "devDependencies": {
34+ "@types/wicg-file-system-access": "^2023.10.7",
35+ "@webgpu/types": "^0.1.71",
36+ "playwright": "^1.61.1",
37+ "trusted-types": "^2.0.0",
38+ "typescript": "^5.9.0",
39+ "vite": "^7.0.0"
40+ }
41+ },
42+ "node_modules/@esbuild/aix-ppc64": {
43+ "version": "0.28.1",
44+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
45+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
46+ "cpu": [
47+ "ppc64"
48+ ],
49+ "dev": true,
50+ "license": "MIT",
51+ "optional": true,
52+ "os": [
53+ "aix"
54+ ],
55+ "engines": {
56+ "node": ">=18"
57+ }
58+ },
59+ "node_modules/@esbuild/android-arm": {
60+ "version": "0.28.1",
61+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
62+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
63+ "cpu": [
64+ "arm"
65+ ],
66+ "dev": true,
67+ "license": "MIT",
68+ "optional": true,
69+ "os": [
70+ "android"
71+ ],
72+ "engines": {
73+ "node": ">=18"
74+ }
75+ },
76+ "node_modules/@esbuild/android-arm64": {
77+ "version": "0.28.1",
78+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
79+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
80+ "cpu": [
81+ "arm64"
82+ ],
83+ "dev": true,
84+ "license": "MIT",
85+ "optional": true,
86+ "os": [
87+ "android"
88+ ],
89+ "engines": {
90+ "node": ">=18"
91+ }
92+ },
93+ "node_modules/@esbuild/android-x64": {
94+ "version": "0.28.1",
95+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
96+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
97+ "cpu": [
98+ "x64"
99+ ],
100+ "dev": true,
101+ "license": "MIT",
102+ "optional": true,
103+ "os": [
104+ "android"
105+ ],
106+ "engines": {
107+ "node": ">=18"
108+ }
109+ },
110+ "node_modules/@esbuild/darwin-arm64": {
111+ "version": "0.28.1",
112+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
113+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
114+ "cpu": [
115+ "arm64"
116+ ],
117+ "dev": true,
118+ "license": "MIT",
119+ "optional": true,
120+ "os": [
121+ "darwin"
122+ ],
123+ "engines": {
124+ "node": ">=18"
125+ }
126+ },
127+ "node_modules/@esbuild/darwin-x64": {
128+ "version": "0.28.1",
129+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
130+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
131+ "cpu": [
132+ "x64"
133+ ],
134+ "dev": true,
135+ "license": "MIT",
136+ "optional": true,
137+ "os": [
138+ "darwin"
139+ ],
140+ "engines": {
141+ "node": ">=18"
142+ }
143+ },
144+ "node_modules/@esbuild/freebsd-arm64": {
145+ "version": "0.28.1",
146+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
147+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
148+ "cpu": [
149+ "arm64"
150+ ],
151+ "dev": true,
152+ "license": "MIT",
153+ "optional": true,
154+ "os": [
155+ "freebsd"
156+ ],
157+ "engines": {
158+ "node": ">=18"
159+ }
160+ },
161+ "node_modules/@esbuild/freebsd-x64": {
162+ "version": "0.28.1",
163+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
164+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
165+ "cpu": [
166+ "x64"
167+ ],
168+ "dev": true,
169+ "license": "MIT",
170+ "optional": true,
171+ "os": [
172+ "freebsd"
173+ ],
174+ "engines": {
175+ "node": ">=18"
176+ }
177+ },
178+ "node_modules/@esbuild/linux-arm": {
179+ "version": "0.28.1",
180+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
181+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
182+ "cpu": [
183+ "arm"
184+ ],
185+ "dev": true,
186+ "license": "MIT",
187+ "optional": true,
188+ "os": [
189+ "linux"
190+ ],
191+ "engines": {
192+ "node": ">=18"
193+ }
194+ },
195+ "node_modules/@esbuild/linux-arm64": {
196+ "version": "0.28.1",
197+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
198+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
199+ "cpu": [
200+ "arm64"
201+ ],
202+ "dev": true,
203+ "license": "MIT",
204+ "optional": true,
205+ "os": [
206+ "linux"
207+ ],
208+ "engines": {
209+ "node": ">=18"
210+ }
211+ },
212+ "node_modules/@esbuild/linux-ia32": {
213+ "version": "0.28.1",
214+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
215+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
216+ "cpu": [
217+ "ia32"
218+ ],
219+ "dev": true,
220+ "license": "MIT",
221+ "optional": true,
222+ "os": [
223+ "linux"
224+ ],
225+ "engines": {
226+ "node": ">=18"
227+ }
228+ },
229+ "node_modules/@esbuild/linux-loong64": {
230+ "version": "0.28.1",
231+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
232+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
233+ "cpu": [
234+ "loong64"
235+ ],
236+ "dev": true,
237+ "license": "MIT",
238+ "optional": true,
239+ "os": [
240+ "linux"
241+ ],
242+ "engines": {
243+ "node": ">=18"
244+ }
245+ },
246+ "node_modules/@esbuild/linux-mips64el": {
247+ "version": "0.28.1",
248+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
249+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
250+ "cpu": [
251+ "mips64el"
252+ ],
253+ "dev": true,
254+ "license": "MIT",
255+ "optional": true,
256+ "os": [
257+ "linux"
258+ ],
259+ "engines": {
260+ "node": ">=18"
261+ }
262+ },
263+ "node_modules/@esbuild/linux-ppc64": {
264+ "version": "0.28.1",
265+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
266+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
267+ "cpu": [
268+ "ppc64"
269+ ],
270+ "dev": true,
271+ "license": "MIT",
272+ "optional": true,
273+ "os": [
274+ "linux"
275+ ],
276+ "engines": {
277+ "node": ">=18"
278+ }
279+ },
280+ "node_modules/@esbuild/linux-riscv64": {
281+ "version": "0.28.1",
282+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
283+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
284+ "cpu": [
285+ "riscv64"
286+ ],
287+ "dev": true,
288+ "license": "MIT",
289+ "optional": true,
290+ "os": [
291+ "linux"
292+ ],
293+ "engines": {
294+ "node": ">=18"
295+ }
296+ },
297+ "node_modules/@esbuild/linux-s390x": {
298+ "version": "0.28.1",
299+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
300+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
301+ "cpu": [
302+ "s390x"
303+ ],
304+ "dev": true,
305+ "license": "MIT",
306+ "optional": true,
307+ "os": [
308+ "linux"
309+ ],
310+ "engines": {
311+ "node": ">=18"
312+ }
313+ },
314+ "node_modules/@esbuild/linux-x64": {
315+ "version": "0.28.1",
316+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
317+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
318+ "cpu": [
319+ "x64"
320+ ],
321+ "dev": true,
322+ "license": "MIT",
323+ "optional": true,
324+ "os": [
325+ "linux"
326+ ],
327+ "engines": {
328+ "node": ">=18"
329+ }
330+ },
331+ "node_modules/@esbuild/netbsd-arm64": {
332+ "version": "0.28.1",
333+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
334+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
335+ "cpu": [
336+ "arm64"
337+ ],
338+ "dev": true,
339+ "license": "MIT",
340+ "optional": true,
341+ "os": [
342+ "netbsd"
343+ ],
344+ "engines": {
345+ "node": ">=18"
346+ }
347+ },
348+ "node_modules/@esbuild/netbsd-x64": {
349+ "version": "0.28.1",
350+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
351+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
352+ "cpu": [
353+ "x64"
354+ ],
355+ "dev": true,
356+ "license": "MIT",
357+ "optional": true,
358+ "os": [
359+ "netbsd"
360+ ],
361+ "engines": {
362+ "node": ">=18"
363+ }
364+ },
365+ "node_modules/@esbuild/openbsd-arm64": {
366+ "version": "0.28.1",
367+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
368+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
369+ "cpu": [
370+ "arm64"
371+ ],
372+ "dev": true,
373+ "license": "MIT",
374+ "optional": true,
375+ "os": [
376+ "openbsd"
377+ ],
378+ "engines": {
379+ "node": ">=18"
380+ }
381+ },
382+ "node_modules/@esbuild/openbsd-x64": {
383+ "version": "0.28.1",
384+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
385+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
386+ "cpu": [
387+ "x64"
388+ ],
389+ "dev": true,
390+ "license": "MIT",
391+ "optional": true,
392+ "os": [
393+ "openbsd"
394+ ],
395+ "engines": {
396+ "node": ">=18"
397+ }
398+ },
399+ "node_modules/@esbuild/openharmony-arm64": {
400+ "version": "0.28.1",
401+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
402+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
403+ "cpu": [
404+ "arm64"
405+ ],
406+ "dev": true,
407+ "license": "MIT",
408+ "optional": true,
409+ "os": [
410+ "openharmony"
411+ ],
412+ "engines": {
413+ "node": ">=18"
414+ }
415+ },
416+ "node_modules/@esbuild/sunos-x64": {
417+ "version": "0.28.1",
418+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
419+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
420+ "cpu": [
421+ "x64"
422+ ],
423+ "dev": true,
424+ "license": "MIT",
425+ "optional": true,
426+ "os": [
427+ "sunos"
428+ ],
429+ "engines": {
430+ "node": ">=18"
431+ }
432+ },
433+ "node_modules/@esbuild/win32-arm64": {
434+ "version": "0.28.1",
435+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
436+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
437+ "cpu": [
438+ "arm64"
439+ ],
440+ "dev": true,
441+ "license": "MIT",
442+ "optional": true,
443+ "os": [
444+ "win32"
445+ ],
446+ "engines": {
447+ "node": ">=18"
448+ }
449+ },
450+ "node_modules/@esbuild/win32-ia32": {
451+ "version": "0.28.1",
452+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
453+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
454+ "cpu": [
455+ "ia32"
456+ ],
457+ "dev": true,
458+ "license": "MIT",
459+ "optional": true,
460+ "os": [
461+ "win32"
462+ ],
463+ "engines": {
464+ "node": ">=18"
465+ }
466+ },
467+ "node_modules/@esbuild/win32-x64": {
468+ "version": "0.28.1",
469+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
470+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
471+ "cpu": [
472+ "x64"
473+ ],
474+ "dev": true,
475+ "license": "MIT",
476+ "optional": true,
477+ "os": [
478+ "win32"
479+ ],
480+ "engines": {
481+ "node": ">=18"
482+ }
483+ },
484+ "node_modules/@rollup/rollup-android-arm-eabi": {
485+ "version": "4.62.2",
486+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
487+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
488+ "cpu": [
489+ "arm"
490+ ],
491+ "dev": true,
492+ "license": "MIT",
493+ "optional": true,
494+ "os": [
495+ "android"
496+ ]
497+ },
498+ "node_modules/@rollup/rollup-android-arm64": {
499+ "version": "4.62.2",
500+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
501+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
502+ "cpu": [
503+ "arm64"
504+ ],
505+ "dev": true,
506+ "license": "MIT",
507+ "optional": true,
508+ "os": [
509+ "android"
510+ ]
511+ },
512+ "node_modules/@rollup/rollup-darwin-arm64": {
513+ "version": "4.62.2",
514+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
515+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
516+ "cpu": [
517+ "arm64"
518+ ],
519+ "dev": true,
520+ "license": "MIT",
521+ "optional": true,
522+ "os": [
523+ "darwin"
524+ ]
525+ },
526+ "node_modules/@rollup/rollup-darwin-x64": {
527+ "version": "4.62.2",
528+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
529+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
530+ "cpu": [
531+ "x64"
532+ ],
533+ "dev": true,
534+ "license": "MIT",
535+ "optional": true,
536+ "os": [
537+ "darwin"
538+ ]
539+ },
540+ "node_modules/@rollup/rollup-freebsd-arm64": {
541+ "version": "4.62.2",
542+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
543+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
544+ "cpu": [
545+ "arm64"
546+ ],
547+ "dev": true,
548+ "license": "MIT",
549+ "optional": true,
550+ "os": [
551+ "freebsd"
552+ ]
553+ },
554+ "node_modules/@rollup/rollup-freebsd-x64": {
555+ "version": "4.62.2",
556+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
557+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
558+ "cpu": [
559+ "x64"
560+ ],
561+ "dev": true,
562+ "license": "MIT",
563+ "optional": true,
564+ "os": [
565+ "freebsd"
566+ ]
567+ },
568+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
569+ "version": "4.62.2",
570+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
571+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
572+ "cpu": [
573+ "arm"
574+ ],
575+ "dev": true,
576+ "libc": [
577+ "glibc"
578+ ],
579+ "license": "MIT",
580+ "optional": true,
581+ "os": [
582+ "linux"
583+ ]
584+ },
585+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
586+ "version": "4.62.2",
587+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
588+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
589+ "cpu": [
590+ "arm"
591+ ],
592+ "dev": true,
593+ "libc": [
594+ "musl"
595+ ],
596+ "license": "MIT",
597+ "optional": true,
598+ "os": [
599+ "linux"
600+ ]
601+ },
602+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
603+ "version": "4.62.2",
604+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
605+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
606+ "cpu": [
607+ "arm64"
608+ ],
609+ "dev": true,
610+ "libc": [
611+ "glibc"
612+ ],
613+ "license": "MIT",
614+ "optional": true,
615+ "os": [
616+ "linux"
617+ ]
618+ },
619+ "node_modules/@rollup/rollup-linux-arm64-musl": {
620+ "version": "4.62.2",
621+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
622+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
623+ "cpu": [
624+ "arm64"
625+ ],
626+ "dev": true,
627+ "libc": [
628+ "musl"
629+ ],
630+ "license": "MIT",
631+ "optional": true,
632+ "os": [
633+ "linux"
634+ ]
635+ },
636+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
637+ "version": "4.62.2",
638+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
639+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
640+ "cpu": [
641+ "loong64"
642+ ],
643+ "dev": true,
644+ "libc": [
645+ "glibc"
646+ ],
647+ "license": "MIT",
648+ "optional": true,
649+ "os": [
650+ "linux"
651+ ]
652+ },
653+ "node_modules/@rollup/rollup-linux-loong64-musl": {
654+ "version": "4.62.2",
655+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
656+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
657+ "cpu": [
658+ "loong64"
659+ ],
660+ "dev": true,
661+ "libc": [
662+ "musl"
663+ ],
664+ "license": "MIT",
665+ "optional": true,
666+ "os": [
667+ "linux"
668+ ]
669+ },
670+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
671+ "version": "4.62.2",
672+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
673+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
674+ "cpu": [
675+ "ppc64"
676+ ],
677+ "dev": true,
678+ "libc": [
679+ "glibc"
680+ ],
681+ "license": "MIT",
682+ "optional": true,
683+ "os": [
684+ "linux"
685+ ]
686+ },
687+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
688+ "version": "4.62.2",
689+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
690+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
691+ "cpu": [
692+ "ppc64"
693+ ],
694+ "dev": true,
695+ "libc": [
696+ "musl"
697+ ],
698+ "license": "MIT",
699+ "optional": true,
700+ "os": [
701+ "linux"
702+ ]
703+ },
704+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
705+ "version": "4.62.2",
706+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
707+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
708+ "cpu": [
709+ "riscv64"
710+ ],
711+ "dev": true,
712+ "libc": [
713+ "glibc"
714+ ],
715+ "license": "MIT",
716+ "optional": true,
717+ "os": [
718+ "linux"
719+ ]
720+ },
721+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
722+ "version": "4.62.2",
723+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
724+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
725+ "cpu": [
726+ "riscv64"
727+ ],
728+ "dev": true,
729+ "libc": [
730+ "musl"
731+ ],
732+ "license": "MIT",
733+ "optional": true,
734+ "os": [
735+ "linux"
736+ ]
737+ },
738+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
739+ "version": "4.62.2",
740+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
741+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
742+ "cpu": [
743+ "s390x"
744+ ],
745+ "dev": true,
746+ "libc": [
747+ "glibc"
748+ ],
749+ "license": "MIT",
750+ "optional": true,
751+ "os": [
752+ "linux"
753+ ]
754+ },
755+ "node_modules/@rollup/rollup-linux-x64-gnu": {
756+ "version": "4.62.2",
757+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
758+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
759+ "cpu": [
760+ "x64"
761+ ],
762+ "dev": true,
763+ "libc": [
764+ "glibc"
765+ ],
766+ "license": "MIT",
767+ "optional": true,
768+ "os": [
769+ "linux"
770+ ]
771+ },
772+ "node_modules/@rollup/rollup-linux-x64-musl": {
773+ "version": "4.62.2",
774+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
775+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
776+ "cpu": [
777+ "x64"
778+ ],
779+ "dev": true,
780+ "libc": [
781+ "musl"
782+ ],
783+ "license": "MIT",
784+ "optional": true,
785+ "os": [
786+ "linux"
787+ ]
788+ },
789+ "node_modules/@rollup/rollup-openbsd-x64": {
790+ "version": "4.62.2",
791+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
792+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
793+ "cpu": [
794+ "x64"
795+ ],
796+ "dev": true,
797+ "license": "MIT",
798+ "optional": true,
799+ "os": [
800+ "openbsd"
801+ ]
802+ },
803+ "node_modules/@rollup/rollup-openharmony-arm64": {
804+ "version": "4.62.2",
805+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
806+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
807+ "cpu": [
808+ "arm64"
809+ ],
810+ "dev": true,
811+ "license": "MIT",
812+ "optional": true,
813+ "os": [
814+ "openharmony"
815+ ]
816+ },
817+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
818+ "version": "4.62.2",
819+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
820+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
821+ "cpu": [
822+ "arm64"
823+ ],
824+ "dev": true,
825+ "license": "MIT",
826+ "optional": true,
827+ "os": [
828+ "win32"
829+ ]
830+ },
831+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
832+ "version": "4.62.2",
833+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
834+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
835+ "cpu": [
836+ "ia32"
837+ ],
838+ "dev": true,
839+ "license": "MIT",
840+ "optional": true,
841+ "os": [
842+ "win32"
843+ ]
844+ },
845+ "node_modules/@rollup/rollup-win32-x64-gnu": {
846+ "version": "4.62.2",
847+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
848+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
849+ "cpu": [
850+ "x64"
851+ ],
852+ "dev": true,
853+ "license": "MIT",
854+ "optional": true,
855+ "os": [
856+ "win32"
857+ ]
858+ },
859+ "node_modules/@rollup/rollup-win32-x64-msvc": {
860+ "version": "4.62.2",
861+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
862+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
863+ "cpu": [
864+ "x64"
865+ ],
866+ "dev": true,
867+ "license": "MIT",
868+ "optional": true,
869+ "os": [
870+ "win32"
871+ ]
872+ },
873+ "node_modules/@types/estree": {
874+ "version": "1.0.9",
875+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
876+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
877+ "dev": true,
878+ "license": "MIT"
879+ },
880+ "node_modules/charenc": {
881+ "version": "0.0.2",
882+ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
883+ "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
884+ "license": "BSD-3-Clause",
885+ "engines": {
886+ "node": "*"
887+ }
888+ },
889+ "node_modules/crypt": {
890+ "version": "0.0.2",
891+ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
892+ "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
893+ "license": "BSD-3-Clause",
894+ "engines": {
895+ "node": "*"
896+ }
897+ },
898+ "node_modules/esbuild": {
899+ "version": "0.28.1",
900+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
901+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
902+ "dev": true,
903+ "hasInstallScript": true,
904+ "license": "MIT",
905+ "bin": {
906+ "esbuild": "bin/esbuild"
907+ },
908+ "engines": {
909+ "node": ">=18"
910+ },
911+ "optionalDependencies": {
912+ "@esbuild/aix-ppc64": "0.28.1",
913+ "@esbuild/android-arm": "0.28.1",
914+ "@esbuild/android-arm64": "0.28.1",
915+ "@esbuild/android-x64": "0.28.1",
916+ "@esbuild/darwin-arm64": "0.28.1",
917+ "@esbuild/darwin-x64": "0.28.1",
918+ "@esbuild/freebsd-arm64": "0.28.1",
919+ "@esbuild/freebsd-x64": "0.28.1",
920+ "@esbuild/linux-arm": "0.28.1",
921+ "@esbuild/linux-arm64": "0.28.1",
922+ "@esbuild/linux-ia32": "0.28.1",
923+ "@esbuild/linux-loong64": "0.28.1",
924+ "@esbuild/linux-mips64el": "0.28.1",
925+ "@esbuild/linux-ppc64": "0.28.1",
926+ "@esbuild/linux-riscv64": "0.28.1",
927+ "@esbuild/linux-s390x": "0.28.1",
928+ "@esbuild/linux-x64": "0.28.1",
929+ "@esbuild/netbsd-arm64": "0.28.1",
930+ "@esbuild/netbsd-x64": "0.28.1",
931+ "@esbuild/openbsd-arm64": "0.28.1",
932+ "@esbuild/openbsd-x64": "0.28.1",
933+ "@esbuild/openharmony-arm64": "0.28.1",
934+ "@esbuild/sunos-x64": "0.28.1",
935+ "@esbuild/win32-arm64": "0.28.1",
936+ "@esbuild/win32-ia32": "0.28.1",
937+ "@esbuild/win32-x64": "0.28.1"
938+ }
939+ },
940+ "node_modules/fdir": {
941+ "version": "6.5.0",
942+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
943+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
944+ "dev": true,
945+ "license": "MIT",
946+ "engines": {
947+ "node": ">=12.0.0"
948+ },
949+ "peerDependencies": {
950+ "picomatch": "^3 || ^4"
951+ },
952+ "peerDependenciesMeta": {
953+ "picomatch": {
954+ "optional": true
955+ }
956+ }
957+ },
958+ "node_modules/fsevents": {
959+ "version": "2.3.3",
960+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
961+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
962+ "dev": true,
963+ "hasInstallScript": true,
964+ "license": "MIT",
965+ "optional": true,
966+ "os": [
967+ "darwin"
968+ ],
969+ "engines": {
970+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
971+ }
972+ },
973+ "node_modules/hasharray": {
974+ "version": "1.1.2",
975+ "resolved": "https://registry.npmjs.org/hasharray/-/hasharray-1.1.2.tgz",
976+ "integrity": "sha512-7w3idwaVXX9gL9LiTCBSNKRGTBcp2WI/kf13UYeZ9+trOGBHVYHei6qtMY6DVnwGOouVUSRg0+L2xf4Q2/CmzA==",
977+ "license": "MIT",
978+ "dependencies": {
979+ "jclass": "^1.0.1"
980+ }
981+ },
982+ "node_modules/is-buffer": {
983+ "version": "1.1.6",
984+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
985+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
986+ "license": "MIT"
987+ },
988+ "node_modules/jclass": {
989+ "version": "1.2.1",
990+ "resolved": "https://registry.npmjs.org/jclass/-/jclass-1.2.1.tgz",
991+ "integrity": "sha512-mRx8uv1qJLOtxbRf3IWOQIH2ro7VIPn6ZkhbTcUJvJEslLzYA7BSATXDi/GR1yKYV9DASsjTZL+0YJPdqSMznw==",
992+ "license": "MIT",
993+ "engines": {
994+ "node": ">= 0.6"
995+ }
996+ },
997+ "node_modules/mcmc-stats": {
998+ "version": "0.0.1",
999+ "resolved": "https://registry.npmjs.org/mcmc-stats/-/mcmc-stats-0.0.1.tgz",
1000+ "integrity": "sha512-lP9RYjQMwBsEvNEyb6aFB7ICryeZb1HRgXa0F8P3tJV7V7U+uV1IkmFO3debm9wSGiHGlnVG5eW1YbVIu8wDDw==",
1001+ "license": "BSD-3-Clause"
1002+ },
1003+ "node_modules/md5": {
1004+ "version": "2.3.0",
1005+ "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
1006+ "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
1007+ "license": "BSD-3-Clause",
1008+ "dependencies": {
1009+ "charenc": "0.0.2",
1010+ "crypt": "0.0.2",
1011+ "is-buffer": "~1.1.6"
1012+ }
1013+ },
1014+ "node_modules/minwebide": {
1015+ "resolved": "../minwebide",
1016+ "link": true
1017+ },
1018+ "node_modules/nanoid": {
1019+ "version": "3.3.15",
1020+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
1021+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
1022+ "dev": true,
1023+ "funding": [
1024+ {
1025+ "type": "github",
1026+ "url": "https://github.com/sponsors/ai"
1027+ }
1028+ ],
1029+ "license": "MIT",
1030+ "bin": {
1031+ "nanoid": "bin/nanoid.cjs"
1032+ },
1033+ "engines": {
1034+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1035+ }
1036+ },
1037+ "node_modules/picocolors": {
1038+ "version": "1.1.1",
1039+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1040+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1041+ "dev": true,
1042+ "license": "ISC"
1043+ },
1044+ "node_modules/picomatch": {
1045+ "version": "4.0.5",
1046+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
1047+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
1048+ "dev": true,
1049+ "license": "MIT",
1050+ "engines": {
1051+ "node": ">=12"
1052+ },
1053+ "funding": {
1054+ "url": "https://github.com/sponsors/jonschlinkert"
1055+ }
1056+ },
1057+ "node_modules/playwright": {
1058+ "version": "1.61.1",
1059+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
1060+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
1061+ "dev": true,
1062+ "license": "Apache-2.0",
1063+ "dependencies": {
1064+ "playwright-core": "1.61.1"
1065+ },
1066+ "bin": {
1067+ "playwright": "cli.js"
1068+ },
1069+ "engines": {
1070+ "node": ">=18"
1071+ },
1072+ "optionalDependencies": {
1073+ "fsevents": "2.3.2"
1074+ }
1075+ },
1076+ "node_modules/playwright-core": {
1077+ "version": "1.61.1",
1078+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
1079+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
1080+ "dev": true,
1081+ "license": "Apache-2.0",
1082+ "bin": {
1083+ "playwright-core": "cli.js"
1084+ },
1085+ "engines": {
1086+ "node": ">=18"
1087+ }
1088+ },
1089+ "node_modules/playwright/node_modules/fsevents": {
1090+ "version": "2.3.2",
1091+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1092+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1093+ "dev": true,
1094+ "hasInstallScript": true,
1095+ "license": "MIT",
1096+ "optional": true,
1097+ "os": [
1098+ "darwin"
1099+ ],
1100+ "engines": {
1101+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1102+ }
1103+ },
1104+ "node_modules/postcss": {
1105+ "version": "8.5.16",
1106+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
1107+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
1108+ "dev": true,
1109+ "funding": [
1110+ {
1111+ "type": "opencollective",
1112+ "url": "https://opencollective.com/postcss/"
1113+ },
1114+ {
1115+ "type": "tidelift",
1116+ "url": "https://tidelift.com/funding/github/npm/postcss"
1117+ },
1118+ {
1119+ "type": "github",
1120+ "url": "https://github.com/sponsors/ai"
1121+ }
1122+ ],
1123+ "license": "MIT",
1124+ "dependencies": {
1125+ "nanoid": "^3.3.12",
1126+ "picocolors": "^1.1.1",
1127+ "source-map-js": "^1.2.1"
1128+ },
1129+ "engines": {
1130+ "node": "^10 || ^12 || >=14"
1131+ }
1132+ },
1133+ "node_modules/rollup": {
1134+ "version": "4.62.2",
1135+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
1136+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
1137+ "dev": true,
1138+ "license": "MIT",
1139+ "dependencies": {
1140+ "@types/estree": "1.0.9"
1141+ },
1142+ "bin": {
1143+ "rollup": "dist/bin/rollup"
1144+ },
1145+ "engines": {
1146+ "node": ">=18.0.0",
1147+ "npm": ">=8.0.0"
1148+ },
1149+ "optionalDependencies": {
1150+ "@rollup/rollup-android-arm-eabi": "4.62.2",
1151+ "@rollup/rollup-android-arm64": "4.62.2",
1152+ "@rollup/rollup-darwin-arm64": "4.62.2",
1153+ "@rollup/rollup-darwin-x64": "4.62.2",
1154+ "@rollup/rollup-freebsd-arm64": "4.62.2",
1155+ "@rollup/rollup-freebsd-x64": "4.62.2",
1156+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
1157+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
1158+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
1159+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
1160+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
1161+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
1162+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
1163+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
1164+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
1165+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
1166+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
1167+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
1168+ "@rollup/rollup-linux-x64-musl": "4.62.2",
1169+ "@rollup/rollup-openbsd-x64": "4.62.2",
1170+ "@rollup/rollup-openharmony-arm64": "4.62.2",
1171+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
1172+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
1173+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
1174+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
1175+ "fsevents": "~2.3.2"
1176+ }
1177+ },
1178+ "node_modules/source-map-js": {
1179+ "version": "1.2.1",
1180+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1181+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1182+ "dev": true,
1183+ "license": "BSD-3-Clause",
1184+ "engines": {
1185+ "node": ">=0.10.0"
1186+ }
1187+ },
1188+ "node_modules/stan-language-server": {
1189+ "version": "0.4.10",
1190+ "resolved": "https://registry.npmjs.org/stan-language-server/-/stan-language-server-0.4.10.tgz",
1191+ "integrity": "sha512-LS4yxStkci7UENh8TjoN/2xRfMqMw2B9srghcHM3Px1hY/9Q38EjZspOGPGNYS0QpfgOUzEfPCw/A7eZ6+NEqg==",
1192+ "license": "MIT",
1193+ "dependencies": {
1194+ "stanc3": "2.39.1",
1195+ "trie-search": "2.2.1",
1196+ "vscode-languageserver": "9.0.1",
1197+ "vscode-languageserver-textdocument": "1.0.12",
1198+ "vscode-uri": "3.1.0"
1199+ },
1200+ "bin": {
1201+ "stan-language-server": "dist/server/cli.js"
1202+ },
1203+ "engines": {
1204+ "node": ">=18.0.0"
1205+ }
1206+ },
1207+ "node_modules/stanc3": {
1208+ "version": "2.39.1",
1209+ "resolved": "https://registry.npmjs.org/stanc3/-/stanc3-2.39.1.tgz",
1210+ "integrity": "sha512-Guyf/g9pmaJZjj6lg9YLURpsOlnf802YOaVe9KGgoACaY2bPiyb+cYQdI4kfMciIHKHLp8Y9VN5Oh2eUV0OQaw==",
1211+ "license": "BSD-3-Clause"
1212+ },
1213+ "node_modules/tinyglobby": {
1214+ "version": "0.2.17",
1215+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1216+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1217+ "dev": true,
1218+ "license": "MIT",
1219+ "dependencies": {
1220+ "fdir": "^6.5.0",
1221+ "picomatch": "^4.0.4"
1222+ },
1223+ "engines": {
1224+ "node": ">=12.0.0"
1225+ },
1226+ "funding": {
1227+ "url": "https://github.com/sponsors/SuperchupuDev"
1228+ }
1229+ },
1230+ "node_modules/tinystan": {
1231+ "version": "0.3.3",
1232+ "resolved": "https://registry.npmjs.org/tinystan/-/tinystan-0.3.3.tgz",
1233+ "integrity": "sha512-qRvhT8t86q6Ac/MTcwf3jEifM3JHqJTEzspvLkld1SRErtKqsydJ14iB1DGyeQK6hegEsv0DREYoehKu+S1PWg==",
1234+ "license": "BSD-3-Clause"
1235+ },
1236+ "node_modules/trie-search": {
1237+ "version": "2.2.1",
1238+ "resolved": "https://registry.npmjs.org/trie-search/-/trie-search-2.2.1.tgz",
1239+ "integrity": "sha512-JsHrc02RM4loPkL4ibKM3uGNSr1RWARb9oqyJAR87ZxRmW9xpU4INtuYVKBWwieFK8D0h0D+CGy5BkYXQNrvjw==",
1240+ "license": "MIT",
1241+ "dependencies": {
1242+ "hasharray": "^1.1.1",
1243+ "md5": "^2.3.0"
1244+ }
1245+ },
1246+ "node_modules/typescript": {
1247+ "version": "5.9.3",
1248+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1249+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1250+ "dev": true,
1251+ "license": "Apache-2.0",
1252+ "bin": {
1253+ "tsc": "bin/tsc",
1254+ "tsserver": "bin/tsserver"
1255+ },
1256+ "engines": {
1257+ "node": ">=14.17"
1258+ }
1259+ },
1260+ "node_modules/vite": {
1261+ "version": "7.3.6",
1262+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
1263+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
1264+ "dev": true,
1265+ "license": "MIT",
1266+ "dependencies": {
1267+ "esbuild": "^0.27.0 || ^0.28.0",
1268+ "fdir": "^6.5.0",
1269+ "picomatch": "^4.0.3",
1270+ "postcss": "^8.5.6",
1271+ "rollup": "^4.43.0",
1272+ "tinyglobby": "^0.2.15"
1273+ },
1274+ "bin": {
1275+ "vite": "bin/vite.js"
1276+ },
1277+ "engines": {
1278+ "node": "^20.19.0 || >=22.12.0"
1279+ },
1280+ "funding": {
1281+ "url": "https://github.com/vitejs/vite?sponsor=1"
1282+ },
1283+ "optionalDependencies": {
1284+ "fsevents": "~2.3.3"
1285+ },
1286+ "peerDependencies": {
1287+ "@types/node": "^20.19.0 || >=22.12.0",
1288+ "jiti": ">=1.21.0",
1289+ "less": "^4.0.0",
1290+ "lightningcss": "^1.21.0",
1291+ "sass": "^1.70.0",
1292+ "sass-embedded": "^1.70.0",
1293+ "stylus": ">=0.54.8",
1294+ "sugarss": "^5.0.0",
1295+ "terser": "^5.16.0",
1296+ "tsx": "^4.8.1",
1297+ "yaml": "^2.4.2"
1298+ },
1299+ "peerDependenciesMeta": {
1300+ "@types/node": {
1301+ "optional": true
1302+ },
1303+ "jiti": {
1304+ "optional": true
1305+ },
1306+ "less": {
1307+ "optional": true
1308+ },
1309+ "lightningcss": {
1310+ "optional": true
1311+ },
1312+ "sass": {
1313+ "optional": true
1314+ },
1315+ "sass-embedded": {
1316+ "optional": true
1317+ },
1318+ "stylus": {
1319+ "optional": true
1320+ },
1321+ "sugarss": {
1322+ "optional": true
1323+ },
1324+ "terser": {
1325+ "optional": true
1326+ },
1327+ "tsx": {
1328+ "optional": true
1329+ },
1330+ "yaml": {
1331+ "optional": true
1332+ }
1333+ }
1334+ },
1335+ "node_modules/vscode-jsonrpc": {
1336+ "version": "8.2.0",
1337+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
1338+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
1339+ "license": "MIT",
1340+ "engines": {
1341+ "node": ">=14.0.0"
1342+ }
1343+ },
1344+ "node_modules/vscode-languageserver": {
1345+ "version": "9.0.1",
1346+ "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
1347+ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
1348+ "license": "MIT",
1349+ "dependencies": {
1350+ "vscode-languageserver-protocol": "3.17.5"
1351+ },
1352+ "bin": {
1353+ "installServerIntoExtension": "bin/installServerIntoExtension"
1354+ }
1355+ },
1356+ "node_modules/vscode-languageserver-protocol": {
1357+ "version": "3.17.5",
1358+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
1359+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
1360+ "license": "MIT",
1361+ "dependencies": {
1362+ "vscode-jsonrpc": "8.2.0",
1363+ "vscode-languageserver-types": "3.17.5"
1364+ }
1365+ },
1366+ "node_modules/vscode-languageserver-textdocument": {
1367+ "version": "1.0.12",
1368+ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
1369+ "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
1370+ "license": "MIT"
1371+ },
1372+ "node_modules/vscode-languageserver-types": {
1373+ "version": "3.17.5",
1374+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
1375+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
1376+ "license": "MIT"
1377+ },
1378+ "node_modules/vscode-uri": {
1379+ "version": "3.1.0",
1380+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
1381+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
1382+ "license": "MIT"
1383+ },
1384+ "node_modules/yaml": {
1385+ "version": "2.9.0",
1386+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
1387+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
1388+ "license": "ISC",
1389+ "bin": {
1390+ "yaml": "bin.mjs"
1391+ },
1392+ "engines": {
1393+ "node": ">= 14.6"
1394+ },
1395+ "funding": {
1396+ "url": "https://github.com/sponsors/eemeli"
1397+ }
1398+ }
1399+ }
1400+}
package.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "name": "stan-web-ide",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "typecheck": "bash scripts/typecheck.sh",
11+ "smoke": "npm run build && node scripts/smoke.mjs"
12+ },
13+ "dependencies": {
14+ "mcmc-stats": "^0.0.1",
15+ "minwebide": "file:../minwebide",
16+ "stan-language-server": "^0.4.9",
17+ "tinystan": "^0.3.3",
18+ "vscode-languageserver": "^9.0.1",
19+ "yaml": "^2.8.0"
20+ },
21+ "devDependencies": {
22+ "playwright": "^1.61.1",
23+ "typescript": "^5.9.0",
24+ "vite": "^7.0.0"
25+ }
26+}
public/coi-serviceworker.jsadded+72−0View file
@@ -0,0 +1,72 @@
1+/*
2+ * Cross-Origin Isolation Service Worker
3+ *
4+ * Adds COOP/COEP headers to responses so that SharedArrayBuffer is available
5+ * on hosts that don't allow custom response headers (e.g. GitHub Pages).
6+ *
7+ * Based on https://github.com/niccokunzmann/coi-serviceworker (MIT).
8+ */
9+
10+/* global self, caches, fetch, Response, clients */
11+
12+if (typeof window === "undefined") {
13+ // --- Service Worker scope ---
14+ self.addEventListener("install", () => self.skipWaiting());
15+ self.addEventListener("activate", event =>
16+ event.waitUntil(self.clients.claim())
17+ );
18+
19+ self.addEventListener("fetch", event => {
20+ const request = event.request;
21+ if (request.cache === "only-if-cached" && request.mode !== "same-origin") {
22+ return; // Chrome bug workaround
23+ }
24+
25+ // Only add isolation headers to same-origin responses.
26+ // Wrapping cross-origin responses in a new Response strips CORS
27+ // internal flags, which breaks cross-origin fetch requests.
28+ if (new URL(request.url).origin !== self.location.origin) {
29+ return; // let the browser handle cross-origin requests normally
30+ }
31+
32+ event.respondWith(
33+ fetch(request).then(response => {
34+ if (response.status === 0) return response; // opaque response
35+
36+ const headers = new Headers(response.headers);
37+ // must match the value the dev/preview servers send — a document and
38+ // its dedicated workers with mismatched COEP values fail to load
39+ headers.set("Cross-Origin-Embedder-Policy", "require-corp");
40+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
41+
42+ return new Response(response.body, {
43+ status: response.status,
44+ statusText: response.statusText,
45+ headers,
46+ });
47+ })
48+ );
49+ });
50+} else {
51+ // --- Window scope (registration) ---
52+
53+ // Capture currentScript synchronously — it becomes null after script runs.
54+ const scriptUrl = document.currentScript && document.currentScript.src;
55+
56+ if (!window.crossOriginIsolated && navigator.serviceWorker) {
57+ navigator.serviceWorker.register(scriptUrl || "/coi-serviceworker.js").then(
58+ reg => {
59+ if (reg.installing || reg.waiting) {
60+ const sw = reg.installing || reg.waiting;
61+ sw.addEventListener("statechange", () => {
62+ if (sw.state === "activated") window.location.reload();
63+ });
64+ } else if (reg.active && !navigator.serviceWorker.controller) {
65+ // Active but not yet controlling — reload to let it intercept.
66+ window.location.reload();
67+ }
68+ },
69+ err => console.error("COI service worker registration failed:", err)
70+ );
71+ }
72+}
scripts/dev-check.mjsadded+48−0View file
@@ -0,0 +1,48 @@
1+// Quick dev-server sanity check (expects `npx vite` already running on 3000):
2+// project opens, LSP diagnostics work, a full run completes if a compile
3+// server is up. Not part of `npm run smoke`.
4+import { chromium } from 'playwright';
5+
6+const browser = await chromium.launch({ channel: 'chrome', headless: true });
7+const page = await browser.newPage({ viewport: { width: 1500, height: 900 } });
8+const errors = [];
9+page.on('pageerror', (e) => errors.push(e.message));
10+page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
11+const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
12+
13+try {
14+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'networkidle' });
15+ await page.waitForTimeout(2000);
16+ check('dev: cross-origin isolated', await page.evaluate(() => window.crossOriginIsolated));
17+ await page.getByRole('button', { name: 'New sample project' }).click();
18+ await page.waitForTimeout(2500);
19+ check('dev: form view opens', await page.locator('.sample-editor').count() >= 1);
20+
21+ // run first (the LSP check below leaves the model file edited)
22+ const haveServer = await fetch('http://localhost:8083/probe').then(r => r.ok).catch(() => false);
23+ if (haveServer) {
24+ await page.locator('.sample-run-button').click();
25+ let done = false;
26+ const start = Date.now();
27+ while (Date.now() - start < 360_000 && !done) {
28+ done = ((await page.locator('.mw-output').innerText()).replace(/\u00a0/g, ' ')).includes('sampling completed');
29+ if (!done) await page.waitForTimeout(400);
30+ }
31+ check('dev: full run completes', done);
32+ }
33+
34+ await page.locator('.mw-explorer-item-label').filter({ hasText: /^linear\.stan$/ }).click();
35+ await page.waitForTimeout(4000);
36+ await page.locator('.view-lines').first().click();
37+ await page.keyboard.press('Control+End');
38+ await page.keyboard.type('\nbroken');
39+ let sawMarker = false;
40+ for (let i = 0; i < 60 && !sawMarker; i++) {
41+ await page.waitForTimeout(250);
42+ sawMarker = await page.locator('.squiggly-error').count() > 0;
43+ }
44+ check('dev: LSP diagnostics', sawMarker);
45+ console.log(errors.length ? 'page errors:\n ' + errors.slice(0, 6).join('\n ') : 'no page errors');
46+} finally {
47+ await browser.close();
48+}
scripts/smoke.mjsadded+172−0View file
@@ -0,0 +1,172 @@
1+// End-to-end smoke test: landing → sample project → .sample form view,
2+// Stan LSP diagnostics, server status. When a compile server is reachable
3+// at http://localhost:8083 (e.g. the stan-wasm-server docker image), also
4+// compiles + samples for real and checks the output files.
5+import { chromium } from 'playwright';
6+import { spawn } from 'node:child_process';
7+
8+const root = new URL('..', import.meta.url).pathname;
9+const out = process.argv[2] ?? '.';
10+const previewProc = spawn('npx', ['vite', 'preview', '--port', '4173', '--strictPort'], { stdio: 'ignore', cwd: root });
11+for (let i = 0; i < 60; i++) {
12+ const up = await fetch('http://127.0.0.1:4173/').then(r => r.ok).catch(() => false);
13+ if (up) break;
14+ await new Promise((r) => setTimeout(r, 500));
15+}
16+
17+const serverUrl = 'http://localhost:8083';
18+const haveServer = await fetch(`${serverUrl}/probe`).then(r => r.ok).catch(() => false);
19+console.log(haveServer ? `compile server detected at ${serverUrl} — running full e2e` : 'no compile server — UI checks only');
20+
21+const browser = await chromium.launch({ channel: 'chrome', headless: true });
22+const page = await browser.newPage({ viewport: { width: 1500, height: 900 } });
23+const errors = [];
24+page.on('pageerror', (e) => errors.push(e.message));
25+page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
26+const explorerItem = (name) => page.locator('.mw-explorer-item-label').filter({ hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) });
27+const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
28+// monaco renders spaces as U+00A0 — normalize before matching
29+const normalize = (text) => text.replace(/\u00a0/g, ' ');
30+const outputText = async () => normalize(await page.locator('.mw-output').innerText());
31+const waitForOutput = async (needle, timeout = 20000) => {
32+ const start = Date.now();
33+ while (Date.now() - start < timeout) {
34+ if ((await outputText()).includes(needle)) return true;
35+ await page.waitForTimeout(250);
36+ }
37+ return false;
38+};
39+
40+try {
41+ // use 127.0.0.1: the compile server's CORS allowlist matches that origin
42+ await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle' });
43+ await page.waitForTimeout(1200);
44+ check('landing renders', await page.locator('.landing-empty').count() === 1);
45+ await page.screenshot({ path: out + '/s-landing.png' });
46+
47+ // sample project → fit.sample opens as the form view
48+ await page.getByRole('button', { name: 'New sample project' }).click();
49+ await page.waitForTimeout(1800);
50+ check('URL has project route', /#\/project\/[a-z0-9]+/i.test(page.url()));
51+ check('fit.sample opens as form', await page.locator('.sample-editor h2', { hasText: 'fit.sample' }).count() === 1);
52+ check('form: stan file selected', await page.locator('.sample-editor select').first().inputValue() === 'linear.stan');
53+ check('form: num_chains = 4', await page.locator('.sample-params input').first().inputValue() === '4');
54+ check('run button enabled', await page.locator('.sample-run-button').isEnabled());
55+ await page.screenshot({ path: out + '/s-form.png' });
56+
57+ // server status bar item
58+ await page.waitForTimeout(1500);
59+ const statusText = normalize(await page.locator('.mw-statusbar').innerText());
60+ check('server status item shows', statusText.includes('Stan server:'));
61+ check(`server status is ${haveServer ? 'connected' : 'offline'}`, statusText.includes(haveServer ? 'connected' : 'offline'));
62+
63+ // Stan editor: highlighting + LSP diagnostics
64+ await explorerItem('linear.stan').click();
65+ await page.waitForTimeout(2500); // language server warm-up
66+ check('stan file opens in text editor', await page.locator('.view-lines').count() >= 1);
67+ check('no error markers on valid model', await page.locator('.squiggly-error').count() === 0);
68+ // introduce a syntax error and expect a marker
69+ await page.locator('.view-lines').first().click();
70+ await page.keyboard.press('Control+End');
71+ await page.keyboard.type('\nbroken');
72+ let sawMarker = false;
73+ for (let i = 0; i < 40 && !sawMarker; i++) {
74+ await page.waitForTimeout(250);
75+ sawMarker = await page.locator('.squiggly-error').count() > 0;
76+ }
77+ check('LSP reports syntax error', sawMarker);
78+ await page.screenshot({ path: out + '/s-lsp.png' });
79+ // revert
80+ for (let i = 0; i < 7; i++) await page.keyboard.press('Control+z');
81+ await page.waitForTimeout(1200);
82+ check('marker clears after undo', await page.locator('.squiggly-error').count() === 0);
83+
84+ if (!haveServer) {
85+ // run without a server → helpful failure in the form status
86+ await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
87+ await page.waitForTimeout(400);
88+ await page.locator('.sample-run-button').click();
89+ let failed = false;
90+ for (let i = 0; i < 40 && !failed; i++) {
91+ await page.waitForTimeout(250);
92+ failed = await page.locator('.sample-run-status.error').count() === 1;
93+ }
94+ check('run without server fails with message', failed);
95+ await page.screenshot({ path: out + '/s-noserver.png' });
96+ } else {
97+ // ---- full e2e: compile + sample fit.sample ----
98+ await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
99+ await page.waitForTimeout(400);
100+ await page.locator('.sample-run-button').click();
101+ // progress bars should appear while sampling (4 chains)
102+ let sawBars = 0;
103+ const start = Date.now();
104+ let done = false;
105+ while (Date.now() - start < 360_000 && !done) {
106+ sawBars = Math.max(sawBars, await page.locator('.sample-chain').count());
107+ done = (await outputText()).includes('sampling completed');
108+ if (!done) await page.waitForTimeout(300);
109+ }
110+ check('fit.sample sampling completed', done);
111+ check('per-chain progress bars shown (4)', sawBars === 4);
112+ await page.screenshot({ path: out + '/s-run-done.png' });
113+
114+ // output files in the explorer
115+ await page.waitForTimeout(800);
116+ await explorerItem('out').click();
117+ await page.waitForTimeout(400);
118+ await explorerItem('fit').click();
119+ await page.waitForTimeout(400);
120+ check('chain_1.csv written', await explorerItem('chain_1.csv').count() === 1);
121+ check('summary.csv written', await explorerItem('summary.csv').count() === 1);
122+ await explorerItem('summary.csv').click();
123+ await page.waitForTimeout(800);
124+ const summary = normalize(await page.locator('.view-lines').first().innerText());
125+ check('summary has beta row', summary.includes('beta'));
126+ await page.screenshot({ path: out + '/s-summary.png' });
127+
128+ // form edit round-trip: bump quick.sample's num_samples, run, check
129+ // the recorded sampling_opts.json (proves form → YAML → runner)
130+ await explorerItem('quick.sample').click();
131+ await page.waitForTimeout(600);
132+ // scope to the visible pane: the fit.sample form stays in the DOM
133+ const quickForm = page.locator('.sample-editor:visible');
134+ const samplesInput = quickForm.locator('.sample-params input').nth(2);
135+ await samplesInput.fill('150');
136+ await samplesInput.blur();
137+ await page.waitForTimeout(300);
138+ check('form edit marks tab dirty', await page.locator('.mw-tab.dirty').count() >= 1);
139+ await quickForm.locator('.sample-run-button').click();
140+ check('quick.sample sampling completed', await waitForOutput('files to /out/quick', 120_000));
141+ await page.waitForTimeout(800);
142+ await explorerItem('quick').click();
143+ await page.waitForTimeout(400);
144+ // both out/fit and out/quick hold one; /out/quick sorts last
145+ await explorerItem('sampling_opts.json').last().click();
146+ await page.waitForTimeout(800);
147+ const opts = normalize(await page.locator('.view-lines').first().innerText());
148+ check('sampling_opts records form-edited num_samples', opts.includes('"num_samples": 150'));
149+ await page.screenshot({ path: out + '/s-opts.png' });
150+ }
151+
152+ // project lifecycle basics
153+ await page.getByTitle('Back to projects').click();
154+ await page.waitForTimeout(800);
155+ check('back on landing', await page.locator('.landing-project').count() === 1);
156+ await page.getByRole('button', { name: 'New project', exact: true }).click();
157+ await page.waitForTimeout(1500);
158+ check('empty project opens fit.sample form', await page.locator('.sample-editor').count() === 1);
159+ await page.goto('http://127.0.0.1:4173/#/project/nope1234', { waitUntil: 'networkidle' });
160+ await page.waitForTimeout(800);
161+ check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
162+
163+ if (errors.length) {
164+ console.log('page errors:');
165+ for (const e of errors.slice(0, 10)) console.log(' ' + e);
166+ } else {
167+ console.log('no page errors');
168+ }
169+} finally {
170+ await browser.close();
171+ previewProc.kill();
172+}
scripts/typecheck.shadded+21−0View file
@@ -0,0 +1,21 @@
1+#!/usr/bin/env bash
2+# Typecheck the app. Diagnostics inside the minwebide vendor tree (VS Code
3+# source) are reported as a count only — they stem from TS-version and
4+# ambient-type differences with VS Code's own build and never affect the
5+# bundle. Errors in the app's own code fail the check.
6+set -uo pipefail
7+
8+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9+OUTPUT="$(cd "$ROOT" && npx tsc --noEmit --pretty false 2>&1)"
10+
11+VENDOR_COUNT="$(printf '%s\n' "$OUTPUT" | grep -cE '/vendor/vscode/' || true)"
12+OURS="$(printf '%s\n' "$OUTPUT" | grep -vE '/vendor/vscode/|^ ' | grep -v '^$' || true)"
13+
14+if [ -n "$VENDOR_COUNT" ] && [ "$VENDOR_COUNT" != "0" ]; then
15+ echo "note: $VENDOR_COUNT vendor diagnostics suppressed (run 'npx tsc --noEmit' to see them)"
16+fi
17+if [ -n "$OURS" ]; then
18+ printf '%s\n' "$OURS"
19+ exit 1
20+fi
21+echo "typecheck OK"
src/ide.tsadded+73−0View file
@@ -0,0 +1,73 @@
1+import { createWorkbench, type WorkbenchTheme } from 'minwebide';
2+import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
3+import { createStanRunner } from './stan/runner';
4+import { createSampleEditorProvider } from './stan/sampleEditor';
5+import { showServerDialog } from './stan/serverDialog';
6+import { getServerUrl, onDidChangeServerUrl, probeServer } from './stan/settings';
7+
8+/** Opens the IDE for a project. Returns a disposable view. */
9+export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
10+ touchProject(project.id);
11+ document.title = `${project.name} — stan web IDE`;
12+
13+ const fs = await openProjectFileSystem(project.id);
14+ const stan = createStanRunner(fs);
15+
16+ const workbench = createWorkbench(container, {
17+ fileSystem: fs,
18+ theme,
19+ workspaceName: project.name,
20+ });
21+ workbench.registerRunner(stan.runner);
22+ workbench.registerCustomEditor(createSampleEditorProvider(fs, workbench, { stop: stan.stop }));
23+
24+ // the project indicator: click to go back to the project list
25+ workbench.statusBar.setItem('project', 'left', project.name, {
26+ icon: 'folder-opened',
27+ title: 'Back to projects',
28+ onClick: () => { location.hash = '#/'; },
29+ });
30+ // replace the default branding item with the project indicator
31+ workbench.statusBar.removeItem('branding');
32+
33+ // compile-server status: shows connectivity, click to change the URL
34+ let disposed = false;
35+ const refreshServerItem = async () => {
36+ const url = getServerUrl();
37+ workbench.statusBar.setItem('stan-server', 'right', 'Stan server: checking...', {
38+ icon: 'server',
39+ title: `${url}\nClick to change the compilation server`,
40+ onClick: () => showServerDialog(container),
41+ });
42+ const ok = await probeServer(url);
43+ if (disposed || url !== getServerUrl()) {
44+ return;
45+ }
46+ workbench.statusBar.setItem('stan-server', 'right', `Stan server: ${ok ? 'connected' : 'offline'}`, {
47+ icon: ok ? 'server' : 'warning',
48+ title: `${url} — ${ok ? 'connected' : 'not reachable'}\nClick to change the compilation server`,
49+ onClick: () => showServerDialog(container),
50+ });
51+ };
52+ void refreshServerItem();
53+ const serverListener = onDidChangeServerUrl(() => void refreshServerItem());
54+
55+ // open the most useful starting file
56+ for (const path of ['/fit.sample', '/main.stan', '/README.md']) {
57+ const uri = fs.root.with({ path });
58+ if (await fs.fileService.exists(uri)) {
59+ await workbench.openFile(uri);
60+ break;
61+ }
62+ }
63+
64+ return {
65+ dispose() {
66+ disposed = true;
67+ serverListener.dispose();
68+ stan.dispose();
69+ workbench.dispose();
70+ fs.dispose();
71+ },
72+ };
73+}
src/landing.cssadded+157−0View file
@@ -0,0 +1,157 @@
1+/* Project-picker landing page, themed with the same --vscode-* variables as
2+ * the workbench (in the spirit of VS Code's welcome page). */
3+
4+.landing {
5+ height: 100%;
6+ overflow-y: auto;
7+ background-color: var(--vscode-editor-background);
8+ color: var(--vscode-foreground);
9+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10+ font-size: 14px;
11+}
12+
13+.landing-inner {
14+ max-width: 720px;
15+ margin: 0 auto;
16+ padding: 48px 32px 64px;
17+}
18+
19+.landing-header h1 {
20+ margin: 0 0 6px;
21+ font-size: 34px;
22+ font-weight: 300;
23+ letter-spacing: 0.5px;
24+}
25+
26+.landing-subtitle {
27+ margin: 0 0 4px;
28+ color: var(--vscode-descriptionForeground);
29+}
30+
31+.landing-links {
32+ margin: 0;
33+ color: var(--vscode-descriptionForeground);
34+ font-size: 13px;
35+}
36+
37+.landing-link {
38+ color: var(--vscode-textLink-foreground);
39+ text-decoration: none;
40+ font-size: 13px;
41+}
42+
43+.landing-link:hover {
44+ text-decoration: underline;
45+}
46+
47+.landing-section {
48+ margin-top: 36px;
49+}
50+
51+.landing-section h2 {
52+ margin: 0 0 12px;
53+ font-size: 18px;
54+ font-weight: 400;
55+ border-bottom: 1px solid var(--vscode-panel-border);
56+ padding-bottom: 6px;
57+}
58+
59+.landing-start {
60+ display: flex;
61+ gap: 10px;
62+}
63+
64+.landing-button {
65+ padding: 6px 14px;
66+ font-size: 13px;
67+ font-family: inherit;
68+ cursor: pointer;
69+ border-radius: 3px;
70+ border: 1px solid var(--vscode-button-border, transparent);
71+ background-color: var(--vscode-button-secondaryBackground);
72+ color: var(--vscode-button-secondaryForeground);
73+}
74+
75+.landing-button:hover {
76+ background-color: var(--vscode-button-secondaryHoverBackground);
77+}
78+
79+.landing-button.primary {
80+ background-color: var(--vscode-button-background);
81+ color: var(--vscode-button-foreground);
82+}
83+
84+.landing-button.primary:hover {
85+ background-color: var(--vscode-button-hoverBackground);
86+}
87+
88+.landing-button:disabled {
89+ opacity: 0.6;
90+ cursor: default;
91+}
92+
93+.landing-projects {
94+ display: flex;
95+ flex-direction: column;
96+}
97+
98+.landing-empty {
99+ color: var(--vscode-descriptionForeground);
100+ padding: 8px 0;
101+}
102+
103+.landing-project {
104+ display: flex;
105+ align-items: center;
106+ gap: 12px;
107+ padding: 7px 8px;
108+ border-radius: 4px;
109+}
110+
111+.landing-project:hover {
112+ background-color: var(--vscode-list-hoverBackground);
113+}
114+
115+.landing-project-name {
116+ color: var(--vscode-textLink-foreground);
117+ text-decoration: none;
118+ font-size: 14px;
119+ overflow: hidden;
120+ text-overflow: ellipsis;
121+ white-space: nowrap;
122+}
123+
124+.landing-project-name:hover {
125+ text-decoration: underline;
126+}
127+
128+.landing-project-meta {
129+ color: var(--vscode-descriptionForeground);
130+ font-size: 12px;
131+ flex: 1;
132+}
133+
134+.landing-project-actions {
135+ display: flex;
136+ gap: 2px;
137+ visibility: hidden;
138+}
139+
140+.landing-project:hover .landing-project-actions {
141+ visibility: visible;
142+}
143+
144+.landing-action {
145+ background: none;
146+ border: none;
147+ padding: 2px 7px;
148+ font-size: 12px;
149+ font-family: inherit;
150+ cursor: pointer;
151+ border-radius: 3px;
152+ color: var(--vscode-textLink-foreground);
153+}
154+
155+.landing-action:hover {
156+ background-color: var(--vscode-toolbar-hoverBackground);
157+}
src/landing.tsadded+157−0View file
@@ -0,0 +1,157 @@
1+import { applyThemeToElement, type WorkbenchTheme } from 'minwebide';
2+import { createProject, deleteProject, duplicateProject, listProjects, nextUntitledName, openProjectFileSystem, renameProject, type ProjectInfo } from './projects';
3+import { emptyWorkspace, sampleWorkspace } from './sampleWorkspace';
4+import './landing.css';
5+
6+function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
7+ const node = document.createElement(tag);
8+ if (className) {
9+ node.className = className;
10+ }
11+ if (text !== undefined) {
12+ node.textContent = text;
13+ }
14+ return node;
15+}
16+
17+function openProject(id: string): void {
18+ location.hash = `#/project/${id}`;
19+}
20+
21+async function createSampleProject(): Promise<ProjectInfo> {
22+ const project = createProject(nextUntitledName('sample'));
23+ const fs = await openProjectFileSystem(project.id);
24+ try {
25+ await fs.seed(sampleWorkspace);
26+ } finally {
27+ fs.dispose();
28+ }
29+ return project;
30+}
31+
32+async function createEmptyProject(): Promise<ProjectInfo> {
33+ const project = createProject(nextUntitledName());
34+ const fs = await openProjectFileSystem(project.id);
35+ try {
36+ await fs.seed(emptyWorkspace);
37+ } finally {
38+ fs.dispose();
39+ }
40+ return project;
41+}
42+
43+function formatWhen(timestamp: number): string {
44+ const delta = Date.now() - timestamp;
45+ if (delta < 60_000) {
46+ return 'just now';
47+ }
48+ if (delta < 3_600_000) {
49+ return `${Math.round(delta / 60_000)}m ago`;
50+ }
51+ if (delta < 86_400_000) {
52+ return `${Math.round(delta / 3_600_000)}h ago`;
53+ }
54+ return new Date(timestamp).toLocaleDateString();
55+}
56+
57+/** Renders the project-picker landing page. Returns a disposable view. */
58+export function renderLanding(container: HTMLElement, theme: WorkbenchTheme): { dispose(): void } {
59+ const root = el('div', 'landing');
60+ applyThemeToElement(theme, root);
61+ container.appendChild(root);
62+ document.title = 'stan web IDE';
63+
64+ const inner = el('div', 'landing-inner');
65+ root.appendChild(inner);
66+
67+ const header = el('header', 'landing-header');
68+ header.appendChild(el('h1', undefined, 'stan web IDE'));
69+ header.appendChild(el('p', 'landing-subtitle', 'Run Stan sampling in your browser. Projects are stored locally, in your browser.'));
70+ const links = el('p', 'landing-links');
71+ const stanLink = el('a', 'landing-link', 'mc-stan.org');
72+ stanLink.href = 'https://mc-stan.org';
73+ const spLink = el('a', 'landing-link', 'stan-playground');
74+ spLink.href = 'https://stan-playground.flatironinstitute.org';
75+ const ghLink = el('a', 'landing-link', 'github.com/concept-collection/stan-web-ide');
76+ ghLink.href = 'https://github.com/concept-collection/stan-web-ide';
77+ links.append(stanLink, ' · ', spLink, ' · ', ghLink);
78+ header.appendChild(links);
79+ inner.appendChild(header);
80+
81+ // start section
82+ const start = el('section', 'landing-section');
83+ start.appendChild(el('h2', undefined, 'Start'));
84+ const startButtons = el('div', 'landing-start');
85+ const newButton = el('button', 'landing-button primary', 'New project');
86+ newButton.addEventListener('click', async () => {
87+ newButton.disabled = true;
88+ openProject((await createEmptyProject()).id);
89+ });
90+ const sampleButton = el('button', 'landing-button', 'New sample project');
91+ sampleButton.title = 'Seeded with a linear-regression model, data, and ready-to-run .sample configs';
92+ sampleButton.addEventListener('click', async () => {
93+ sampleButton.disabled = true;
94+ openProject((await createSampleProject()).id);
95+ });
96+ startButtons.append(newButton, sampleButton);
97+ start.appendChild(startButtons);
98+ inner.appendChild(start);
99+
100+ // projects section
101+ const section = el('section', 'landing-section');
102+ section.appendChild(el('h2', undefined, 'Projects'));
103+ const list = el('div', 'landing-projects');
104+ section.appendChild(list);
105+ inner.appendChild(section);
106+
107+ const renderList = () => {
108+ list.textContent = '';
109+ const projects = listProjects();
110+ if (projects.length === 0) {
111+ list.appendChild(el('div', 'landing-empty', 'No projects yet.'));
112+ return;
113+ }
114+ for (const project of projects) {
115+ const row = el('div', 'landing-project');
116+
117+ const name = el('a', 'landing-project-name', project.name);
118+ name.href = `#/project/${project.id}`;
119+ row.appendChild(name);
120+
121+ row.appendChild(el('span', 'landing-project-meta', `opened ${formatWhen(project.lastOpenedAt)}`));
122+
123+ const actions = el('span', 'landing-project-actions');
124+ const action = (label: string, handler: () => void | Promise<void>) => {
125+ const button = el('button', 'landing-action', label);
126+ button.addEventListener('click', () => handler());
127+ actions.appendChild(button);
128+ };
129+ action('Rename', () => {
130+ const name = prompt('Project name', project.name);
131+ if (name !== null) {
132+ renameProject(project.id, name);
133+ renderList();
134+ }
135+ });
136+ action('Duplicate', async () => {
137+ await duplicateProject(project.id);
138+ renderList();
139+ });
140+ action('Delete', async () => {
141+ if (confirm(`Delete project "${project.name}" and all of its files?`)) {
142+ await deleteProject(project.id);
143+ renderList();
144+ }
145+ });
146+ row.appendChild(actions);
147+ list.appendChild(row);
148+ }
149+ };
150+ renderList();
151+
152+ return {
153+ dispose() {
154+ root.remove();
155+ },
156+ };
157+}
src/main.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+import { loadBuiltinTheme, registerBuiltinLanguages } from 'minwebide';
2+import { openIde } from './ide';
3+import { renderLanding } from './landing';
4+import { registerStanLanguage } from './stan/language';
5+import { registerStanLsp } from './stan/lsp';
6+import { getProject } from './projects';
7+
8+// Routes:
9+// #/ project picker (landing page)
10+// #/project/<id> the IDE, opened on that project's file system
11+
12+async function start(): Promise<void> {
13+ // dev never uses a service worker (isolation comes from server headers) —
14+ // unregister any stale coi-serviceworker left over from a production
15+ // build or an earlier version, since a controlling stale SW can block
16+ // module workers with mismatched COEP headers
17+ if (import.meta.env.DEV && 'serviceWorker' in navigator) {
18+ const registrations = await navigator.serviceWorker.getRegistrations();
19+ if (registrations.length > 0) {
20+ await Promise.all(registrations.map(r => r.unregister()));
21+ if (navigator.serviceWorker.controller) {
22+ location.reload();
23+ return;
24+ }
25+ }
26+ }
27+
28+ const app = document.getElementById('app')!;
29+
30+ // one-time global setup: theme + languages are shared by all views;
31+ // Stan registers last so it owns .stan (and .sample maps to YAML)
32+ const theme = await loadBuiltinTheme('dark_modern');
33+ await registerBuiltinLanguages(theme);
34+ registerStanLanguage();
35+ // the Stan language server (diagnostics, hover, completion, format) runs
36+ // in one worker for the whole session, across projects
37+ registerStanLsp();
38+
39+ let current: { dispose(): void } | undefined;
40+ let navigating = false;
41+
42+ const route = async () => {
43+ if (navigating) {
44+ return;
45+ }
46+ navigating = true;
47+ try {
48+ current?.dispose();
49+ current = undefined;
50+ app.textContent = '';
51+
52+ const match = location.hash.match(/^#\/project\/([a-z0-9]+)/i);
53+ if (match) {
54+ const project = getProject(match[1]);
55+ if (project) {
56+ current = await openIde(app, project, theme);
57+ return;
58+ }
59+ // unknown project id: fall through to the landing page
60+ history.replaceState(null, '', '#/');
61+ }
62+ current = renderLanding(app, theme);
63+ } finally {
64+ navigating = false;
65+ }
66+ };
67+
68+ window.addEventListener('hashchange', route);
69+ await route();
70+}
71+
72+start();
src/projects.tsadded+123−0View file
@@ -0,0 +1,123 @@
1+import { createIndexedDBFileSystem, type WorkspaceFileSystem } from 'minwebide';
2+
3+// The project registry: a small localStorage index of projects, each backed
4+// by its own IndexedDB database (its own workspace file system).
5+
6+export interface ProjectInfo {
7+ readonly id: string;
8+ name: string;
9+ createdAt: number;
10+ lastOpenedAt: number;
11+}
12+
13+const REGISTRY_KEY = 'stan-web-ide.projects';
14+
15+function readRegistry(): ProjectInfo[] {
16+ try {
17+ const raw = localStorage.getItem(REGISTRY_KEY);
18+ const parsed = raw ? JSON.parse(raw) : [];
19+ return Array.isArray(parsed) ? parsed : [];
20+ } catch {
21+ return [];
22+ }
23+}
24+
25+function writeRegistry(projects: ProjectInfo[]): void {
26+ localStorage.setItem(REGISTRY_KEY, JSON.stringify(projects));
27+}
28+
29+export function projectDbName(id: string): string {
30+ return `stan-web-ide-project-${id}`;
31+}
32+
33+export function listProjects(): ProjectInfo[] {
34+ return readRegistry().sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
35+}
36+
37+export function getProject(id: string): ProjectInfo | undefined {
38+ return readRegistry().find(p => p.id === id);
39+}
40+
41+/** Picks 'untitled', 'untitled-2', ... skipping names already in use. */
42+export function nextUntitledName(base = 'untitled'): string {
43+ const names = new Set(readRegistry().map(p => p.name));
44+ if (!names.has(base)) {
45+ return base;
46+ }
47+ for (let i = 2; ; i++) {
48+ if (!names.has(`${base}-${i}`)) {
49+ return `${base}-${i}`;
50+ }
51+ }
52+}
53+
54+export function createProject(name: string): ProjectInfo {
55+ const project: ProjectInfo = {
56+ id: Math.random().toString(36).slice(2, 10),
57+ name,
58+ createdAt: Date.now(),
59+ lastOpenedAt: Date.now(),
60+ };
61+ writeRegistry([...readRegistry(), project]);
62+ return project;
63+}
64+
65+export function renameProject(id: string, name: string): void {
66+ const projects = readRegistry();
67+ const project = projects.find(p => p.id === id);
68+ if (project && name.trim()) {
69+ project.name = name.trim();
70+ writeRegistry(projects);
71+ }
72+}
73+
74+export function touchProject(id: string): void {
75+ const projects = readRegistry();
76+ const project = projects.find(p => p.id === id);
77+ if (project) {
78+ project.lastOpenedAt = Date.now();
79+ writeRegistry(projects);
80+ }
81+}
82+
83+export async function deleteProject(id: string): Promise<void> {
84+ writeRegistry(readRegistry().filter(p => p.id !== id));
85+ await new Promise<void>((resolve) => {
86+ const request = indexedDB.deleteDatabase(projectDbName(id));
87+ request.onsuccess = request.onerror = request.onblocked = () => resolve();
88+ });
89+}
90+
91+export async function openProjectFileSystem(id: string): Promise<WorkspaceFileSystem> {
92+ return createIndexedDBFileSystem({ dbName: projectDbName(id) });
93+}
94+
95+/** Copies all files of one project into a brand-new project. */
96+export async function duplicateProject(id: string): Promise<ProjectInfo | undefined> {
97+ const source = getProject(id);
98+ if (!source) {
99+ return undefined;
100+ }
101+ const copy = createProject(nextUntitledName(`${source.name}-copy`));
102+ const sourceFs = await openProjectFileSystem(source.id);
103+ const targetFs = await openProjectFileSystem(copy.id);
104+ try {
105+ const copyTree = async (path: string): Promise<void> => {
106+ const stat = await sourceFs.fileService.resolve(sourceFs.root.with({ path }));
107+ for (const child of stat.children ?? []) {
108+ if (child.isDirectory) {
109+ await targetFs.fileService.createFolder(targetFs.root.with({ path: child.resource.path }));
110+ await copyTree(child.resource.path);
111+ } else {
112+ const content = await sourceFs.fileService.readFile(child.resource);
113+ await targetFs.fileService.writeFile(targetFs.root.with({ path: child.resource.path }), content.value);
114+ }
115+ }
116+ };
117+ await copyTree('/');
118+ } finally {
119+ sourceFs.dispose();
120+ targetFs.dispose();
121+ }
122+ return copy;
123+}
src/sampleWorkspace.tsadded+104−0View file
@@ -0,0 +1,104 @@
1+// Files seeded into new projects.
2+
3+const readme = `# stan sample project
4+
5+Bayesian linear regression, run entirely in your browser.
6+
7+- \`linear.stan\` — the model (with syntax checking, hover docs, completion,
8+ and auto-format from the Stan language server).
9+- \`data.json\` — the data: 20 noisy points around y = 2 + 1.5 x.
10+- \`fit.sample\` — a sampling run: which program, which data, sampling
11+ parameters, and the output directory. Opens as a form; press
12+ **Run sampling** there (or ▶ in the tab bar). Reopen as raw YAML via the
13+ tab context menu.
14+- \`quick.sample\` — the same fit with fewer iterations and a random seed.
15+
16+Compiling the Stan program needs a **compilation server** (sampling itself
17+runs locally, in a web worker). The status bar shows the configured server;
18+click it to change. To run one on your machine:
19+
20+ docker run -p 8083:8080 -it ghcr.io/flatironinstitute/stan-wasm-server:latest
21+
22+When a run finishes, its output directory (e.g. \`out/fit/\`) appears in the
23+Explorer: \`chain_*.csv\` (one row per draw), \`summary.csv\` (mean, sd,
24+percentiles, ESS, Rhat per parameter), \`sampling_opts.json\`, and
25+\`console.txt\`.
26+
27+Edits save with **Ctrl+S** and persist in your browser. Runs use current
28+editor contents, saved or not.
29+`;
30+
31+const linearStan = `// Bayesian linear regression: y ~ normal(alpha + beta * x, sigma)
32+data {
33+ int<lower=0> N;
34+ vector[N] x;
35+ vector[N] y;
36+}
37+parameters {
38+ real alpha;
39+ real beta;
40+ real<lower=0> sigma;
41+}
42+model {
43+ alpha ~ normal(0, 5);
44+ beta ~ normal(0, 5);
45+ sigma ~ normal(0, 2);
46+ y ~ normal(alpha + beta * x, sigma);
47+}
48+generated quantities {
49+ // posterior predictive draw at x = 6
50+ real y_at_6 = normal_rng(alpha + beta * 6, sigma);
51+}
52+`;
53+
54+const dataJson = `{
55+ "N": 20,
56+ "x": [0.17, 0.41, 0.77, 0.96, 1.23, 1.46, 1.66, 1.8, 2.01, 2.32,
57+ 2.59, 3.05, 3.21, 3.45, 3.61, 3.83, 4.03, 4.52, 4.75, 5.0],
58+ "y": [2.13, 2.9, 3.51, 3.69, 3.41, 4.05, 4.56, 3.9, 3.5, 5.17,
59+ 6.14, 6.62, 6.55, 7.31, 7.34, 6.33, 8.81, 8.79, 8.56, 9.16]
60+}
61+`;
62+
63+const fitSample = `# A sampling run. This file opens as a form; use the tab context menu to
64+# edit the raw YAML. Paths are relative to this file.
65+stan: linear.stan
66+data: data.json
67+output_dir: out/fit
68+num_chains: 4
69+num_warmup: 1000
70+num_samples: 1000
71+seed: 42
72+`;
73+
74+const quickSample = `# A quicker look: fewer iterations, random seed each run.
75+stan: linear.stan
76+data: data.json
77+output_dir: out/quick
78+num_chains: 2
79+num_warmup: 200
80+num_samples: 200
81+`;
82+
83+export const sampleWorkspace: Record<string, string> = {
84+ '/README.md': readme,
85+ '/linear.stan': linearStan,
86+ '/data.json': dataJson,
87+ '/fit.sample': fitSample,
88+ '/quick.sample': quickSample,
89+};
90+
91+const emptyStan = `// Write your Stan program here.
92+parameters {
93+ real mu;
94+}
95+model {
96+ mu ~ normal(0, 1);
97+}
98+`;
99+
100+export const emptyWorkspace: Record<string, string> = {
101+ '/main.stan': emptyStan,
102+ '/data.json': '{}\n',
103+ '/fit.sample': `stan: main.stan\ndata: data.json\noutput_dir: out/fit\n`,
104+};
src/stan/compile.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+// Client for the stan-wasm-server compile endpoint (the same protocol as
2+// stan-playground): POST the .stan source, get a model id, and reference the
3+// compiled emscripten module at /download/{model_id}/main.js. The server
4+// caches compilations by source hash; on top of that we keep a small
5+// in-session cache so re-running an unchanged model skips the round trip
6+// (validated with a HEAD request, since server redeploys invalidate ids).
7+
8+export interface CompileResult {
9+ mainJsUrl?: string;
10+ error?: string;
11+}
12+
13+const cache = new Map<string, string>();
14+
15+export async function compileStanProgram(
16+ serverUrl: string,
17+ stanProgram: string,
18+ onStatus: (message: string) => void,
19+): Promise<CompileResult> {
20+ const cacheKey = `${serverUrl}\0${stanProgram}`;
21+
22+ const cached = cache.get(cacheKey);
23+ if (cached && await urlExists(cached)) {
24+ onStatus('compiled (cached)');
25+ return { mainJsUrl: cached };
26+ }
27+
28+ try {
29+ onStatus('compiling...');
30+ const response = await fetch(`${serverUrl}/compile`, {
31+ method: 'POST',
32+ headers: {
33+ 'Content-Type': 'text/plain',
34+ // the stan-wasm-server passcode (fixed, same as stan-playground)
35+ 'Authorization': 'Bearer 1234',
36+ },
37+ body: stanProgram,
38+ });
39+ if (!response.ok) {
40+ return { error: `compilation failed: ${await messageOrStatus(response)}` };
41+ }
42+ const { model_id } = await response.json();
43+ const mainJsUrl = `${serverUrl}/download/${model_id}/main.js`;
44+
45+ onStatus('checking download of main.js');
46+ if (!await urlExists(mainJsUrl)) {
47+ return { error: `compiled, but main.js is not downloadable from ${mainJsUrl}` };
48+ }
49+
50+ cache.set(cacheKey, mainJsUrl);
51+ onStatus('compiled');
52+ return { mainJsUrl };
53+ } catch (error) {
54+ return { error: `compilation request failed: ${error} (is the compile server at ${serverUrl} running, and does its CORS allowlist include this origin?)` };
55+ }
56+}
57+
58+async function urlExists(url: string): Promise<boolean> {
59+ try {
60+ const response = await fetch(url, { method: 'HEAD' });
61+ return response.ok;
62+ } catch {
63+ return false;
64+ }
65+}
66+
67+async function messageOrStatus(response: Response): Promise<string> {
68+ try {
69+ const body = await response.json();
70+ return body?.message ?? response.statusText;
71+ } catch {
72+ return response.statusText;
73+ }
74+}
src/stan/language.tsadded+23−0View file
@@ -0,0 +1,23 @@
1+import { monaco } from 'minwebide';
2+import { conf, language } from './stanLanguageDef';
3+
4+/**
5+ * Registers the Stan language (Monarch tokenizer + language configuration)
6+ * for .stan files, and maps .sample files to the built-in YAML language.
7+ * Called after registerBuiltinLanguages so these claims win.
8+ */
9+export function registerStanLanguage(): void {
10+ monaco.languages.register({
11+ id: 'stan',
12+ extensions: ['.stan'],
13+ aliases: ['Stan', 'stan'],
14+ });
15+ monaco.languages.setMonarchTokensProvider('stan', language);
16+ monaco.languages.setLanguageConfiguration('stan', conf);
17+
18+ // .sample files are YAML; extend the built-in yaml language's claim
19+ monaco.languages.register({
20+ id: 'yaml',
21+ extensions: ['.sample'],
22+ });
23+}
src/stan/lsp.tsadded+352−0View file
@@ -0,0 +1,352 @@
1+import { monaco } from 'minwebide';
2+
3+// Stan editor smarts: connects the stan-language-server worker (diagnostics
4+// from stanc3, hover docs, completions, auto-format) to monaco. VS Code's
5+// monaco build has no built-in LSP client, so this is a small purpose-built
6+// one: JSON-RPC over worker postMessage (the wire format of
7+// vscode-languageserver's browser transport).
8+
9+const MARKER_OWNER = 'stan-language-server';
10+const DEBOUNCE_MS = 300;
11+
12+interface JsonRpcMessage {
13+ jsonrpc: '2.0';
14+ id?: number;
15+ method?: string;
16+ params?: unknown;
17+ result?: unknown;
18+ error?: { code: number; message: string };
19+}
20+
21+class LspClient {
22+ private nextId = 1;
23+ private readonly pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
24+ private readonly notificationHandlers = new Map<string, (params: any) => void>();
25+ private readonly requestHandlers = new Map<string, (params: any) => unknown>();
26+
27+ constructor(private readonly worker: Worker) {
28+ worker.onmessage = (event: MessageEvent<JsonRpcMessage>) => this.dispatch(event.data);
29+ }
30+
31+ request<T = unknown>(method: string, params: unknown): Promise<T> {
32+ const id = this.nextId++;
33+ this.worker.postMessage({ jsonrpc: '2.0', id, method, params });
34+ return new Promise<T>((resolve, reject) => {
35+ this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
36+ });
37+ }
38+
39+ notify(method: string, params: unknown): void {
40+ this.worker.postMessage({ jsonrpc: '2.0', method, params });
41+ }
42+
43+ onNotification(method: string, handler: (params: any) => void): void {
44+ this.notificationHandlers.set(method, handler);
45+ }
46+
47+ onRequest(method: string, handler: (params: any) => unknown): void {
48+ this.requestHandlers.set(method, handler);
49+ }
50+
51+ private dispatch(message: JsonRpcMessage): void {
52+ if (message.method !== undefined && message.id !== undefined) {
53+ // server → client request
54+ const handler = this.requestHandlers.get(message.method);
55+ if (handler) {
56+ Promise.resolve(handler(message.params)).then(
57+ (result) => this.worker.postMessage({ jsonrpc: '2.0', id: message.id, result }),
58+ (error) => this.worker.postMessage({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: String(error) } }),
59+ );
60+ } else {
61+ this.worker.postMessage({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `unhandled method ${message.method}` } });
62+ }
63+ } else if (message.method !== undefined) {
64+ this.notificationHandlers.get(message.method)?.(message.params);
65+ } else if (message.id !== undefined) {
66+ const pending = this.pending.get(message.id);
67+ this.pending.delete(message.id);
68+ if (pending) {
69+ if (message.error) {
70+ pending.reject(new Error(message.error.message));
71+ } else {
72+ pending.resolve(message.result);
73+ }
74+ }
75+ }
76+ }
77+}
78+
79+/**
80+ * Starts the Stan language server and wires it to every 'stan' monaco model
81+ * (current and future). Call once at startup; returns a disposable.
82+ */
83+export function registerStanLsp(): { dispose(): void } {
84+ const worker = new Worker(new URL('./lspWorker.ts', import.meta.url), { type: 'module' });
85+ const client = new LspClient(worker);
86+ const disposables: { dispose(): void }[] = [];
87+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
88+
89+ client.onRequest('workspace/configuration', (params: { items: unknown[] }) =>
90+ params.items.map(() => ({ warnPedantic: false })));
91+ client.onNotification('window/logMessage', () => { /* quiet */ });
92+
93+ interface LspDiagnostic {
94+ range: LspRange;
95+ message: string;
96+ severity?: number;
97+ source?: string;
98+ code?: string | number;
99+ }
100+
101+ const applyDiagnostics = (model: monaco.editor.ITextModel, diagnostics: LspDiagnostic[]): void => {
102+ monaco.editor.setModelMarkers(model, MARKER_OWNER, diagnostics.map((diagnostic) => ({
103+ ...toMonacoRange(diagnostic.range),
104+ message: diagnostic.message,
105+ severity: toMarkerSeverity(diagnostic.severity),
106+ source: diagnostic.source ?? MARKER_OWNER,
107+ code: diagnostic.code === undefined ? undefined : String(diagnostic.code),
108+ })));
109+ };
110+
111+ // the server implements LSP 3.17 pull diagnostics (textDocument/diagnostic),
112+ // so the client asks after every (debounced) change
113+ const diagnosticGeneration = new Map<string, number>();
114+ const pullDiagnostics = async (model: monaco.editor.ITextModel): Promise<void> => {
115+ const uri = model.uri.toString();
116+ const generation = (diagnosticGeneration.get(uri) ?? 0) + 1;
117+ diagnosticGeneration.set(uri, generation);
118+ const result = await client.request<{ kind: string; items?: LspDiagnostic[] } | null>('textDocument/diagnostic', {
119+ textDocument: { uri },
120+ }).catch(() => null);
121+ if (result?.items && !model.isDisposed() && diagnosticGeneration.get(uri) === generation) {
122+ applyDiagnostics(model, result.items);
123+ }
124+ };
125+
126+ // push diagnostics too, in case a future server version publishes them
127+ client.onNotification('textDocument/publishDiagnostics', (params: { uri: string; diagnostics: LspDiagnostic[] }) => {
128+ const model = findModel(params.uri);
129+ if (model) {
130+ applyDiagnostics(model, params.diagnostics);
131+ }
132+ });
133+
134+ const initialized = client.request('initialize', {
135+ processId: null,
136+ rootUri: null,
137+ workspaceFolders: null,
138+ capabilities: {
139+ textDocument: {
140+ publishDiagnostics: {},
141+ hover: { contentFormat: ['markdown', 'plaintext'] },
142+ completion: { completionItem: { documentationFormat: ['markdown', 'plaintext'] } },
143+ formatting: {},
144+ },
145+ workspace: {
146+ configuration: true,
147+ didChangeConfiguration: {},
148+ },
149+ },
150+ }).then(() => {
151+ client.notify('initialized', {});
152+ }).catch((error) => {
153+ console.warn('stan language server failed to initialize', error);
154+ });
155+
156+ // --- document sync ----------------------------------------------------
157+
158+ const opened = new Set<string>();
159+
160+ const openModel = (model: monaco.editor.ITextModel): void => {
161+ if (model.getLanguageId() !== 'stan' || opened.has(model.uri.toString())) {
162+ return;
163+ }
164+ const uri = model.uri.toString();
165+ opened.add(uri);
166+ void initialized.then(() => {
167+ client.notify('textDocument/didOpen', {
168+ textDocument: { uri, languageId: 'stan', version: model.getVersionId(), text: model.getValue() },
169+ });
170+ void pullDiagnostics(model);
171+ });
172+ const changeListener = model.onDidChangeContent(() => {
173+ clearTimeout(timers.get(uri));
174+ timers.set(uri, setTimeout(() => {
175+ client.notify('textDocument/didChange', {
176+ textDocument: { uri, version: model.getVersionId() },
177+ contentChanges: [{ text: model.getValue() }],
178+ });
179+ void pullDiagnostics(model);
180+ }, DEBOUNCE_MS));
181+ });
182+ const disposeListener = model.onWillDispose(() => {
183+ changeListener.dispose();
184+ disposeListener.dispose();
185+ clearTimeout(timers.get(uri));
186+ timers.delete(uri);
187+ opened.delete(uri);
188+ client.notify('textDocument/didClose', { textDocument: { uri } });
189+ });
190+ };
191+
192+ for (const model of monaco.editor.getModels()) {
193+ openModel(model);
194+ }
195+ disposables.push(monaco.editor.onDidCreateModel(openModel));
196+ disposables.push(monaco.editor.onDidChangeModelLanguage(({ model }) => openModel(model)));
197+
198+ // --- providers ----------------------------------------------------------
199+
200+ disposables.push(monaco.languages.registerHoverProvider('stan', {
201+ async provideHover(model, position) {
202+ const result = await client.request<{ contents: unknown; range?: LspRange } | null>('textDocument/hover', {
203+ textDocument: { uri: model.uri.toString() },
204+ position: toLspPosition(position),
205+ }).catch(() => null);
206+ if (!result) {
207+ return null;
208+ }
209+ return {
210+ contents: toMarkdownStrings(result.contents),
211+ range: result.range ? toMonacoRange(result.range) : undefined,
212+ };
213+ },
214+ }));
215+
216+ disposables.push(monaco.languages.registerCompletionItemProvider('stan', {
217+ triggerCharacters: ['~', '.'],
218+ async provideCompletionItems(model, position) {
219+ const result = await client.request<unknown>('textDocument/completion', {
220+ textDocument: { uri: model.uri.toString() },
221+ position: toLspPosition(position),
222+ }).catch(() => null);
223+ const items = Array.isArray(result) ? result : (result as { items?: unknown[] } | null)?.items ?? [];
224+ const word = model.getWordUntilPosition(position);
225+ const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
226+ return {
227+ suggestions: (items as {
228+ label: string;
229+ kind?: number;
230+ detail?: string;
231+ documentation?: unknown;
232+ insertText?: string;
233+ sortText?: string;
234+ }[]).map((item) => ({
235+ label: item.label,
236+ kind: toMonacoCompletionKind(item.kind),
237+ detail: item.detail,
238+ documentation: toDocumentation(item.documentation),
239+ insertText: item.insertText ?? item.label,
240+ sortText: item.sortText,
241+ range,
242+ })),
243+ };
244+ },
245+ }));
246+
247+ disposables.push(monaco.languages.registerDocumentFormattingEditProvider('stan', {
248+ async provideDocumentFormattingEdits(model) {
249+ const edits = await client.request<{ range: LspRange; newText: string }[] | null>('textDocument/formatting', {
250+ textDocument: { uri: model.uri.toString() },
251+ options: { tabSize: 2, insertSpaces: true },
252+ }).catch(() => null);
253+ return (edits ?? []).map((edit) => ({
254+ range: toMonacoRange(edit.range),
255+ text: edit.newText,
256+ }));
257+ },
258+ }));
259+
260+ return {
261+ dispose(): void {
262+ for (const disposable of disposables) {
263+ disposable.dispose();
264+ }
265+ for (const timer of timers.values()) {
266+ clearTimeout(timer);
267+ }
268+ worker.terminate();
269+ },
270+ };
271+}
272+
273+// --- LSP ↔ monaco conversions ---------------------------------------------
274+
275+interface LspRange {
276+ start: { line: number; character: number };
277+ end: { line: number; character: number };
278+}
279+
280+function toLspPosition(position: monaco.IPosition): { line: number; character: number } {
281+ return { line: position.lineNumber - 1, character: position.column - 1 };
282+}
283+
284+function toMonacoRange(range: LspRange): monaco.IRange {
285+ return {
286+ startLineNumber: range.start.line + 1,
287+ startColumn: range.start.character + 1,
288+ endLineNumber: range.end.line + 1,
289+ endColumn: range.end.character + 1,
290+ };
291+}
292+
293+function toMarkerSeverity(severity: number | undefined): monaco.editor.IMarkerData['severity'] {
294+ switch (severity) {
295+ case 1: return monaco.MarkerSeverity.Error;
296+ case 2: return monaco.MarkerSeverity.Warning;
297+ case 3: return monaco.MarkerSeverity.Info;
298+ case 4: return monaco.MarkerSeverity.Hint;
299+ default: return monaco.MarkerSeverity.Error;
300+ }
301+}
302+
303+function toMarkdownStrings(contents: unknown): { value: string }[] {
304+ const toValue = (entry: unknown): string => {
305+ if (typeof entry === 'string') {
306+ return entry;
307+ }
308+ if (entry && typeof entry === 'object' && 'value' in entry) {
309+ return String((entry as { value: unknown }).value);
310+ }
311+ return '';
312+ };
313+ const list = Array.isArray(contents) ? contents : [contents];
314+ return list.map(toValue).filter(Boolean).map(value => ({ value }));
315+}
316+
317+function toDocumentation(documentation: unknown): string | { value: string } | undefined {
318+ if (documentation === undefined || documentation === null) {
319+ return undefined;
320+ }
321+ if (typeof documentation === 'string') {
322+ return documentation;
323+ }
324+ return { value: String((documentation as { value?: unknown }).value ?? '') };
325+}
326+
327+function toMonacoCompletionKind(kind: number | undefined): monaco.languages.CompletionItemKind {
328+ const kinds = monaco.languages.CompletionItemKind;
329+ // LSP CompletionItemKind → monaco's (different numberings)
330+ switch (kind) {
331+ case 2: return kinds.Method;
332+ case 3: return kinds.Function;
333+ case 4: return kinds.Constructor;
334+ case 5: return kinds.Field;
335+ case 6: return kinds.Variable;
336+ case 7: return kinds.Class;
337+ case 8: return kinds.Interface;
338+ case 9: return kinds.Module;
339+ case 10: return kinds.Property;
340+ case 12: return kinds.Value;
341+ case 13: return kinds.Enum;
342+ case 14: return kinds.Keyword;
343+ case 15: return kinds.Snippet;
344+ case 21: return kinds.Constant;
345+ case 22: return kinds.Struct;
346+ default: return kinds.Text;
347+ }
348+}
349+
350+function findModel(uri: string): monaco.editor.ITextModel | undefined {
351+ return monaco.editor.getModels().find(model => model.uri.toString() === uri);
352+}
src/stan/lspWorker.tsadded+15−0View file
@@ -0,0 +1,15 @@
1+// Web worker running the Stan language server (stanc3 compiled to JS),
2+// which provides diagnostics, hover, completion, and formatting for .stan
3+// files. Speaks LSP over postMessage (vscode-languageserver's browser
4+// transport); the app side is the small client in lsp.ts.
5+
6+import startLanguageServer from 'stan-language-server';
7+import { BrowserMessageReader, BrowserMessageWriter, createConnection } from 'vscode-languageserver/browser';
8+
9+const reader = new BrowserMessageReader(self as unknown as Worker);
10+const writer = new BrowserMessageWriter(self as unknown as Worker);
11+const connection = createConnection(reader, writer);
12+
13+// the connection types differ between the node and browser entry points of
14+// vscode-languageserver, but the runtime shape is the same
15+startLanguageServer(connection as unknown as Parameters<typeof startLanguageServer>[0]);
src/stan/outputs.tsadded+102−0View file
@@ -0,0 +1,102 @@
1+import type { WorkspaceFileSystem } from 'minwebide';
2+import {
3+ effective_sample_size,
4+ mean,
5+ percentile,
6+ split_potential_scale_reduction,
7+ std_deviation,
8+} from 'mcmc-stats';
9+
10+// Writes a completed run into the .sample file's output directory:
11+//
12+// <output_dir>/chain_1.csv ... one CSV per chain, header = parameter names
13+// <output_dir>/summary.csv mean, MCSE, sd, percentiles, ESS, Rhat
14+// <output_dir>/sampling_opts.json the exact configuration used
15+// <output_dir>/console.txt sampler console output
16+//
17+// (the per-chain CSV layout matches stan-playground's "download multiple
18+// CSVs" export)
19+
20+export interface RunOutputs {
21+ /** draws[param][draw], chains concatenated along the draw axis (tinystan). */
22+ draws: number[][];
23+ paramNames: string[];
24+ numChains: number;
25+ consoleText: string;
26+ samplingOpts: Record<string, unknown>;
27+ computeTimeSec: number;
28+}
29+
30+export async function writeRunOutputs(fs: WorkspaceFileSystem, outputDir: string, run: RunOutputs): Promise<string[]> {
31+ const written: string[] = [];
32+ const write = async (name: string, contents: string) => {
33+ const path = `${outputDir}/${name}`;
34+ await fs.writeFile(path, contents);
35+ written.push(path);
36+ };
37+
38+ // clear previous results so the directory holds exactly this run
39+ await fs.deleteFile(outputDir);
40+
41+ const numDraws = run.draws[0]?.length ?? 0;
42+ const perChain = Math.floor(numDraws / run.numChains);
43+
44+ for (let chain = 0; chain < run.numChains; chain++) {
45+ const lines = [run.paramNames.join(',')];
46+ for (let draw = chain * perChain; draw < (chain + 1) * perChain; draw++) {
47+ lines.push(run.draws.map(paramDraws => String(paramDraws[draw])).join(','));
48+ }
49+ await write(`chain_${chain + 1}.csv`, lines.join('\n') + '\n');
50+ }
51+
52+ await write('summary.csv', summaryCsv(run));
53+ await write('sampling_opts.json', JSON.stringify(run.samplingOpts, null, 2) + '\n');
54+ await write('console.txt', run.consoleText);
55+
56+ return written;
57+}
58+
59+function summaryCsv(run: RunOutputs): string {
60+ const numDraws = run.draws[0]?.length ?? 0;
61+ const perChain = Math.floor(numDraws / run.numChains);
62+
63+ // model parameters first, sampler diagnostics (lp__, divergent__, ...) last
64+ const order = [...run.paramNames.keys()].sort((a, b) =>
65+ Number(run.paramNames[a].endsWith('__')) - Number(run.paramNames[b].endsWith('__')));
66+
67+ const lines = ['parameter,mean,mcse,sd,p5,median,p95,ess,ess_per_sec,rhat'];
68+ for (const index of order) {
69+ const flat = run.draws[index];
70+ const byChain = Array.from({ length: run.numChains }, (_, chain) =>
71+ flat.slice(chain * perChain, (chain + 1) * perChain));
72+ const sorted = [...flat].sort((a, b) => a - b);
73+
74+ const ess = safe(() => effective_sample_size(byChain));
75+ const sd = safe(() => std_deviation(sorted));
76+ const row = [
77+ safe(() => mean(sorted)),
78+ sd / Math.sqrt(ess),
79+ sd,
80+ safe(() => percentile(sorted, 0.05)),
81+ safe(() => percentile(sorted, 0.5)),
82+ safe(() => percentile(sorted, 0.95)),
83+ ess,
84+ run.computeTimeSec > 0 ? ess / run.computeTimeSec : NaN,
85+ safe(() => split_potential_scale_reduction(byChain)),
86+ ];
87+ lines.push([run.paramNames[index], ...row.map(formatStat)].join(','));
88+ }
89+ return lines.join('\n') + '\n';
90+}
91+
92+function safe(compute: () => number): number {
93+ try {
94+ return compute();
95+ } catch {
96+ return NaN;
97+ }
98+}
99+
100+function formatStat(value: number): string {
101+ return Number.isFinite(value) ? String(Number(value.toPrecision(6))) : 'NaN';
102+}
src/stan/pathShim.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+// Browser stand-in for node's 'path', aliased in vite.config.ts:
2+// stan-language-server imports { join } for #include resolution.
3+
4+export function join(...parts: string[]): string {
5+ return parts.join('/').replace(/\/{2,}/g, '/');
6+}
7+
8+export default { join };
src/stan/protocol.tsadded+36−0View file
@@ -0,0 +1,36 @@
1+// Message protocol between the app and the sampler web worker.
2+
3+/** The config passed to tinystan's model.sample() (unset options use
4+ * tinystan defaults: diagonal metric, adapt_delta 0.8, max_depth 10, ...). */
5+export interface StanSampleConfig {
6+ /** Contents of the data JSON file. */
7+ data: string;
8+ num_chains: number;
9+ num_warmup: number;
10+ num_samples: number;
11+ init_radius: number;
12+ seed: number;
13+ /** Iterations between progress lines. */
14+ refresh: number;
15+ /** One thread per chain runs chains in parallel (needs SharedArrayBuffer). */
16+ num_threads: number;
17+}
18+
19+export interface Progress {
20+ chain: number;
21+ iteration: number;
22+ totalIterations: number;
23+ percent: number;
24+ warmup: boolean;
25+}
26+
27+export type WorkerRequest =
28+ | { type: 'load'; mainJsUrl: string }
29+ | { type: 'sample'; config: StanSampleConfig };
30+
31+export type WorkerResponse =
32+ | { type: 'loaded'; stanVersion: string }
33+ | { type: 'progress'; report: Progress }
34+ | { type: 'console'; text: string; level: 'log' | 'error' }
35+ | { type: 'done'; draws: number[][]; paramNames: string[] }
36+ | { type: 'error'; message: string };
src/stan/runEvents.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+// Shared run state per .sample file, connecting the runner (which drives a
2+// run) with the form editor view (which shows the run button, per-chain
3+// progress bars, and status). Keyed by the file URI.
4+
5+import type { Progress } from './protocol';
6+
7+export type RunPhase = 'idle' | 'compiling' | 'loading' | 'sampling' | 'writing' | 'done' | 'failed';
8+
9+export interface ChainProgress {
10+ iteration: number;
11+ totalIterations: number;
12+ warmup: boolean;
13+}
14+
15+export interface RunState {
16+ phase: RunPhase;
17+ /** Status detail or error message. */
18+ message?: string;
19+ /** Per-chain progress (index 0 = chain 1), while sampling. */
20+ chains?: ChainProgress[];
21+ computeTimeSec?: number;
22+}
23+
24+type Listener = (uriKey: string, state: RunState) => void;
25+
26+const states = new Map<string, RunState>();
27+const listeners = new Set<Listener>();
28+
29+export function getRunState(uriKey: string): RunState {
30+ return states.get(uriKey) ?? { phase: 'idle' };
31+}
32+
33+export function setRunState(uriKey: string, state: RunState): void {
34+ states.set(uriKey, state);
35+ for (const listener of listeners) {
36+ listener(uriKey, state);
37+ }
38+}
39+
40+export function updateChainProgress(uriKey: string, numChains: number, report: Progress): void {
41+ const state = getRunState(uriKey);
42+ const chains = state.chains ?? Array.from({ length: numChains }, () => ({
43+ iteration: 0,
44+ totalIterations: report.totalIterations,
45+ warmup: true,
46+ }));
47+ if (report.chain >= 1 && report.chain <= chains.length) {
48+ chains[report.chain - 1] = {
49+ iteration: report.iteration,
50+ totalIterations: report.totalIterations,
51+ warmup: report.warmup,
52+ };
53+ }
54+ setRunState(uriKey, { ...state, phase: 'sampling', chains });
55+}
56+
57+export function onDidChangeRunState(listener: Listener): { dispose(): void } {
58+ listeners.add(listener);
59+ return { dispose: () => listeners.delete(listener) };
60+}
src/stan/runner.tsadded+237−0View file
@@ -0,0 +1,237 @@
1+import { monaco, type FileRunner, type RunContext, type WorkspaceFileSystem } from 'minwebide';
2+import { compileStanProgram } from './compile';
3+import { writeRunOutputs } from './outputs';
4+import type { StanSampleConfig, WorkerResponse } from './protocol';
5+import { setRunState, updateChainProgress } from './runEvents';
6+import { dirnameOf, parseSampleFile, resolveProjectPath, type SampleFileConfig } from './sampleConfig';
7+import { getServerUrl } from './settings';
8+
9+// The .sample runner: compile the referenced Stan program on the compile
10+// server, run NUTS-HMC sampling in a web worker (tinystan), stream progress
11+// to the output channel (and to the .sample view's progress bars via
12+// runEvents), then write draws + summary into the output directory.
13+
14+interface ActiveRun {
15+ uriKey: string;
16+ worker: Worker;
17+ /** Resolves the run() promise; the worker is terminated afterwards. */
18+ finish: () => void;
19+ stopped: boolean;
20+}
21+
22+export interface StanRunner {
23+ runner: FileRunner;
24+ /** Stops the in-flight run, if any. */
25+ stop(): void;
26+ dispose(): void;
27+}
28+
29+export function createStanRunner(fs: WorkspaceFileSystem): StanRunner {
30+ let active: ActiveRun | undefined;
31+
32+ const run = async ({ uri, getText, output }: RunContext): Promise<void> => {
33+ const uriKey = uri.toString();
34+ const fail = (message: string): void => {
35+ output.error(message);
36+ setRunState(uriKey, { phase: 'failed', message });
37+ };
38+
39+ output.appendLine('');
40+ output.info(`run ${uri.path}`);
41+
42+ // 1. the .sample config
43+ const { config, errors, warnings } = parseSampleFile(await getText());
44+ for (const warning of warnings) {
45+ output.warn(warning);
46+ }
47+ if (errors.length > 0) {
48+ for (const error of errors) {
49+ output.error(error);
50+ }
51+ setRunState(uriKey, { phase: 'failed', message: errors[0] });
52+ return;
53+ }
54+
55+ // 2. referenced files
56+ const sampleDir = dirnameOf(uri.path);
57+ const stanPath = resolveProjectPath(sampleDir, config.stan!);
58+ const dataPath = resolveProjectPath(sampleDir, config.data!);
59+ const outputDir = resolveProjectPath(sampleDir, config.output_dir!);
60+ if (outputDir === '/') {
61+ return fail("'output_dir' must not be the project root (its contents are replaced on each run)");
62+ }
63+ for (const [name, path] of [['.sample file', uri.path], ['stan file', stanPath], ['data file', dataPath]] as const) {
64+ if (path === outputDir || path.startsWith(`${outputDir}/`)) {
65+ return fail(`'output_dir' (${outputDir}) would overwrite the ${name} (${path})`);
66+ }
67+ }
68+
69+ const stanText = await readProjectText(fs, stanPath);
70+ if (stanText === undefined) {
71+ return fail(`Stan program not found: ${stanPath}`);
72+ }
73+ const dataText = await readProjectText(fs, dataPath);
74+ if (dataText === undefined) {
75+ return fail(`data file not found: ${dataPath}`);
76+ }
77+ try {
78+ JSON.parse(dataText);
79+ } catch (error) {
80+ return fail(`data file ${dataPath} is not valid JSON: ${error instanceof Error ? error.message : error}`);
81+ }
82+
83+ // 3. compile (server-side, cached by source hash)
84+ setRunState(uriKey, { phase: 'compiling', message: 'compiling...' });
85+ const serverUrl = getServerUrl();
86+ output.info(`compiling ${stanPath} (server: ${serverUrl})`);
87+ const compiled = await compileStanProgram(serverUrl, stanText, (status) => {
88+ output.info(`[compile] ${status}`);
89+ setRunState(uriKey, { phase: 'compiling', message: status });
90+ });
91+ if (!compiled.mainJsUrl) {
92+ return fail(compiled.error ?? 'compilation failed');
93+ }
94+
95+ // 4. sample in a fresh worker
96+ const seed = config.seed ?? Math.floor(Math.random() * Math.pow(2, 32));
97+ const sampleConfig: StanSampleConfig = {
98+ data: dataText,
99+ num_chains: config.num_chains,
100+ num_warmup: config.num_warmup,
101+ num_samples: config.num_samples,
102+ init_radius: config.init_radius,
103+ seed,
104+ refresh: reasonableRefreshRate(config),
105+ // one thread per chain: chains run in parallel (issue mirrors
106+ // stan-playground's setting)
107+ num_threads: config.num_chains,
108+ };
109+
110+ setRunState(uriKey, { phase: 'loading', message: 'loading model...' });
111+ const worker = new Worker(new URL('./samplerWorker.ts', import.meta.url), { type: 'module' });
112+ const consoleLines: string[] = [];
113+ let samplingStarted = 0;
114+ let computeTimeSec = 0;
115+
116+ await new Promise<void>((resolve) => {
117+ const current: ActiveRun = { uriKey, worker, finish: resolve, stopped: false };
118+ active = current;
119+
120+ worker.onmessage = async (event: MessageEvent<WorkerResponse>) => {
121+ if (current.stopped) {
122+ return;
123+ }
124+ const message = event.data;
125+ switch (message.type) {
126+ case 'loaded': {
127+ output.info(`model loaded (Stan v${message.stanVersion}); sampling: ${config.num_chains} chains × (${config.num_warmup} warmup + ${config.num_samples} samples), seed ${seed}`);
128+ setRunState(uriKey, { phase: 'sampling', message: 'sampling...' });
129+ samplingStarted = performance.now();
130+ worker.postMessage({ type: 'sample', config: sampleConfig });
131+ break;
132+ }
133+ case 'progress': {
134+ const r = message.report;
135+ updateChainProgress(uriKey, config.num_chains, r);
136+ const line = `Chain ${r.chain} Iteration: ${r.iteration} / ${r.totalIterations} [${String(r.percent).padStart(3)}%] (${r.warmup ? 'Warmup' : 'Sampling'})`;
137+ consoleLines.push(line);
138+ output.appendLine(line);
139+ break;
140+ }
141+ case 'console': {
142+ consoleLines.push(message.text);
143+ output.appendLine(message.text);
144+ break;
145+ }
146+ case 'done': {
147+ computeTimeSec = (performance.now() - samplingStarted) / 1000;
148+ setRunState(uriKey, { phase: 'writing', message: 'writing outputs...' });
149+ try {
150+ const written = await writeRunOutputs(fs, outputDir, {
151+ draws: message.draws,
152+ paramNames: message.paramNames,
153+ numChains: config.num_chains,
154+ consoleText: consoleLines.join('\n') + '\n',
155+ samplingOpts: {
156+ stan: stanPath,
157+ data: dataPath,
158+ output_dir: outputDir,
159+ num_chains: config.num_chains,
160+ num_warmup: config.num_warmup,
161+ num_samples: config.num_samples,
162+ init_radius: config.init_radius,
163+ seed,
164+ compute_time_sec: Number(computeTimeSec.toFixed(3)),
165+ },
166+ computeTimeSec,
167+ });
168+ output.info(`sampling completed in ${computeTimeSec.toFixed(2)}s — wrote ${written.length} files to ${outputDir}`);
169+ setRunState(uriKey, { phase: 'done', message: `completed in ${computeTimeSec.toFixed(2)}s → ${outputDir}`, computeTimeSec });
170+ } catch (error) {
171+ fail(`failed to write outputs: ${error}`);
172+ }
173+ resolve();
174+ break;
175+ }
176+ case 'error': {
177+ fail(message.message);
178+ resolve();
179+ break;
180+ }
181+ }
182+ };
183+ worker.onerror = (event) => {
184+ fail(`worker error: ${event.message ?? 'failed to load'}`);
185+ resolve();
186+ };
187+ worker.postMessage({ type: 'load', mainJsUrl: compiled.mainJsUrl });
188+ }).finally(() => {
189+ worker.terminate();
190+ if (active?.worker === worker) {
191+ active = undefined;
192+ }
193+ });
194+ };
195+
196+ const stop = (): void => {
197+ if (active) {
198+ active.stopped = true;
199+ setRunState(active.uriKey, { phase: 'failed', message: 'stopped' });
200+ active.finish();
201+ }
202+ };
203+
204+ return {
205+ runner: {
206+ id: 'stan.sample',
207+ displayName: 'Run sampling',
208+ selector: [{ filenamePattern: '*.sample' }],
209+ run,
210+ stop,
211+ },
212+ stop,
213+ dispose(): void {
214+ active?.finish();
215+ },
216+ };
217+}
218+
219+/** Progress lines roughly every 2.5% of total iterations (min every 15). */
220+function reasonableRefreshRate(config: SampleFileConfig): number {
221+ const total = (config.num_samples + config.num_warmup) * config.num_chains;
222+ const nearestTen = Math.round(Math.floor(total / 40) / 10) * 10;
223+ return Math.max(15, nearestTen);
224+}
225+
226+/** Reads a project file as text, preferring an open editor's contents. */
227+async function readProjectText(fs: WorkspaceFileSystem, path: string): Promise<string | undefined> {
228+ const uri = fs.root.with({ path });
229+ const model = monaco.editor.getModel(uri);
230+ if (model) {
231+ return model.getValue();
232+ }
233+ if (!(await fs.fileService.exists(uri))) {
234+ return undefined;
235+ }
236+ return (await fs.fileService.readFile(uri)).value.toString();
237+}
src/stan/sampleConfig.tsadded+163−0View file
@@ -0,0 +1,163 @@
1+import { parse, parseDocument } from 'yaml';
2+
3+// .sample files: a YAML description of one sampling run —
4+//
5+// stan: linear.stan # the Stan program
6+// data: data.json # the data file
7+// output_dir: out/fit1 # where results are written
8+// num_chains: 4 # optional, with stan-playground's defaults
9+// num_warmup: 1000
10+// num_samples: 1000
11+// init_radius: 2.0
12+// seed: 42 # omit for a random seed
13+//
14+// File references are relative to the .sample file's directory; a leading
15+// '/' means the project root.
16+
17+export interface SampleFileConfig {
18+ stan?: string;
19+ data?: string;
20+ output_dir?: string;
21+ num_chains: number;
22+ num_warmup: number;
23+ num_samples: number;
24+ init_radius: number;
25+ seed?: number;
26+}
27+
28+export const samplingDefaults = {
29+ num_chains: 4,
30+ num_warmup: 1000,
31+ num_samples: 1000,
32+ init_radius: 2.0,
33+} as const;
34+
35+export const KNOWN_KEYS = ['stan', 'data', 'output_dir', 'num_chains', 'num_warmup', 'num_samples', 'init_radius', 'seed'] as const;
36+
37+export interface ParsedSampleFile {
38+ config: SampleFileConfig;
39+ /** Problems that make the config unrunnable. */
40+ errors: string[];
41+ /** Non-fatal issues (unknown keys, ...). */
42+ warnings: string[];
43+}
44+
45+export function parseSampleFile(text: string): ParsedSampleFile {
46+ const errors: string[] = [];
47+ const warnings: string[] = [];
48+ const config: SampleFileConfig = { ...samplingDefaults };
49+
50+ let raw: unknown;
51+ try {
52+ raw = parse(text);
53+ } catch (error) {
54+ return { config, errors: [`invalid YAML: ${error instanceof Error ? error.message : error}`], warnings };
55+ }
56+ if (raw === null || raw === undefined) {
57+ raw = {};
58+ }
59+ if (typeof raw !== 'object' || Array.isArray(raw)) {
60+ return { config, errors: ['the .sample file must be a YAML mapping'], warnings };
61+ }
62+ const record = raw as Record<string, unknown>;
63+
64+ for (const key of Object.keys(record)) {
65+ if (!(KNOWN_KEYS as readonly string[]).includes(key)) {
66+ warnings.push(`unknown key '${key}' (ignored)`);
67+ }
68+ }
69+
70+ const str = (key: 'stan' | 'data' | 'output_dir'): string | undefined => {
71+ const value = record[key];
72+ if (value === undefined || value === null) {
73+ return undefined;
74+ }
75+ if (typeof value !== 'string' || !value.trim()) {
76+ errors.push(`'${key}' must be a non-empty string`);
77+ return undefined;
78+ }
79+ return value.trim();
80+ };
81+ config.stan = str('stan');
82+ config.data = str('data');
83+ config.output_dir = str('output_dir');
84+
85+ const num = (key: 'num_chains' | 'num_warmup' | 'num_samples' | 'init_radius' | 'seed', opts: { min: number; max?: number; integer: boolean }): number | undefined => {
86+ const value = record[key];
87+ if (value === undefined || value === null) {
88+ return undefined;
89+ }
90+ if (typeof value !== 'number' || !Number.isFinite(value)
91+ || (opts.integer && !Number.isInteger(value))
92+ || value < opts.min || (opts.max !== undefined && value > opts.max)) {
93+ const range = opts.max !== undefined ? `${opts.min}..${opts.max}` : `>= ${opts.min}`;
94+ errors.push(`'${key}' must be ${opts.integer ? 'an integer' : 'a number'} (${range})`);
95+ return undefined;
96+ }
97+ return value;
98+ };
99+ config.num_chains = num('num_chains', { min: 1, max: 8, integer: true }) ?? samplingDefaults.num_chains;
100+ config.num_warmup = num('num_warmup', { min: 0, integer: true }) ?? samplingDefaults.num_warmup;
101+ config.num_samples = num('num_samples', { min: 1, integer: true }) ?? samplingDefaults.num_samples;
102+ config.init_radius = num('init_radius', { min: 0, integer: false }) ?? samplingDefaults.init_radius;
103+ config.seed = num('seed', { min: 0, integer: true });
104+
105+ if (!config.stan) {
106+ errors.push("missing 'stan': the Stan program to compile and run");
107+ } else if (!config.stan.endsWith('.stan')) {
108+ errors.push("'stan' must reference a .stan file");
109+ }
110+ if (!config.data) {
111+ errors.push("missing 'data': the JSON data file");
112+ }
113+ if (!config.output_dir) {
114+ errors.push("missing 'output_dir': where results are written");
115+ }
116+
117+ return { config, errors, warnings };
118+}
119+
120+/**
121+ * Sets (or, with undefined, removes) one top-level key in the YAML text,
122+ * preserving comments and formatting of everything else.
123+ */
124+export function updateSampleYaml(text: string, key: string, value: string | number | undefined): string {
125+ const doc = parseDocument(text);
126+ if (doc.contents === null || doc.contents === undefined) {
127+ // empty document: build a fresh mapping
128+ return value === undefined ? text : `${key}: ${JSON.stringify(value)}\n`;
129+ }
130+ if (value === undefined) {
131+ doc.delete(key);
132+ } else {
133+ doc.set(key, value);
134+ }
135+ return doc.toString();
136+}
137+
138+/**
139+ * Resolves a file reference from a .sample file: relative to the .sample
140+ * file's directory, or from the project root with a leading '/'.
141+ * Returns a normalized absolute project path.
142+ */
143+export function resolveProjectPath(sampleFileDir: string, reference: string): string {
144+ const joined = reference.startsWith('/') ? reference : `${sampleFileDir}/${reference}`;
145+ const parts: string[] = [];
146+ for (const part of joined.split('/')) {
147+ if (part === '' || part === '.') {
148+ continue;
149+ }
150+ if (part === '..') {
151+ parts.pop();
152+ } else {
153+ parts.push(part);
154+ }
155+ }
156+ return '/' + parts.join('/');
157+}
158+
159+/** The directory of a project file path ('/a/b/c.sample' → '/a/b'). */
160+export function dirnameOf(path: string): string {
161+ const index = path.lastIndexOf('/');
162+ return index <= 0 ? '/' : path.slice(0, index);
163+}
src/stan/sampleEditor.cssadded+180−0View file
@@ -0,0 +1,180 @@
1+/* The .sample form editor pane, themed with --vscode-* variables in the
2+ * spirit of VS Code's settings editor. */
3+
4+.sample-editor {
5+ height: 100%;
6+ overflow-y: auto;
7+ background-color: var(--vscode-editor-background);
8+ color: var(--vscode-foreground);
9+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10+ font-size: 13px;
11+}
12+
13+.sample-editor-inner {
14+ max-width: 640px;
15+ margin: 0 auto;
16+ padding: 24px 32px 48px;
17+}
18+
19+.sample-editor h2 {
20+ margin: 0 0 2px;
21+ font-size: 20px;
22+ font-weight: 400;
23+}
24+
25+.sample-editor-subtitle {
26+ margin: 0 0 18px;
27+ color: var(--vscode-descriptionForeground);
28+ font-size: 12px;
29+}
30+
31+.sample-field {
32+ margin-bottom: 14px;
33+}
34+
35+.sample-field label {
36+ display: block;
37+ margin-bottom: 4px;
38+ font-weight: 600;
39+}
40+
41+.sample-field .sample-field-hint {
42+ margin-left: 8px;
43+ font-weight: 400;
44+ color: var(--vscode-descriptionForeground);
45+ font-size: 12px;
46+}
47+
48+.sample-field input,
49+.sample-field select {
50+ width: 260px;
51+ max-width: 100%;
52+ padding: 4px 6px;
53+ font-size: 13px;
54+ font-family: inherit;
55+ color: var(--vscode-input-foreground);
56+ background-color: var(--vscode-input-background);
57+ border: 1px solid var(--vscode-input-border, transparent);
58+ border-radius: 2px;
59+ outline: none;
60+ box-sizing: border-box;
61+}
62+
63+.sample-field input:focus,
64+.sample-field select:focus {
65+ border-color: var(--vscode-focusBorder);
66+}
67+
68+.sample-params {
69+ display: grid;
70+ grid-template-columns: repeat(auto-fill, 150px);
71+ gap: 12px 18px;
72+ margin-bottom: 14px;
73+}
74+
75+.sample-params .sample-field {
76+ margin-bottom: 0;
77+}
78+
79+.sample-params input {
80+ width: 100%;
81+}
82+
83+.sample-problems {
84+ margin: 0 0 14px;
85+ padding: 8px 10px;
86+ border-left: 3px solid var(--vscode-editorError-foreground, #f48771);
87+ background-color: var(--vscode-inputValidation-errorBackground, rgba(90, 29, 29, 0.4));
88+ white-space: pre-wrap;
89+}
90+
91+.sample-problems.warnings {
92+ border-left-color: var(--vscode-editorWarning-foreground, #cca700);
93+ background-color: var(--vscode-inputValidation-warningBackground, rgba(90, 73, 29, 0.4));
94+}
95+
96+.sample-run-row {
97+ display: flex;
98+ align-items: center;
99+ gap: 12px;
100+ margin: 18px 0 10px;
101+}
102+
103+.sample-run-button {
104+ padding: 6px 18px;
105+ font-size: 13px;
106+ font-family: inherit;
107+ cursor: pointer;
108+ border-radius: 3px;
109+ border: 1px solid var(--vscode-button-border, transparent);
110+ background-color: var(--vscode-button-background);
111+ color: var(--vscode-button-foreground);
112+}
113+
114+.sample-run-button:hover {
115+ background-color: var(--vscode-button-hoverBackground);
116+}
117+
118+.sample-run-button:disabled {
119+ opacity: 0.5;
120+ cursor: default;
121+}
122+
123+.sample-run-button.stop {
124+ background-color: var(--vscode-button-secondaryBackground);
125+ color: var(--vscode-button-secondaryForeground);
126+}
127+
128+.sample-run-status {
129+ color: var(--vscode-descriptionForeground);
130+}
131+
132+.sample-run-status.error {
133+ color: var(--vscode-editorError-foreground, #f48771);
134+ white-space: pre-wrap;
135+}
136+
137+.sample-run-status.done {
138+ color: var(--vscode-charts-green, #89d185);
139+}
140+
141+.sample-chains {
142+ display: flex;
143+ flex-direction: column;
144+ gap: 6px;
145+ margin-top: 8px;
146+}
147+
148+.sample-chain {
149+ display: flex;
150+ align-items: center;
151+ gap: 10px;
152+}
153+
154+.sample-chain-label {
155+ width: 200px;
156+ font-size: 12px;
157+ color: var(--vscode-descriptionForeground);
158+ font-variant-numeric: tabular-nums;
159+ white-space: nowrap;
160+}
161+
162+.sample-chain-bar {
163+ flex: 1;
164+ height: 6px;
165+ border-radius: 3px;
166+ background-color: var(--vscode-input-background);
167+ overflow: hidden;
168+}
169+
170+.sample-chain-fill {
171+ height: 100%;
172+ width: 0;
173+ border-radius: 3px;
174+ background-color: var(--vscode-progressBar-background, #0e70c0);
175+ transition: width 0.15s ease-out;
176+}
177+
178+.sample-chain-fill.warmup {
179+ opacity: 0.55;
180+}
src/stan/sampleEditor.tsadded+291−0View file
@@ -0,0 +1,291 @@
1+import { monaco, type CustomEditorProvider, type Workbench, type WorkspaceFileSystem } from 'minwebide';
2+import { getRunState, onDidChangeRunState, type RunState } from './runEvents';
3+import { dirnameOf, parseSampleFile, samplingDefaults, updateSampleYaml } from './sampleConfig';
4+import './sampleEditor.css';
5+
6+// The default view for .sample files: a form over the YAML (shared text
7+// model, so 'Reopen as Text Editor', dirty state, and Ctrl+S behave), plus
8+// the run button and per-chain progress bars fed by runEvents.
9+
10+interface StopHandle {
11+ stop(): void;
12+}
13+
14+export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: Workbench, stopHandle: StopHandle): CustomEditorProvider {
15+ return {
16+ viewType: 'stan.sampleView',
17+ displayName: 'Sampling Run',
18+ selector: [{ filenamePattern: '*.sample' }],
19+ priority: 'default',
20+ async resolveCustomEditor(doc) {
21+ const model = await doc.getTextModel();
22+ const uriKey = doc.uri.toString();
23+ const sampleDir = dirnameOf(doc.uri.path);
24+ const disposables: { dispose(): void }[] = [];
25+
26+ const element = el('div', 'sample-editor');
27+ const inner = el('div', 'sample-editor-inner');
28+ element.appendChild(inner);
29+
30+ const fileName = doc.uri.path.split('/').pop() ?? doc.uri.path;
31+ inner.appendChild(el('h2', undefined, fileName));
32+ inner.appendChild(el('p', 'sample-editor-subtitle', 'A sampling run: the Stan program, the data, sampling parameters, and where results go. This form edits the underlying YAML (tab menu → Reopen as Text Editor).'));
33+
34+ const problems = el('div', 'sample-problems');
35+ problems.style.display = 'none';
36+ inner.appendChild(problems);
37+
38+ // --- fields ------------------------------------------------------
39+ let applyingEdit = false;
40+ const setKey = (key: string, value: string | number | undefined) => {
41+ const updated = updateSampleYaml(model.getValue(), key, value);
42+ if (updated !== model.getValue()) {
43+ applyingEdit = true;
44+ try {
45+ model.pushEditOperations([], [{ range: model.getFullModelRange(), text: updated }], () => null);
46+ } finally {
47+ applyingEdit = false;
48+ }
49+ refresh();
50+ }
51+ };
52+
53+ const stanField = fileSelect('stan', 'the Stan program', '.stan');
54+ const dataField = fileSelect('data', 'the data (JSON)', '.json');
55+
56+ const outputField = el('input');
57+ outputField.type = 'text';
58+ outputField.placeholder = 'e.g. out/fit1';
59+ outputField.addEventListener('change', () => setKey('output_dir', outputField.value.trim() || undefined));
60+ inner.appendChild(field('output_dir', 'results are written here (replaced on each run)', outputField));
61+
62+ const params = el('div', 'sample-params');
63+ inner.appendChild(params);
64+ const numberField = (key: 'num_chains' | 'num_warmup' | 'num_samples' | 'init_radius' | 'seed', hint: string, opts: { min: number; max?: number; step?: string; optional?: boolean }) => {
65+ const input = el('input');
66+ input.type = 'number';
67+ input.min = String(opts.min);
68+ if (opts.max !== undefined) {
69+ input.max = String(opts.max);
70+ }
71+ input.step = opts.step ?? '1';
72+ if (opts.optional) {
73+ input.placeholder = 'random';
74+ }
75+ input.addEventListener('change', () => {
76+ const raw = input.value.trim();
77+ if (!raw) {
78+ setKey(key, opts.optional ? undefined : samplingDefaults[key as keyof typeof samplingDefaults]);
79+ return;
80+ }
81+ const value = Number(raw);
82+ if (Number.isFinite(value)) {
83+ setKey(key, value);
84+ }
85+ });
86+ params.appendChild(field(key, hint, input, true));
87+ return input;
88+ };
89+ const chainsInput = numberField('num_chains', 'chains', { min: 1, max: 8 });
90+ const warmupInput = numberField('num_warmup', 'warmup iterations', { min: 0 });
91+ const samplesInput = numberField('num_samples', 'draws per chain', { min: 1 });
92+ const radiusInput = numberField('init_radius', 'init radius', { min: 0, step: '0.1' });
93+ const seedInput = numberField('seed', 'random seed', { min: 0, optional: true });
94+
95+ // --- run button + progress ---------------------------------------
96+ const runRow = el('div', 'sample-run-row');
97+ const runButton = el('button', 'sample-run-button', 'Run sampling');
98+ runButton.addEventListener('click', () => {
99+ const state = getRunState(uriKey);
100+ if (isRunning(state)) {
101+ stopHandle.stop();
102+ } else {
103+ void workbench.runFile(doc.uri);
104+ }
105+ });
106+ const runStatus = el('span', 'sample-run-status', '');
107+ runRow.append(runButton, runStatus);
108+ inner.appendChild(runRow);
109+
110+ const chainsBox = el('div', 'sample-chains');
111+ inner.appendChild(chainsBox);
112+
113+ const renderRunState = (state: RunState) => {
114+ const running = isRunning(state);
115+ runButton.textContent = running ? 'Stop' : 'Run sampling';
116+ runButton.classList.toggle('stop', running);
117+ runStatus.textContent = state.message ?? '';
118+ runStatus.className = 'sample-run-status'
119+ + (state.phase === 'failed' ? ' error' : state.phase === 'done' ? ' done' : '');
120+ chainsBox.textContent = '';
121+ if (state.chains) {
122+ state.chains.forEach((chain, index) => {
123+ const row = el('div', 'sample-chain');
124+ const percent = chain.totalIterations > 0 ? Math.round((chain.iteration / chain.totalIterations) * 100) : 0;
125+ row.appendChild(el('span', 'sample-chain-label',
126+ `Chain ${index + 1} ${chain.iteration} / ${chain.totalIterations}${chain.iteration > 0 ? (chain.warmup ? ' (warmup)' : ' (sampling)') : ''}`));
127+ const bar = el('div', 'sample-chain-bar');
128+ const fill = el('div', 'sample-chain-fill' + (chain.warmup ? ' warmup' : ''));
129+ fill.style.width = `${percent}%`;
130+ bar.appendChild(fill);
131+ row.appendChild(bar);
132+ chainsBox.appendChild(row);
133+ });
134+ }
135+ };
136+ renderRunState(getRunState(uriKey));
137+ disposables.push(onDidChangeRunState((key, state) => {
138+ if (key === uriKey) {
139+ renderRunState(state);
140+ }
141+ }));
142+
143+ // --- model → form ------------------------------------------------
144+ const refresh = () => {
145+ const { config, errors, warnings } = parseSampleFile(model.getValue());
146+ const messages = [...errors, ...warnings.map(w => `warning: ${w}`)];
147+ problems.style.display = messages.length ? '' : 'none';
148+ problems.className = 'sample-problems' + (errors.length ? '' : ' warnings');
149+ problems.textContent = messages.join('\n');
150+ runButton.disabled = errors.length > 0;
151+
152+ setIfNotFocused(stanField.select, config.stan ?? '');
153+ setIfNotFocused(dataField.select, config.data ?? '');
154+ setIfNotFocused(outputField, config.output_dir ?? '');
155+ setIfNotFocused(chainsInput, String(config.num_chains));
156+ setIfNotFocused(warmupInput, String(config.num_warmup));
157+ setIfNotFocused(samplesInput, String(config.num_samples));
158+ setIfNotFocused(radiusInput, String(config.init_radius));
159+ setIfNotFocused(seedInput, config.seed === undefined ? '' : String(config.seed));
160+ };
161+
162+ disposables.push(model.onDidChangeContent(() => {
163+ if (!applyingEdit) {
164+ refresh();
165+ }
166+ }));
167+
168+ // keep the .stan/.json dropdowns in sync with the project's files
169+ const refreshFileLists = async () => {
170+ const all = await listProjectFiles(fs);
171+ stanField.setOptions(all.filter(path => path.endsWith('.stan')));
172+ dataField.setOptions(all.filter(path => path.endsWith('.json')));
173+ refresh();
174+ };
175+ let fileListTimer: ReturnType<typeof setTimeout> | undefined;
176+ disposables.push(fs.fileService.onDidFilesChange(() => {
177+ clearTimeout(fileListTimer);
178+ fileListTimer = setTimeout(() => void refreshFileLists(), 300);
179+ }));
180+ await refreshFileLists();
181+
182+ return {
183+ element,
184+ dispose() {
185+ clearTimeout(fileListTimer);
186+ for (const disposable of disposables) {
187+ disposable.dispose();
188+ }
189+ },
190+ };
191+
192+ // --- helpers scoped to this pane ---------------------------------
193+
194+ /** A <select> of project files with a given extension, storing
195+ * .sample-dir-relative references in the YAML. */
196+ function fileSelect(key: 'stan' | 'data', hint: string, extension: string) {
197+ const select = el('select');
198+ let options: string[] = [];
199+ const setOptions = (paths: string[]) => {
200+ options = paths.map(path => referenceFor(path));
201+ renderOptions(select.value);
202+ };
203+ const renderOptions = (current: string) => {
204+ select.textContent = '';
205+ const empty = makeOption('', `— select a ${extension} file —`);
206+ select.appendChild(empty);
207+ const seen = new Set<string>();
208+ for (const reference of options) {
209+ seen.add(reference);
210+ select.appendChild(makeOption(reference, reference));
211+ }
212+ if (current && !seen.has(current)) {
213+ select.appendChild(makeOption(current, `${current} (missing)`));
214+ }
215+ select.value = current;
216+ };
217+ select.addEventListener('change', () => setKey(key, select.value || undefined));
218+ inner.appendChild(field(key, hint, select));
219+ return { select, setOptions };
220+ }
221+
222+ function referenceFor(path: string): string {
223+ return path.startsWith(`${sampleDir}/`) && sampleDir !== '/'
224+ ? path.slice(sampleDir.length + 1)
225+ : (sampleDir === '/' ? path.slice(1) : path);
226+ }
227+
228+ function setIfNotFocused(input: HTMLInputElement | HTMLSelectElement, value: string): void {
229+ if (document.activeElement === input) {
230+ return;
231+ }
232+ if (input instanceof HTMLSelectElement) {
233+ const has = [...input.options].some(option => option.value === value);
234+ if (!has && value) {
235+ input.appendChild(makeOption(value, `${value} (missing)`));
236+ }
237+ }
238+ if (input.value !== value) {
239+ input.value = value;
240+ }
241+ }
242+
243+ function makeOption(value: string, label: string): HTMLOptionElement {
244+ const option = document.createElement('option');
245+ option.value = value;
246+ option.textContent = label;
247+ return option;
248+ }
249+ },
250+ };
251+
252+ function isRunning(state: RunState): boolean {
253+ return state.phase === 'compiling' || state.phase === 'loading' || state.phase === 'sampling' || state.phase === 'writing';
254+ }
255+}
256+
257+function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
258+ const node = document.createElement(tag);
259+ if (className) {
260+ node.className = className;
261+ }
262+ if (text !== undefined) {
263+ node.textContent = text;
264+ }
265+ return node;
266+}
267+
268+function field(label: string, hint: string, control: HTMLElement, compact = false): HTMLElement {
269+ const wrap = el('div', 'sample-field');
270+ const labelEl = el('label', undefined, label);
271+ if (!compact) {
272+ labelEl.appendChild(el('span', 'sample-field-hint', hint));
273+ } else {
274+ labelEl.title = hint;
275+ }
276+ wrap.append(labelEl, control);
277+ return wrap;
278+}
279+
280+async function listProjectFiles(fs: WorkspaceFileSystem, path = '/'): Promise<string[]> {
281+ const result: string[] = [];
282+ const stat = await fs.fileService.resolve(fs.root.with({ path }));
283+ for (const child of stat.children ?? []) {
284+ if (child.isDirectory) {
285+ result.push(...await listProjectFiles(fs, child.resource.path));
286+ } else {
287+ result.push(child.resource.path);
288+ }
289+ }
290+ return result.sort();
291+}
src/stan/samplerWorker.tsadded+107−0View file
@@ -0,0 +1,107 @@
1+// Web worker that loads a compiled Stan model (the emscripten module built
2+// by the compile server) via tinystan and runs NUTS-HMC sampling. Mirrors
3+// stan-playground's StanModelWorker: progress is parsed out of Stan's
4+// stdout lines; everything else streams back as console messages.
5+
6+import StanModel from 'tinystan';
7+import type { Progress, WorkerRequest, WorkerResponse } from './protocol';
8+
9+let model: StanModel | undefined;
10+
11+function post(message: WorkerResponse): void {
12+ self.postMessage(message);
13+}
14+
15+// The compiled models are threaded emscripten ES6 builds: they spawn their
16+// pthread pool with new Worker(new URL('main.js', import.meta.url)), which
17+// throws for a cross-origin script (the compile server). Workers cannot be
18+// *constructed* from a cross-origin URL, but a module worker may *import*
19+// one via CORS — so route cross-origin worker scripts through a same-origin
20+// blob trampoline.
21+const NativeWorker = Worker;
22+(self as { Worker: unknown }).Worker = class extends NativeWorker {
23+ constructor(scriptUrl: string | URL, options?: WorkerOptions) {
24+ const resolved = new URL(scriptUrl, self.location.href);
25+ if (resolved.origin !== self.location.origin) {
26+ const blob = new Blob([`import ${JSON.stringify(resolved.href)};`], { type: 'text/javascript' });
27+ super(URL.createObjectURL(blob), options);
28+ } else {
29+ super(scriptUrl, options);
30+ }
31+ }
32+};
33+
34+// Stan progress lines look like (spacing varies):
35+// Chain [1] Iteration: 2000 / 2000 [100%] (Sampling)
36+// Chain [2] Iteration: 800 / 2000 [ 40%] (Warmup)
37+// With a single chain the "Chain [x]" prefix is omitted.
38+function parseProgress(line: string): Progress {
39+ if (line.startsWith('Iteration:')) {
40+ line = 'Chain [1] ' + line;
41+ }
42+ line = line.replace(/\[|\]/g, '');
43+ const parts = line.split(/\s+/);
44+ return {
45+ chain: parseInt(parts[1], 10),
46+ iteration: parseInt(parts[3], 10),
47+ totalIterations: parseInt(parts[5], 10),
48+ percent: parseInt(parts[6].slice(0, -1), 10),
49+ warmup: parts[7] === '(Warmup)',
50+ };
51+}
52+
53+function onPrint(text: string): void {
54+ if (!text) {
55+ return;
56+ }
57+ if (text.startsWith('Chain') || text.startsWith('Iteration:')) {
58+ const report = parseProgress(text);
59+ if (Number.isFinite(report.chain) && Number.isFinite(report.iteration)) {
60+ post({ type: 'progress', report });
61+ return;
62+ }
63+ }
64+ post({ type: 'console', text, level: 'log' });
65+}
66+
67+function onPrintError(text: string): void {
68+ if (text) {
69+ post({ type: 'console', text, level: 'error' });
70+ }
71+}
72+
73+self.onmessage = (event: MessageEvent<WorkerRequest>) => {
74+ const message = event.data;
75+ switch (message.type) {
76+ case 'load': {
77+ if (!self.crossOriginIsolated) {
78+ post({
79+ type: 'console',
80+ text: 'warning: not cross-origin isolated — SharedArrayBuffer is unavailable and the threaded Stan module may fail to load',
81+ level: 'error',
82+ });
83+ }
84+ (async () => {
85+ const js = await import(/* @vite-ignore */ message.mainJsUrl);
86+ model = await StanModel.load(js.default, onPrint, onPrintError);
87+ post({ type: 'loaded', stanVersion: model.stanVersion() });
88+ })().catch((error) => {
89+ post({ type: 'error', message: `failed to load compiled model: ${error}` });
90+ });
91+ break;
92+ }
93+ case 'sample': {
94+ if (!model) {
95+ post({ type: 'error', message: 'model is not loaded' });
96+ return;
97+ }
98+ try {
99+ const { paramNames, draws } = model.sample(message.config);
100+ post({ type: 'done', draws, paramNames });
101+ } catch (error) {
102+ post({ type: 'error', message: String(error) });
103+ }
104+ break;
105+ }
106+ }
107+};
src/stan/serverDialog.cssadded+93−0View file
@@ -0,0 +1,93 @@
1+/* Small modal for the compile-server URL setting. */
2+
3+.server-dialog-overlay {
4+ position: absolute;
5+ inset: 0;
6+ z-index: 100;
7+ display: flex;
8+ align-items: flex-start;
9+ justify-content: center;
10+ padding-top: 12vh;
11+ background-color: rgba(0, 0, 0, 0.35);
12+}
13+
14+.server-dialog {
15+ width: 520px;
16+ max-width: calc(100vw - 40px);
17+ padding: 16px 18px;
18+ border-radius: 6px;
19+ background-color: var(--vscode-editorWidget-background, #252526);
20+ color: var(--vscode-foreground);
21+ border: 1px solid var(--vscode-editorWidget-border, #454545);
22+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
23+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
24+ font-size: 13px;
25+}
26+
27+.server-dialog h3 {
28+ margin: 0 0 8px;
29+ font-size: 15px;
30+ font-weight: 500;
31+}
32+
33+.server-dialog p {
34+ margin: 0 0 10px;
35+ color: var(--vscode-descriptionForeground);
36+}
37+
38+.server-dialog code {
39+ font-family: var(--monaco-monospace-font, monospace);
40+ font-size: 12px;
41+ background-color: var(--vscode-textCodeBlock-background, rgba(255, 255, 255, 0.08));
42+ padding: 1px 4px;
43+ border-radius: 3px;
44+ user-select: all;
45+}
46+
47+.server-dialog input {
48+ width: 100%;
49+ padding: 5px 7px;
50+ margin-bottom: 12px;
51+ font-size: 13px;
52+ font-family: inherit;
53+ color: var(--vscode-input-foreground);
54+ background-color: var(--vscode-input-background);
55+ border: 1px solid var(--vscode-input-border, transparent);
56+ border-radius: 2px;
57+ outline: none;
58+ box-sizing: border-box;
59+}
60+
61+.server-dialog input:focus {
62+ border-color: var(--vscode-focusBorder);
63+}
64+
65+.server-dialog-buttons {
66+ display: flex;
67+ gap: 8px;
68+ justify-content: flex-end;
69+}
70+
71+.server-dialog-buttons button {
72+ padding: 5px 14px;
73+ font-size: 13px;
74+ font-family: inherit;
75+ cursor: pointer;
76+ border-radius: 3px;
77+ border: 1px solid var(--vscode-button-border, transparent);
78+ background-color: var(--vscode-button-secondaryBackground);
79+ color: var(--vscode-button-secondaryForeground);
80+}
81+
82+.server-dialog-buttons button:hover {
83+ background-color: var(--vscode-button-secondaryHoverBackground);
84+}
85+
86+.server-dialog-buttons button.primary {
87+ background-color: var(--vscode-button-background);
88+ color: var(--vscode-button-foreground);
89+}
90+
91+.server-dialog-buttons button.primary:hover {
92+ background-color: var(--vscode-button-hoverBackground);
93+}
src/stan/serverDialog.tsadded+79−0View file
@@ -0,0 +1,79 @@
1+import { DEFAULT_SERVER_URL, getServerUrl, LOCAL_SERVER_DOCKER_COMMAND, setServerUrl } from './settings';
2+import './serverDialog.css';
3+
4+/** Small modal to view/change the compile-server URL. */
5+export function showServerDialog(container: HTMLElement): void {
6+ const overlay = document.createElement('div');
7+ overlay.className = 'server-dialog-overlay';
8+ const close = () => overlay.remove();
9+ overlay.addEventListener('click', (event) => {
10+ if (event.target === overlay) {
11+ close();
12+ }
13+ });
14+
15+ const box = document.createElement('div');
16+ box.className = 'server-dialog';
17+ overlay.appendChild(box);
18+
19+ const title = document.createElement('h3');
20+ title.textContent = 'Stan compilation server';
21+ box.appendChild(title);
22+
23+ const description = document.createElement('p');
24+ description.append(
25+ 'Compiling Stan programs to WebAssembly needs a stan-wasm-server; sampling then runs locally in your browser. Run one on your machine with:',
26+ );
27+ box.appendChild(description);
28+
29+ const command = document.createElement('p');
30+ const code = document.createElement('code');
31+ code.textContent = LOCAL_SERVER_DOCKER_COMMAND;
32+ command.appendChild(code);
33+ box.appendChild(command);
34+
35+ const note = document.createElement('p');
36+ note.textContent = 'The server\'s CORS allowlist must include this page\'s origin.';
37+ box.appendChild(note);
38+
39+ const input = document.createElement('input');
40+ input.type = 'text';
41+ input.value = getServerUrl();
42+ input.placeholder = DEFAULT_SERVER_URL;
43+ input.spellcheck = false;
44+ box.appendChild(input);
45+
46+ const buttons = document.createElement('div');
47+ buttons.className = 'server-dialog-buttons';
48+ const makeButton = (label: string, className: string, handler: () => void) => {
49+ const button = document.createElement('button');
50+ button.textContent = label;
51+ if (className) {
52+ button.className = className;
53+ }
54+ button.addEventListener('click', handler);
55+ buttons.appendChild(button);
56+ };
57+ makeButton('Use default', '', () => {
58+ input.value = DEFAULT_SERVER_URL;
59+ });
60+ makeButton('Cancel', '', close);
61+ makeButton('Save', 'primary', () => {
62+ setServerUrl(input.value);
63+ close();
64+ });
65+ box.appendChild(buttons);
66+
67+ input.addEventListener('keydown', (event) => {
68+ if (event.key === 'Enter') {
69+ setServerUrl(input.value);
70+ close();
71+ } else if (event.key === 'Escape') {
72+ close();
73+ }
74+ });
75+
76+ container.appendChild(overlay);
77+ input.focus();
78+ input.select();
79+}
src/stan/settings.tsadded+54−0View file
@@ -0,0 +1,54 @@
1+// The Stan compilation server: compiling .stan source to WebAssembly needs a
2+// server (stan-playground's stan-wasm-server; see the README). The URL is a
3+// user setting persisted in localStorage, shared by all projects.
4+//
5+// Note the server's CORS allowlist must include this app's origin. The stock
6+// docker image (ghcr.io/flatironinstitute/stan-wasm-server) allows
7+// http://127.0.0.1:3000 and http://127.0.0.1:4173 — which is why dev/preview
8+// run on those ports.
9+
10+const SERVER_URL_KEY = 'stan-web-ide.compileServerUrl';
11+
12+export const DEFAULT_SERVER_URL = 'http://localhost:8083';
13+export const LOCAL_SERVER_DOCKER_COMMAND =
14+ 'docker run -p 8083:8080 -it ghcr.io/flatironinstitute/stan-wasm-server:latest';
15+
16+type Listener = (url: string) => void;
17+const listeners = new Set<Listener>();
18+
19+export function getServerUrl(): string {
20+ return localStorage.getItem(SERVER_URL_KEY) || DEFAULT_SERVER_URL;
21+}
22+
23+export function setServerUrl(url: string): void {
24+ const trimmed = url.trim().replace(/\/+$/, '');
25+ if (trimmed) {
26+ localStorage.setItem(SERVER_URL_KEY, trimmed);
27+ } else {
28+ localStorage.removeItem(SERVER_URL_KEY);
29+ }
30+ for (const listener of listeners) {
31+ listener(getServerUrl());
32+ }
33+}
34+
35+export function onDidChangeServerUrl(listener: Listener): { dispose(): void } {
36+ listeners.add(listener);
37+ return { dispose: () => listeners.delete(listener) };
38+}
39+
40+/** GET {serverUrl}/probe — true when the compile server is reachable. */
41+export async function probeServer(url: string): Promise<boolean> {
42+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
43+ return false;
44+ }
45+ try {
46+ const controller = new AbortController();
47+ const timer = setTimeout(() => controller.abort(), 5000);
48+ const response = await fetch(`${url}/probe`, { signal: controller.signal });
49+ clearTimeout(timer);
50+ return response.ok;
51+ } catch {
52+ return false;
53+ }
54+}
src/stan/stanLanguageDef.tsadded+962−0View file
@@ -0,0 +1,962 @@
1+// See https://microsoft.github.io/monaco-editor/monarch.html
2+// Ported from stan-playground's monacoStanLanguage.ts, which is adapted in
3+// part from https://github.com/WardBrian/vscode-stan-extension/blob/main/lang/syntaxes/stan.json
4+
5+import { monaco } from 'minwebide';
6+
7+const BLOCKS = [
8+ "functions",
9+ "model",
10+ "data",
11+ "parameters",
12+ "generated quantities",
13+ "transformed data",
14+ "transformed parameters",
15+];
16+
17+const STATEMENTS = [
18+ "for",
19+ "in",
20+ "if",
21+ "else",
22+ "while",
23+ "break",
24+ "continue",
25+ "return",
26+ "target", // += ...
27+ "jacobian", // += ...
28+];
29+
30+const TYPES = [
31+ "array",
32+ "tuple",
33+ "data",
34+ "complex",
35+ "int",
36+ "real",
37+ "vector",
38+ "complex_vector",
39+ "ordered",
40+ "positive_ordered",
41+ "simplex",
42+ "unit_vector",
43+ "sum_to_zero_vector",
44+ "row_vector",
45+ "complex_row_vector",
46+ "matrix",
47+ "complex_matrix",
48+ "cholesky_factor_corr",
49+ "cholesky_factor_cov",
50+ "corr_matrix",
51+ "cov_matrix",
52+ "column_stochastic_matrix",
53+ "row_stochastic_matrix",
54+ "sum_to_zero_matrix",
55+ "void",
56+];
57+
58+const FUNCTIONS = [
59+ "Phi",
60+ "Phi_approx",
61+ "abs",
62+ "acos",
63+ "acosh",
64+ "add_diag",
65+ "algebra_solver",
66+ "algebra_solver_newton",
67+ "append_array",
68+ "append_col",
69+ "append_row",
70+ "arg",
71+ "asin",
72+ "asinh",
73+ "atan",
74+ "atan2",
75+ "atanh",
76+ "bernoulli_cdf",
77+ "bernoulli_lccdf",
78+ "bernoulli_lcdf",
79+ "bernoulli_logit_glm_lpmf",
80+ "bernoulli_logit_glm_lupmf",
81+ "bernoulli_logit_glm_rng",
82+ "bernoulli_logit_lpmf",
83+ "bernoulli_logit_lupmf",
84+ "bernoulli_logit_rng",
85+ "bernoulli_lpmf",
86+ "bernoulli_lupmf",
87+ "bernoulli_rng",
88+ "bessel_first_kind",
89+ "bessel_second_kind",
90+ "beta",
91+ "beta_binomial_cdf",
92+ "beta_binomial_lccdf",
93+ "beta_binomial_lcdf",
94+ "beta_binomial_lpmf",
95+ "beta_binomial_lupmf",
96+ "beta_binomial_rng",
97+ "beta_cdf",
98+ "beta_lccdf",
99+ "beta_lcdf",
100+ "beta_lpdf",
101+ "beta_lupdf",
102+ "beta_neg_binomial_cdf",
103+ "beta_neg_binomial_lccdf",
104+ "beta_neg_binomial_lcdf",
105+ "beta_neg_binomial_lpmf",
106+ "beta_neg_binomial_lupmf",
107+ "beta_neg_binomial_rng",
108+ "beta_proportion_lccdf",
109+ "beta_proportion_lcdf",
110+ "beta_proportion_lpdf",
111+ "beta_proportion_lupdf",
112+ "beta_proportion_rng",
113+ "beta_rng",
114+ "binary_log_loss",
115+ "binomial_cdf",
116+ "binomial_lccdf",
117+ "binomial_lcdf",
118+ "binomial_logit_glm_lpmf",
119+ "binomial_logit_glm_lupmf",
120+ "binomial_logit_lpmf",
121+ "binomial_logit_lupmf",
122+ "binomial_lpmf",
123+ "binomial_lupmf",
124+ "binomial_rng",
125+ "block",
126+ "categorical_logit_glm_lpmf",
127+ "categorical_logit_glm_lupmf",
128+ "categorical_logit_lpmf",
129+ "categorical_logit_lupmf",
130+ "categorical_logit_rng",
131+ "categorical_lpmf",
132+ "categorical_lupmf",
133+ "categorical_rng",
134+ "cauchy_cdf",
135+ "cauchy_lccdf",
136+ "cauchy_lcdf",
137+ "cauchy_lpdf",
138+ "cauchy_lupdf",
139+ "cauchy_rng",
140+ "cbrt",
141+ "ceil",
142+ "chi_square_cdf",
143+ "chi_square_lccdf",
144+ "chi_square_lcdf",
145+ "chi_square_lpdf",
146+ "chi_square_lupdf",
147+ "chi_square_rng",
148+ "chol2inv",
149+ "cholesky_decompose",
150+ "cholesky_factor_corr_constrain",
151+ "cholesky_factor_corr_jacobian",
152+ "cholesky_factor_corr_unconstrain",
153+ "cholesky_factor_cov_constrain",
154+ "cholesky_factor_cov_jacobian",
155+ "cholesky_factor_cov_unconstrain",
156+ "choose",
157+ "col",
158+ "cols",
159+ "columns_dot_product",
160+ "columns_dot_self",
161+ "complex_schur_decompose",
162+ "complex_schur_decompose_t",
163+ "complex_schur_decompose_u",
164+ "conj",
165+ "corr_matrix_constrain",
166+ "corr_matrix_jacobian",
167+ "corr_matrix_unconstrain",
168+ "cos",
169+ "cosh",
170+ "cov_exp_quad",
171+ "cov_matrix_constrain",
172+ "cov_matrix_jacobian",
173+ "cov_matrix_unconstrain",
174+ "crossprod",
175+ "csr_extract",
176+ "csr_extract_u",
177+ "csr_extract_v",
178+ "csr_extract_w",
179+ "csr_matrix_times_vector",
180+ "csr_to_dense_matrix",
181+ "cumulative_sum",
182+ "dae",
183+ "dae_tol",
184+ "determinant",
185+ "diag_matrix",
186+ "diag_post_multiply",
187+ "diag_pre_multiply",
188+ "diagonal",
189+ "digamma",
190+ "dims",
191+ "dirichlet_lpdf",
192+ "dirichlet_lupdf",
193+ "dirichlet_multinomial_lpmf",
194+ "dirichlet_multinomial_lupmf",
195+ "dirichlet_multinomial_rng",
196+ "dirichlet_rng",
197+ "discrete_range_cdf",
198+ "discrete_range_lccdf",
199+ "discrete_range_lcdf",
200+ "discrete_range_lpmf",
201+ "discrete_range_lupmf",
202+ "discrete_range_rng",
203+ "distance",
204+ "dot_product",
205+ "dot_self",
206+ "double_exponential_cdf",
207+ "double_exponential_lccdf",
208+ "double_exponential_lcdf",
209+ "double_exponential_lpdf",
210+ "double_exponential_lupdf",
211+ "double_exponential_rng",
212+ "e",
213+ "eigendecompose",
214+ "eigendecompose_sym",
215+ "eigenvalues",
216+ "eigenvalues_sym",
217+ "eigenvectors",
218+ "eigenvectors_sym",
219+ "erf",
220+ "erfc",
221+ "exp",
222+ "exp2",
223+ "exp_mod_normal_cdf",
224+ "exp_mod_normal_lccdf",
225+ "exp_mod_normal_lcdf",
226+ "exp_mod_normal_lpdf",
227+ "exp_mod_normal_lupdf",
228+ "exp_mod_normal_rng",
229+ "expm1",
230+ "exponential_cdf",
231+ "exponential_lccdf",
232+ "exponential_lcdf",
233+ "exponential_lpdf",
234+ "exponential_lupdf",
235+ "exponential_rng",
236+ "falling_factorial",
237+ "fdim",
238+ "fft",
239+ "fft2",
240+ "floor",
241+ "fma",
242+ "fmax",
243+ "fmin",
244+ "fmod",
245+ "frechet_cdf",
246+ "frechet_lccdf",
247+ "frechet_lcdf",
248+ "frechet_lpdf",
249+ "frechet_lupdf",
250+ "frechet_rng",
251+ "gamma_cdf",
252+ "gamma_lccdf",
253+ "gamma_lcdf",
254+ "gamma_lpdf",
255+ "gamma_lupdf",
256+ "gamma_p",
257+ "gamma_q",
258+ "gamma_rng",
259+ "gaussian_dlm_obs_lpdf",
260+ "gaussian_dlm_obs_lupdf",
261+ "generalized_inverse",
262+ "generate_laplace_options",
263+ "get_imag",
264+ "get_real",
265+ "gp_dot_prod_cov",
266+ "gp_exp_quad_cov",
267+ "gp_exponential_cov",
268+ "gp_matern23_cov",
269+ "gp_matern52_cov",
270+ "gp_periodic_cov",
271+ "gumbel_cdf",
272+ "gumbel_lccdf",
273+ "gumbel_lcdf",
274+ "gumbel_lpdf",
275+ "gumbel_lupdf",
276+ "gumbel_rng",
277+ "head",
278+ "hmm_hidden_state_prob",
279+ "hmm_latent_rng",
280+ "hmm_marginal",
281+ "hypergeometric_1F0",
282+ "hypergeometric_2F1",
283+ "hypergeometric_3F2",
284+ "hypergeometric_lpmf",
285+ "hypergeometric_lupmf",
286+ "hypergeometric_pFq",
287+ "hypergeometric_rng",
288+ "hypot",
289+ "identity_matrix",
290+ "inc_beta",
291+ "int_step",
292+ "integrate_1d",
293+ "integrate_ode",
294+ "integrate_ode_adams",
295+ "integrate_ode_bdf",
296+ "integrate_ode_rk45",
297+ "inv",
298+ "inv_Phi",
299+ "inv_chi_square_cdf",
300+ "inv_chi_square_lccdf",
301+ "inv_chi_square_lcdf",
302+ "inv_chi_square_lpdf",
303+ "inv_chi_square_lupdf",
304+ "inv_chi_square_rng",
305+ "inv_cloglog",
306+ "inv_erfc",
307+ "inv_fft",
308+ "inv_fft2",
309+ "inv_gamma_cdf",
310+ "inv_gamma_lccdf",
311+ "inv_gamma_lcdf",
312+ "inv_gamma_lpdf",
313+ "inv_gamma_lupdf",
314+ "inv_gamma_rng",
315+ "inv_inc_beta",
316+ "inv_logit",
317+ "inv_sqrt",
318+ "inv_square",
319+ "inv_wishart_cholesky_lpdf",
320+ "inv_wishart_cholesky_lupdf",
321+ "inv_wishart_cholesky_rng",
322+ "inv_wishart_lpdf",
323+ "inv_wishart_lupdf",
324+ "inv_wishart_rng",
325+ "inverse",
326+ "inverse_spd",
327+ "is_inf",
328+ "is_nan",
329+ "lambert_w0",
330+ "lambert_wm1",
331+ "laplace_latent_bernoulli_logit_rng",
332+ "laplace_latent_neg_binomial_2_log_rng",
333+ "laplace_latent_poisson_log_rng",
334+ "laplace_latent_rng",
335+ "laplace_latent_rng_tol",
336+ "laplace_latent_tol_bernoulli_logit_rng",
337+ "laplace_latent_tol_neg_binomial_2_log_rng",
338+ "laplace_latent_tol_poisson_log_rng",
339+ "laplace_marginal",
340+ "laplace_marginal_bernoulli_logit_lpmf",
341+ "laplace_marginal_bernoulli_logit_lupmf",
342+ "laplace_marginal_neg_binomial_2_log_lpmf",
343+ "laplace_marginal_neg_binomial_2_log_lupmf",
344+ "laplace_marginal_poisson_log_lpmf",
345+ "laplace_marginal_poisson_log_lupmf",
346+ "laplace_marginal_tol",
347+ "laplace_marginal_tol_bernoulli_logit_lpmf",
348+ "laplace_marginal_tol_bernoulli_logit_lupmf",
349+ "laplace_marginal_tol_neg_binomial_2_log_lpmf",
350+ "laplace_marginal_tol_neg_binomial_2_log_lupmf",
351+ "laplace_marginal_tol_poisson_log_lpmf",
352+ "laplace_marginal_tol_poisson_log_lupmf",
353+ "lbeta",
354+ "lchoose",
355+ "ldexp",
356+ "lgamma",
357+ "linspaced_array",
358+ "linspaced_int_array",
359+ "linspaced_row_vector",
360+ "linspaced_vector",
361+ "lkj_corr_cholesky_lpdf",
362+ "lkj_corr_cholesky_lupdf",
363+ "lkj_corr_cholesky_rng",
364+ "lkj_corr_lpdf",
365+ "lkj_corr_lupdf",
366+ "lkj_corr_rng",
367+ "lmgamma",
368+ "lmultiply",
369+ "log",
370+ "log10",
371+ "log1m",
372+ "log1m_exp",
373+ "log1m_inv_logit",
374+ "log1p",
375+ "log1p_exp",
376+ "log2",
377+ "log_determinant",
378+ "log_diff_exp",
379+ "log_falling_factorial",
380+ "log_inv_logit",
381+ "log_inv_logit_diff",
382+ "log_mix",
383+ "log_modified_bessel_first_kind",
384+ "log_rising_factorial",
385+ "log_softmax",
386+ "log_sum_exp",
387+ "logistic_cdf",
388+ "logistic_lccdf",
389+ "logistic_lcdf",
390+ "logistic_lpdf",
391+ "logistic_lupdf",
392+ "logistic_rng",
393+ "logit",
394+ "loglogistic_cdf",
395+ "loglogistic_lpdf",
396+ "loglogistic_rng",
397+ "lognormal_cdf",
398+ "lognormal_lccdf",
399+ "lognormal_lcdf",
400+ "lognormal_lpdf",
401+ "lognormal_lupdf",
402+ "lognormal_rng",
403+ "lower_bound_constrain",
404+ "lower_bound_jacobian",
405+ "lower_bound_unconstrain",
406+ "lower_upper_bound_constrain",
407+ "lower_upper_bound_jacobian",
408+ "lower_upper_bound_unconstrain",
409+ "machine_precision",
410+ "map_rect",
411+ "matrix_exp",
412+ "matrix_exp_multiply",
413+ "matrix_power",
414+ "max",
415+ "mdivide_left_spd",
416+ "mdivide_left_tri_low",
417+ "mdivide_right_spd",
418+ "mdivide_right_tri_low",
419+ "mean",
420+ "min",
421+ "modified_bessel_first_kind",
422+ "modified_bessel_second_kind",
423+ "multi_gp_cholesky_lpdf",
424+ "multi_gp_cholesky_lupdf",
425+ "multi_gp_lpdf",
426+ "multi_gp_lupdf",
427+ "multi_normal_cholesky_lpdf",
428+ "multi_normal_cholesky_lupdf",
429+ "multi_normal_cholesky_rng",
430+ "multi_normal_lpdf",
431+ "multi_normal_lupdf",
432+ "multi_normal_prec_lpdf",
433+ "multi_normal_prec_lupdf",
434+ "multi_normal_rng",
435+ "multi_student_cholesky_t_rng",
436+ "multi_student_t_cholesky_lpdf",
437+ "multi_student_t_cholesky_lupdf",
438+ "multi_student_t_cholesky_rng",
439+ "multi_student_t_lpdf",
440+ "multi_student_t_lupdf",
441+ "multi_student_t_rng",
442+ "multinomial_logit_lpmf",
443+ "multinomial_logit_lupmf",
444+ "multinomial_logit_rng",
445+ "multinomial_lpmf",
446+ "multinomial_lupmf",
447+ "multinomial_rng",
448+ "multiply_lower_tri_self_transpose",
449+ "neg_binomial_2_cdf",
450+ "neg_binomial_2_lccdf",
451+ "neg_binomial_2_lcdf",
452+ "neg_binomial_2_log_glm_lpmf",
453+ "neg_binomial_2_log_glm_lupmf",
454+ "neg_binomial_2_log_lpmf",
455+ "neg_binomial_2_log_lupmf",
456+ "neg_binomial_2_log_rng",
457+ "neg_binomial_2_lpmf",
458+ "neg_binomial_2_lupmf",
459+ "neg_binomial_2_rng",
460+ "neg_binomial_cdf",
461+ "neg_binomial_lccdf",
462+ "neg_binomial_lcdf",
463+ "neg_binomial_lpmf",
464+ "neg_binomial_lupmf",
465+ "neg_binomial_rng",
466+ "negative_infinity",
467+ "norm",
468+ "norm1",
469+ "norm2",
470+ "normal_cdf",
471+ "normal_id_glm_lpdf",
472+ "normal_id_glm_lupdf",
473+ "normal_lccdf",
474+ "normal_lcdf",
475+ "normal_lpdf",
476+ "normal_lupdf",
477+ "normal_rng",
478+ "not_a_number",
479+ "num_elements",
480+ "ode_adams",
481+ "ode_adams_tol",
482+ "ode_adjoint_tol_ctl",
483+ "ode_bdf",
484+ "ode_bdf_tol",
485+ "ode_ckrk",
486+ "ode_ckrk_tol",
487+ "ode_rk45",
488+ "ode_rk45_tol",
489+ "offset_multiplier_constrain",
490+ "offset_multiplier_jacobian",
491+ "offset_multiplier_unconstrain",
492+ "one_hot_array",
493+ "one_hot_int_array",
494+ "one_hot_row_vector",
495+ "one_hot_vector",
496+ "ones_array",
497+ "ones_int_array",
498+ "ones_row_vector",
499+ "ones_vector",
500+ "ordered_constrain",
501+ "ordered_jacobian",
502+ "ordered_logistic_glm_lpmf",
503+ "ordered_logistic_glm_lupmf",
504+ "ordered_logistic_lpmf",
505+ "ordered_logistic_lupmf",
506+ "ordered_logistic_rng",
507+ "ordered_probit_lpmf",
508+ "ordered_probit_lupmf",
509+ "ordered_probit_rng",
510+ "ordered_unconstrain",
511+ "owens_t",
512+ "pareto_cdf",
513+ "pareto_lccdf",
514+ "pareto_lcdf",
515+ "pareto_lpdf",
516+ "pareto_lupdf",
517+ "pareto_rng",
518+ "pareto_type_2_cdf",
519+ "pareto_type_2_lccdf",
520+ "pareto_type_2_lcdf",
521+ "pareto_type_2_lpdf",
522+ "pareto_type_2_lupdf",
523+ "pareto_type_2_rng",
524+ "pi",
525+ "poisson_cdf",
526+ "poisson_lccdf",
527+ "poisson_lcdf",
528+ "poisson_log_glm_lpmf",
529+ "poisson_log_glm_lupmf",
530+ "poisson_log_lpmf",
531+ "poisson_log_lupmf",
532+ "poisson_log_rng",
533+ "poisson_lpmf",
534+ "poisson_lupmf",
535+ "poisson_rng",
536+ "polar",
537+ "positive_infinity",
538+ "positive_ordered_constrain",
539+ "positive_ordered_jacobian",
540+ "positive_ordered_unconstrain",
541+ "pow",
542+ "prod",
543+ "proj",
544+ "qr",
545+ "qr_Q",
546+ "qr_R",
547+ "qr_thin",
548+ "qr_thin_Q",
549+ "qr_thin_R",
550+ "quad_form",
551+ "quad_form_diag",
552+ "quad_form_sym",
553+ "quantile",
554+ "rank",
555+ "rayleigh_cdf",
556+ "rayleigh_lccdf",
557+ "rayleigh_lcdf",
558+ "rayleigh_lpdf",
559+ "rayleigh_lupdf",
560+ "rayleigh_rng",
561+ "reduce_sum",
562+ "reduce_sum_static",
563+ "rep_array",
564+ "rep_matrix",
565+ "rep_row_vector",
566+ "rep_vector",
567+ "reverse",
568+ "rising_factorial",
569+ "round",
570+ "row",
571+ "rows",
572+ "rows_dot_product",
573+ "rows_dot_self",
574+ "scale_matrix_exp_multiply",
575+ "scaled_inv_chi_square_cdf",
576+ "scaled_inv_chi_square_lccdf",
577+ "scaled_inv_chi_square_lcdf",
578+ "scaled_inv_chi_square_lpdf",
579+ "scaled_inv_chi_square_lupdf",
580+ "scaled_inv_chi_square_rng",
581+ "sd",
582+ "segment",
583+ "simplex_constrain",
584+ "simplex_jacobian",
585+ "simplex_unconstrain",
586+ "sin",
587+ "singular_values",
588+ "sinh",
589+ "size",
590+ "skew_double_exponential_cdf",
591+ "skew_double_exponential_lccdf",
592+ "skew_double_exponential_lcdf",
593+ "skew_double_exponential_lpdf",
594+ "skew_double_exponential_lupdf",
595+ "skew_double_exponential_rng",
596+ "skew_normal_cdf",
597+ "skew_normal_lccdf",
598+ "skew_normal_lcdf",
599+ "skew_normal_lpdf",
600+ "skew_normal_lupdf",
601+ "skew_normal_rng",
602+ "softmax",
603+ "solve_newton",
604+ "solve_newton_tol",
605+ "solve_powell",
606+ "solve_powell_tol",
607+ "sort_asc",
608+ "sort_desc",
609+ "sort_indices_asc",
610+ "sort_indices_desc",
611+ "sqrt",
612+ "sqrt2",
613+ "square",
614+ "squared_distance",
615+ "std_normal_cdf",
616+ "std_normal_lccdf",
617+ "std_normal_lcdf",
618+ "std_normal_log_qf",
619+ "std_normal_lpdf",
620+ "std_normal_lupdf",
621+ "std_normal_qf",
622+ "std_normal_rng",
623+ "step",
624+ "stochastic_column_constrain",
625+ "stochastic_column_jacobian",
626+ "stochastic_column_unconstrain",
627+ "stochastic_row_constrain",
628+ "stochastic_row_jacobian",
629+ "stochastic_row_unconstrain",
630+ "student_t_cdf",
631+ "student_t_lccdf",
632+ "student_t_lcdf",
633+ "student_t_lpdf",
634+ "student_t_lupdf",
635+ "student_t_rng",
636+ "sub_col",
637+ "sub_row",
638+ "sum",
639+ "sum_to_zero_constrain",
640+ "sum_to_zero_jacobian",
641+ "sum_to_zero_unconstrain",
642+ "svd",
643+ "svd_U",
644+ "svd_V",
645+ "symmetrize_from_lower_tri",
646+ "tail",
647+ "tan",
648+ "tanh",
649+ "target",
650+ "tcrossprod",
651+ "tgamma",
652+ "to_array_1d",
653+ "to_array_2d",
654+ "to_complex",
655+ "to_int",
656+ "to_matrix",
657+ "to_row_vector",
658+ "to_vector",
659+ "trace",
660+ "trace_dot",
661+ "trace_gen_quad_form",
662+ "trace_quad_form",
663+ "trigamma",
664+ "trunc",
665+ "uniform_cdf",
666+ "uniform_lccdf",
667+ "uniform_lcdf",
668+ "uniform_lpdf",
669+ "uniform_lupdf",
670+ "uniform_rng",
671+ "uniform_simplex",
672+ "unit_vectors_constrain",
673+ "unit_vectors_jacobian",
674+ "unit_vectors_unconstrain",
675+ "upper_bound_constrain",
676+ "upper_bound_jacobian",
677+ "upper_bound_unconstrain",
678+ "variance",
679+ "von_mises_cdf",
680+ "von_mises_lccdf",
681+ "von_mises_lcdf",
682+ "von_mises_lpdf",
683+ "von_mises_lupdf",
684+ "von_mises_rng",
685+ "weibull_cdf",
686+ "weibull_lccdf",
687+ "weibull_lcdf",
688+ "weibull_lpdf",
689+ "weibull_lupdf",
690+ "weibull_rng",
691+ "wiener_lccdf_unnorm",
692+ "wiener_lcdf_unnorm",
693+ "wiener_lpdf",
694+ "wiener_lupdf",
695+ "wishart_cholesky_lpdf",
696+ "wishart_cholesky_lupdf",
697+ "wishart_cholesky_rng",
698+ "wishart_lpdf",
699+ "wishart_lupdf",
700+ "wishart_rng",
701+ "yule_simon_cdf",
702+ "yule_simon_lccdf",
703+ "yule_simon_lcdf",
704+ "yule_simon_lpmf",
705+ "yule_simon_lupmf",
706+ "yule_simon_rng",
707+ "zeros_array",
708+ "zeros_int_array",
709+ "zeros_row_vector",
710+ "zeros_vector",
711+];
712+
713+const SPECIAL_FUNCTIONS = ["print", "reject", "fatal_error"];
714+
715+const DISTRIBUTIONS = [
716+ "bernoulli",
717+ "bernoulli_logit",
718+ "bernoulli_logit_glm",
719+ "beta",
720+ "beta_binomial",
721+ "beta_neg_binomial",
722+ "beta_proportion",
723+ "binomial",
724+ "binomial_logit",
725+ "binomial_logit_glm",
726+ "categorical",
727+ "categorical_logit",
728+ "categorical_logit_glm",
729+ "cauchy",
730+ "chi_square",
731+ "dirichlet",
732+ "dirichlet_multinomial",
733+ "discrete_range",
734+ "double_exponential",
735+ "exp_mod_normal",
736+ "exponential",
737+ "frechet",
738+ "gamma",
739+ "gaussian_dlm_obs",
740+ "gumbel",
741+ "hypergeometric",
742+ "inv_chi_square",
743+ "inv_gamma",
744+ "inv_wishart",
745+ "inv_wishart_cholesky",
746+ "laplace_marginal_bernoulli_logit",
747+ "laplace_marginal_neg_binomial_2_log",
748+ "laplace_marginal_poisson_log",
749+ "laplace_marginal_tol_bernoulli_logit",
750+ "laplace_marginal_tol_neg_binomial_2_log",
751+ "laplace_marginal_tol_poisson_log",
752+ "lkj_corr",
753+ "lkj_corr_cholesky",
754+ "logistic",
755+ "loglogistic",
756+ "lognormal",
757+ "multi_gp",
758+ "multi_gp_cholesky",
759+ "multi_normal",
760+ "multi_normal_cholesky",
761+ "multi_normal_prec",
762+ "multi_student_t",
763+ "multi_student_t_cholesky",
764+ "multinomial",
765+ "multinomial_logit",
766+ "neg_binomial",
767+ "neg_binomial_2",
768+ "neg_binomial_2_log",
769+ "neg_binomial_2_log_glm",
770+ "normal",
771+ "normal_id_glm",
772+ "ordered_logistic",
773+ "ordered_logistic_glm",
774+ "ordered_probit",
775+ "pareto",
776+ "pareto_type_2",
777+ "poisson",
778+ "poisson_log",
779+ "poisson_log_glm",
780+ "rayleigh",
781+ "scaled_inv_chi_square",
782+ "skew_double_exponential",
783+ "skew_normal",
784+ "std_normal",
785+ "student_t",
786+ "uniform",
787+ "von_mises",
788+ "weibull",
789+ "wiener",
790+ "wishart",
791+ "wishart_cholesky",
792+ "yule_simon",
793+];
794+
795+const RANGE_CONSTRAINTS = ["lower", "upper", "offset", "multiplier"];
796+
797+const OPERATORS = [
798+ "=",
799+ ">",
800+ "<",
801+ "!",
802+ "~",
803+ "?",
804+ ":",
805+ "==",
806+ "<=",
807+ ">=",
808+ "!=",
809+ "&&",
810+ "||",
811+ "+",
812+ "-",
813+ "*",
814+ "/",
815+ "\\",
816+ "|",
817+ "^",
818+ "%",
819+ ">>",
820+ "+=",
821+ "-=",
822+ "*=",
823+ "/=",
824+ "&=",
825+ "|=",
826+ "^=",
827+ "%=",
828+];
829+
830+export const conf: monaco.languages.LanguageConfiguration = {
831+ comments: {
832+ lineComment: "//",
833+ blockComment: ["/*", "*/"],
834+ },
835+ brackets: [
836+ ["{", "}"],
837+ ["[", "]"],
838+ ["(", ")"],
839+ ],
840+ autoClosingPairs: [
841+ { open: "[", close: "]" },
842+ { open: "{", close: "}" },
843+ { open: "(", close: ")" },
844+ { open: "'", close: "'", notIn: ["string", "comment"] },
845+ { open: '"', close: '"', notIn: ["string"] },
846+ ],
847+ surroundingPairs: [
848+ { open: "{", close: "}" },
849+ { open: "[", close: "]" },
850+ { open: "(", close: ")" },
851+ { open: '"', close: '"' },
852+ { open: "'", close: "'" },
853+ ],
854+ indentationRules: {
855+ increaseIndentPattern: /(\{[^}\"\']*$) | (\[[^\]\"\']*$) | (\([^)\"\']*$)/,
856+ decreaseIndentPattern: /^[\}\]\)]/,
857+ },
858+ folding: {
859+ markers: {
860+ start:
861+ /^[functions|data|transformed\s+data|parameters|transformed\s+parameters|model|generated\s+quantities]\s\{$/,
862+ end: /^\}$/,
863+ },
864+ },
865+ wordPattern: /[a-zA-Z_][a-zA-Z0-9_]*/,
866+};
867+
868+export const language = <monaco.languages.IMonarchLanguage>{
869+ defaultToken: "",
870+ tokenPostfix: ".stan",
871+
872+ brackets: [
873+ { token: "delimiter.curly", open: "{", close: "}" },
874+ { token: "delimiter.parenthesis", open: "(", close: ")" },
875+ { token: "delimiter.square", open: "[", close: "]" },
876+ { token: "delimiter.angle", open: "<", close: ">" },
877+ ],
878+
879+ blocks: BLOCKS.join("|"),
880+ functions: [...FUNCTIONS, ...SPECIAL_FUNCTIONS].join("|"),
881+ distributions: DISTRIBUTIONS.join("|"),
882+ keywords: STATEMENTS,
883+ types: TYPES,
884+ constraints: RANGE_CONSTRAINTS,
885+
886+ operators: OPERATORS,
887+ symbols: /[=><!~?:&|+\-*\/\^%]+/,
888+
889+ tokenizer: {
890+ root: [
891+ [/(@blocks)\s*{/, "attribute.value.$0"],
892+
893+ [/(@functions)\s*\(/, "tag.function.$0"],
894+ [/~\s*(@distributions)\s*\(/, "tag.distribution.$1"],
895+
896+ // identifiers and keywords
897+ [
898+ /[a-zA-Z_]\w*/,
899+ {
900+ cases: {
901+ "@types": "type.$0",
902+ "@constraints": "variable.predefined.$0",
903+ "@keywords": "keyword.$0",
904+ "@default": "identifier",
905+ },
906+ },
907+ ],
908+
909+ // whitespace
910+ { include: "@whitespace" },
911+
912+ // delimiters and operators
913+ [/[{}()<>\[\]]/, "@brackets"],
914+ [
915+ /@symbols/,
916+ {
917+ cases: {
918+ "@operators": "delimiter",
919+ "@default": "",
920+ },
921+ },
922+ ],
923+
924+ // numbers
925+ [/\d*\d+[eE]([\-+]?\d+)?i?/, "number.float"],
926+ [/\d*\.\d+([eE][\-+]?\d+)?i?/, "number.float"],
927+ [/\d[\d']*\di?/, "number"],
928+ [/\di?/, "number"],
929+
930+ // strings
931+ [/"/, "string", "@string"],
932+
933+ // characters
934+ [/'[^\\']'/, "string"],
935+ [/'/, "string.invalid"],
936+ ],
937+
938+ whitespace: [
939+ [/[ \t\r\n]+/, ""],
940+ [/\/\*/, "comment", "@comment"],
941+ [/\/\/.*\\$/, "comment", "@linecomment"],
942+ [/\/\/.*$/, "comment"],
943+ ],
944+
945+ comment: [
946+ [/[^\/*]+/, "comment"],
947+ [/\*\//, "comment", "@pop"],
948+ [/[\/*]/, "comment"],
949+ ],
950+
951+ //For use with continuous line comments
952+ linecomment: [
953+ [/.*[^\\]$/, "comment", "@pop"],
954+ [/[^]+/, "comment"],
955+ ],
956+ string: [
957+ [/[^\\"]+/, "string"],
958+ [/"/, "string", "@pop"],
959+ ],
960+ },
961+};
962+
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "extends": "minwebide/tsconfig.base.json",
3+ "compilerOptions": {
4+ "types": ["vite/client"]
5+ },
6+ "include": ["src"]
7+}
vite.config.tsadded+44−0View file
@@ -0,0 +1,44 @@
1+import { fileURLToPath } from 'node:url';
2+import { defineConfig, mergeConfig } from 'vite';
3+import { minwebide } from 'minwebide/vite';
4+
5+// Cross-origin isolation makes SharedArrayBuffer available — the compiled
6+// Stan modules are built with pthreads (chains run in parallel threads).
7+// Dev/preview get it from plain response headers — no service worker
8+// involved. Production builds are for GitHub Pages, which can't set headers,
9+// so only there the coi-serviceworker is injected.
10+const coiHeaders = {
11+ 'Cross-Origin-Embedder-Policy': 'require-corp',
12+ 'Cross-Origin-Opener-Policy': 'same-origin',
13+};
14+
15+const injectCoiServiceWorker = {
16+ name: 'inject-coi-serviceworker',
17+ apply: 'build' as const,
18+ transformIndexHtml() {
19+ // relative src so it resolves under the DEPLOY_BASE sub-path
20+ return [{ tag: 'script', attrs: { src: 'coi-serviceworker.js' }, injectTo: 'head' as const }];
21+ },
22+};
23+
24+// DEPLOY_BASE is set by CI when building for GitHub Pages
25+// (the site is served from /stan-web-ide/, not the domain root).
26+//
27+// Ports: the stock stan-wasm-server docker image only allows the origins
28+// http://127.0.0.1:3000 and http://127.0.0.1:4173 in its CORS config, so
29+// dev runs on 3000 (open the 127.0.0.1 URL, not localhost) and preview on
30+// Vite's default 4173.
31+export default defineConfig(mergeConfig(minwebide(), {
32+ base: process.env.DEPLOY_BASE ?? '/',
33+ plugins: [injectCoiServiceWorker],
34+ resolve: {
35+ alias: {
36+ // stan-language-server imports node's 'path' (join only)
37+ path: fileURLToPath(new URL('./src/stan/pathShim.ts', import.meta.url)),
38+ },
39+ },
40+ // host 127.0.0.1 (not 'localhost', which may bind IPv6-only): the page
41+ // origin must be exactly http://127.0.0.1:<port> for the server's CORS
42+ server: { host: '127.0.0.1', port: 3000, headers: coiHeaders },
43+ preview: { host: '127.0.0.1', port: 4173, headers: coiHeaders },
44+}));