From d6c1c8f2a1e5d090889de72b335db7fc9122232f Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Wed, 8 Jul 2026 14:27:04 +0000 Subject: [PATCH] feat: language server intelligence in the playground editor Give the playground editor real diagnostics, hover, completion and go-to-definition for `.marko`, `.ts`/`.js` and `.css` by running the Marko language server (`@marko/language-server`) in a Web Worker. The website owns the browser build: `src/util/lsp/server.ts` assembles the embedded services from `@marko/language-server/browser` and supplies the environment -- a virtual filesystem (`vfs.ts`), a `ts.System` over it (`system.ts`), Node-builtin shims (`node-shims/`), and the injected Marko compiler (`project-defaults.ts`) -- then exposes them over a small JSON-RPC to a main-thread client (`client.ts`) that syncs documents and drives CodeMirror (`codemirror.ts`). A Vite plugin (`assets-plugin.ts`) seeds the worker's virtual disk with TypeScript's `lib.*.d.ts` and the Marko type definitions at build time, and maps the Node builtins the server touches to browser shims (scoped to the client/worker so the Node server build is untouched). Custom tags resolve the way they do at runtime. The client keys documents on the same `/tags/` root the build workspace uses, so Marko's tag discovery treats sibling `.marko` files as mutually referenceable custom tags. The compiler's taglib finder scans for them through its own `require("fs")`, which the dependency optimizer would otherwise stub out, so that `fs` is shimmed during pre-bundling too and the virtual disk lives on a `globalThis` singleton (the pre-bundled compiler holds its own copy of the module and must see the same files). A `` referencing a sibling `foo.marko` now gets tag-name and attribute completion, attribute typing, hover, diagnostics and go-to-definition. A `.marko` file can also import a standalone `.ts` helper and run it: the in-browser preview build resolves the extensionless import to its TypeScript source and strips the types with the compiler's bundled Babel, so what the language server type-checks is what the preview executes. Completions open on trigger characters and apply auto-imports; hover popups stay within the viewport; Cmd/Ctrl+click or F12 jumps to a definition, switching tabs for a cross-file target. Quick fixes offered by the language server appear in the diagnostic tooltip. --- cspell.json | 1 + package-lock.json | 695 +++++++++++++++++- package.json | 7 + .../tags/playground/tags/editor/editor.marko | 56 +- .../tags/editor/editor.styles.module.scss | 32 + src/types/stubs.d.ts | 7 + src/util/codemirror.ts | 6 +- src/util/lsp/assets-plugin.ts | 185 +++++ src/util/lsp/client.ts | 293 ++++++++ src/util/lsp/codemirror.ts | 438 +++++++++++ src/util/lsp/globals.ts | 50 ++ src/util/lsp/node-shims/fs-promises.ts | 66 ++ src/util/lsp/node-shims/fs.ts | 76 ++ src/util/lsp/node-shims/module.ts | 16 + src/util/lsp/node-shims/url.ts | 26 + src/util/lsp/project-defaults.ts | 27 + src/util/lsp/server.ts | 263 +++++++ src/util/lsp/server.worker.ts | 18 + src/util/lsp/system.ts | 78 ++ src/util/lsp/vfs.ts | 100 +++ src/util/lsp/worker-error-report.ts | 22 + src/util/workspace.ts | 12 +- src/util/workspace/main-plugin.ts | 14 + src/util/workspace/script-plugin.ts | 39 + vite.config.ts | 21 +- 25 files changed, 2528 insertions(+), 20 deletions(-) create mode 100644 src/util/lsp/assets-plugin.ts create mode 100644 src/util/lsp/client.ts create mode 100644 src/util/lsp/codemirror.ts create mode 100644 src/util/lsp/globals.ts create mode 100644 src/util/lsp/node-shims/fs-promises.ts create mode 100644 src/util/lsp/node-shims/fs.ts create mode 100644 src/util/lsp/node-shims/module.ts create mode 100644 src/util/lsp/node-shims/url.ts create mode 100644 src/util/lsp/project-defaults.ts create mode 100644 src/util/lsp/server.ts create mode 100644 src/util/lsp/server.worker.ts create mode 100644 src/util/lsp/system.ts create mode 100644 src/util/lsp/vfs.ts create mode 100644 src/util/lsp/worker-error-report.ts create mode 100644 src/util/workspace/script-plugin.ts diff --git a/cspell.json b/cspell.json index 438c6c35f9..60452b401d 100644 --- a/cspell.json +++ b/cspell.json @@ -67,6 +67,7 @@ "taglib", "touchstart", "tsrx", + "tsserverlibrary", "TTFB", "unitless", "webkitdirectory", diff --git a/package-lock.json b/package-lock.json index 8175237b57..1a5ae72a85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "@fortawesome/free-brands-svg-icons": "^7.3.0", "@fortawesome/free-solid-svg-icons": "^7.3.0", "@jridgewell/source-map": "^0.3.11", + "@marko/language-server": "^3.3.2", + "@marko/language-tools": "^2.6.3", "@marko/run": "^0.11.0", "@marko/run-adapter-static": "^2.0.6", "@marko/type-check": "^3.1.1", @@ -57,10 +59,66 @@ "sass-embedded": "^1.100.0", "shiki": "^4.3.0", "terser": "^5.48.0", + "typescript": "^6.0.3", "util": "./shim/util", + "vscode-css-languageservice": "^6.3.10", + "vscode-languageserver": "^10.0.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", "writable-dom": "^1.0.6" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -71,6 +129,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", @@ -942,6 +1013,31 @@ "@csstools/css-tokenizer": "^4.0.0" } }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", @@ -2767,6 +2863,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fontsource/ubuntu": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/ubuntu/-/ubuntu-5.2.8.tgz", @@ -2931,16 +3045,16 @@ "license": "MIT" }, "node_modules/@marko/compiler": { - "version": "5.40.0", - "resolved": "https://registry.npmjs.org/@marko/compiler/-/compiler-5.40.0.tgz", - "integrity": "sha512-8oSqjLeeE/Xdqcfagb3PNP/y9xZNQhFM0Ic+5DpU/EVsxxv2KJ8eipewOAe4+3FWr67fVszVEcTTPvatDZMwbA==", + "version": "5.40.1", + "resolved": "https://registry.npmjs.org/@marko/compiler/-/compiler-5.40.1.tgz", + "integrity": "sha512-s9a8qPxq5FdxWK5FqohqHM6HFkOdtz8xrGsdFStfT6/b3t/Bm8JD7whguNT0/66VMJiEhZidRkB0xXmmZl0J2Q==", "dev": true, "license": "MIT", "dependencies": { - "@luxass/strip-json-comments": "^1.4.0", + "@luxass/strip-json-comments": "^2.0.1", "complain": "^1.6.1", "he": "^1.2.0", - "htmljs-parser": "^5.10.2", + "htmljs-parser": "^5.12.1", "jsesc": "^3.1.0", "kleur": "^4.1.5", "lasso-package-root": "^1.0.1", @@ -2955,16 +3069,83 @@ "node": "18 || 20 || >=22" } }, + "node_modules/@marko/compiler/node_modules/@luxass/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@luxass/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-y0rBJcC8idzWD8qnBOXuTAOG/Fudu0reZs/6INyn6aPxCyvk484x9Ib2TAnJtsol2A6IaQB4kmxBSZlR0Crf0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@marko/language-server": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@marko/language-server/-/language-server-3.3.2.tgz", + "integrity": "sha512-HZyY7XIPuNstNHuQ0n4Ll5xO5iWyf+lViCkUREgVpB07FllGK0i4xpJ08+Cr8wOUumGmPOAbeOMx3cg6K09d8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@luxass/strip-json-comments": "^1.4.0", + "@marko/compiler": "^5.39.66", + "@marko/language-tools": "^2.6.3", + "axe-core": "^4.12.1", + "htmljs-parser": "^5.12.1", + "jsdom": "^29.1.1", + "marko": "^5.39.11", + "prettier": "^3.8.4", + "prettier-plugin-marko": "^4.0.9", + "relative-import-path": "^1.0.0", + "typescript": "^6.0.3", + "vscode-css-languageservice": "^6.3.10", + "vscode-languageserver": "^10.0.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0" + }, + "bin": { + "marko-language-server": "bin.js" + } + }, + "node_modules/@marko/language-server/node_modules/marko": { + "version": "5.39.14", + "resolved": "https://registry.npmjs.org/marko/-/marko-5.39.14.tgz", + "integrity": "sha512-ITiDDioNfhAZFVpW/+4ZdI5OzYoAeqpOhDWj4AquTpgE02AIFV70lDUqYRehxzg5ucz7QYplS+jbTK/YuCjAEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@marko/compiler": "^5.40.1", + "@marko/runtime-tags": "^6.1.19", + "app-module-path": "^2.2.0", + "argly": "^1.2.0", + "browser-refresh-client": "1.1.4", + "complain": "^1.6.1", + "csstype": "^3.2.3", + "events-light": "^1.0.5", + "listener-tracker": "^2.0.0", + "magic-string": "^0.30.21", + "minimatch": "^10.2.5", + "raptor-util": "^3.2.0", + "resolve-from": "^5.0.0", + "self-closing-tags": "^1.0.1", + "warp10": "^2.1.0" + }, + "bin": { + "markoc": "bin/markoc" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@marko/language-tools": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@marko/language-tools/-/language-tools-2.6.2.tgz", - "integrity": "sha512-bm4PlcKRLacS7wu7UIPRrOdLOUbKX3UbDBcuzNdFfzTOGrt2xStmzzp3iSQkVEFCVijPRF7AkzUmT++2YRF+qQ==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@marko/language-tools/-/language-tools-2.6.3.tgz", + "integrity": "sha512-/+tXak2EDXDUbaP76hAS1Bm6STo75ByKWqACrk5MvfuEIMBbEZ6637QtTwMy/LCn4u5wm1jS7dvgxYwT2VLf9g==", "dev": true, "license": "MIT", "dependencies": { "@luxass/strip-json-comments": "^1.4.0", "@marko/compiler": "^5.39.66", - "htmljs-parser": "^5.11.0", + "htmljs-parser": "^5.12.1", "relative-import-path": "^1.0.0" } }, @@ -3029,6 +3210,22 @@ "@marko/run": "^0.7||^0.8||^0.9||^0.10||^0.11" } }, + "node_modules/@marko/runtime-tags": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@marko/runtime-tags/-/runtime-tags-6.2.3.tgz", + "integrity": "sha512-V02omTVRosjjBmcV+sKsEuaIOlEeBjZb7xN33Yq5rp6Wm+qnIQfrLeBGUJpQ7o8fe53HIHM5OFUdU0MtRpMa1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@marko/compiler": "^5.39.66", + "csstype": "^3.2.3", + "fastest-levenshtein": "^1.0.16", + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@marko/type-check": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@marko/type-check/-/type-check-3.1.1.tgz", @@ -4367,6 +4564,13 @@ "dev": true, "license": "ISC" }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4420,6 +4624,13 @@ "node": ">= 8" } }, + "node_modules/app-module-path": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", + "integrity": "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4427,6 +4638,13 @@ "dev": true, "license": "MIT" }, + "node_modules/argly": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/argly/-/argly-1.2.0.tgz", + "integrity": "sha512-+F3InkcH2XOGK7Jf/ZQis4cwZ4wbfmpBKo5J+SAA9bglT1gVd0e9hroHWarU076pJrAfs8JKuRPwDqwPBOKHnw==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4455,6 +4673,16 @@ "resolved": "shim/assert", "link": true }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4499,6 +4727,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4522,6 +4760,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -4548,6 +4796,13 @@ "node": ">=8" } }, + "node_modules/browser-refresh-client": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/browser-refresh-client/-/browser-refresh-client-1.1.4.tgz", + "integrity": "sha512-FM/UzMFsG7wJ1ocxCSl6U7qGAIWASEk+tlvfJLP2Pd1JfS4kQ1r4d5f+nNmQI8fB8sXSD8+u/mWErEkAMxUu3w==", + "dev": true, + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -4631,6 +4886,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "integrity": "sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.0.1", + "deep-eql": "^0.1.3", + "type-detect": "^1.0.0" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -5149,6 +5419,20 @@ "postcss": "^8.4" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssdb": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.9.0.tgz", @@ -5186,6 +5470,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5204,6 +5502,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -5218,6 +5523,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "0.1.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/deep-eql/node_modules/type-detect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", + "integrity": "sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -5612,6 +5940,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-light": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/events-light/-/events-light-1.0.5.tgz", + "integrity": "sha512-jF51LJzg5W+tkJgfZbjlbFCLcyVFEtOjU+xMCBylrXG13X5XHvfp6lNGfyBLF9u1mRTpUsMVYqSDukvpZff1mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chai": "^3.5.0" + } + }, "node_modules/fast-equals": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz", @@ -6089,6 +6427,19 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -6101,9 +6452,9 @@ } }, "node_modules/htmljs-parser": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/htmljs-parser/-/htmljs-parser-5.12.0.tgz", - "integrity": "sha512-AX7R1/VZXAFuywmX6xWIx+oYiSA8py1yUh+tPka+rVb+QIa+Gjw/nlDhNK+DYTYoYLOQ9J7pUGtsEoJj9ve6Zg==", + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/htmljs-parser/-/htmljs-parser-5.12.1.tgz", + "integrity": "sha512-QnHLg5SWqoH5o8MD4iOWJFtdKSS2lXxtNgyznFXvEeeCK0CTi6URdYtFGkFGDANdq8RZzkuc91KaBgLx2WAhSA==", "dev": true, "license": "MIT" }, @@ -6352,6 +6703,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-safe-filename": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", @@ -6388,6 +6746,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6805,6 +7214,13 @@ "uc.micro": "^2.0.0" } }, + "node_modules/listener-tracker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/listener-tracker/-/listener-tracker-2.0.0.tgz", + "integrity": "sha512-U6NLzBRyrAsJs9AAjuBYifXtNYnAIDPIp81rNpxNoypXBR7qi/LhsuUWX5399zuTg1sBEQyOnWDYFrBQ28vk/w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -7083,6 +7499,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -7935,6 +8358,32 @@ "@babel/runtime": "^7.1.2" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8893,6 +9342,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -9034,6 +9493,16 @@ "dev": true, "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9610,6 +10079,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/self-closing-tags": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/self-closing-tags/-/self-closing-tags-1.0.1.tgz", @@ -9945,6 +10427,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -10042,6 +10531,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", + "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.7" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", + "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10075,6 +10584,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -10106,6 +10641,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-detect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", + "integrity": "sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -10407,6 +10952,60 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.1.0.tgz", + "integrity": "sha512-9gEWpXkYGXoqG7pBnE8O8hx/yP7+Aabn4+peQ3KDicQv6qunHSWyLTud3OF0w4S2+HfDD+5HqYKiXQW9HAU6mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.18.2" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", + "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.1", + "vscode-languageserver-types": "3.18.0" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", @@ -10414,6 +11013,13 @@ "dev": true, "license": "MIT" }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -10428,6 +11034,19 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/warp10": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/warp10/-/warp10-2.1.0.tgz", @@ -10435,6 +11054,41 @@ "dev": true, "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/writable-dom": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/writable-dom/-/writable-dom-1.0.6.tgz", @@ -10455,6 +11109,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 4f4f347509..c6e9955117 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "@fortawesome/free-brands-svg-icons": "^7.3.0", "@fortawesome/free-solid-svg-icons": "^7.3.0", "@jridgewell/source-map": "^0.3.11", + "@marko/language-server": "^3.3.2", + "@marko/language-tools": "^2.6.3", "@marko/run": "^0.11.0", "@marko/run-adapter-static": "^2.0.6", "@marko/type-check": "^3.1.1", @@ -71,7 +73,12 @@ "sass-embedded": "^1.100.0", "shiki": "^4.3.0", "terser": "^5.48.0", + "typescript": "^6.0.3", "util": "./shim/util", + "vscode-css-languageservice": "^6.3.10", + "vscode-languageserver": "^10.0.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", "writable-dom": "^1.0.6" } } diff --git a/src/routes/playground/tags/playground/tags/editor/editor.marko b/src/routes/playground/tags/playground/tags/editor/editor.marko index f3bcd70fa4..73b17e4b20 100644 --- a/src/routes/playground/tags/playground/tags/editor/editor.marko +++ b/src/routes/playground/tags/playground/tags/editor/editor.marko @@ -2,6 +2,8 @@ client import { EditorView, keymap } from "@codemirror/view"; client import { EditorState } from "@codemirror/state"; client import extensions, { update } from "app/util/codemirror"; client import { formatCode } from "app/util/format"; +client import { getLspClient, type LspClient } from "app/util/lsp/client"; +client import { markoLsp } from "app/util/lsp/codemirror"; import { faPaintbrush } from "@fortawesome/free-solid-svg-icons"; import type { File } from "app/util/workspace"; import styles from "./editor.styles.module.scss"; @@ -22,7 +24,7 @@ let/file=files[selected] } // Bridges the format button (markup) to the editor's lifecycle; set on mount. -let/doFormat=(() => {}) +let/doFormat=() => {} tabs files:=files selected:=selected div class=styles.editorWrap @@ -42,11 +44,20 @@ div class=styles.editorWrap lifecycle<{ editor?: EditorView; + client?: LspClient; listener: ReturnType; currentPath?: string; change?: boolean; + // A pending go-to-definition jump into another tab: applied once that tab's + // editor state is live (see onUpdate). + pendingNav?: { path: string; position: { line: number; character: number } }; set(content: string): void; format(): void; + openLocation( + path: string, + position: { line: number; character: number }, + ): void; + focusPosition(position: { line: number; character: number }): void; stateFor(f: File | undefined): EditorState; }> ,set(content) { @@ -82,6 +93,27 @@ lifecycle<{ // Ignore formatting errors (e.g. the file currently has a syntax error). }); } + ,openLocation(targetPath, position) { + const index = files.findIndex((f) => f.path === targetPath); + // A target outside the open playground files (eg a lib `.d.ts`) has no tab + // to open, so leave the editor where it is. + if (index < 0) return; + if (index === selected) { + this.focusPosition(position); + } else { + this.pendingNav = { path: targetPath, position }; + selected = index; + } + } + ,focusPosition(position) { + const view = this.editor; + if (!view) return; + const { doc } = view.state; + const line = doc.line(Math.min(Math.max(position.line + 1, 1), doc.lines)); + const anchor = Math.min(line.from + position.character, line.to); + view.dispatch({ selection: { anchor }, scrollIntoView: true }); + view.focus(); + } ,stateFor(f) { return EditorState.create({ doc: f?.content, @@ -99,11 +131,24 @@ lifecycle<{ }, ]), ...extensions, + ...(f && this.client + ? markoLsp(this.client, f.path, (p, position) => + this.openLocation(p, position), + ) + : []), ], }); } ,onMount() { this.change = false; + try { + // The language server is a best-effort enhancement; if it fails to start + // (eg the worker can't be created) the editor still works without it. + this.client = getLspClient(); + this.client.syncFiles(files); + } catch { + this.client = undefined; + } this.listener = EditorView.updateListener.of((update) => { if (update.docChanged) { this.set(update.state.doc.toString()); @@ -120,6 +165,9 @@ lifecycle<{ } } ,onUpdate() { + // Keep the language server's view of every file current so cross-file + // features (imported components, shared types) resolve. + this.client?.syncFiles(files); if (this.change) { this.change = false; } else if (file) { @@ -131,6 +179,12 @@ lifecycle<{ } update(this.editor!, file.content, ext(file.path)); } + // A go-to-definition jump into this tab: now that its editor state is live, + // place the cursor at the target. + if (this.pendingNav && file && this.pendingNav.path === file.path) { + this.focusPosition(this.pendingNav.position); + this.pendingNav = undefined; + } } ,onDestroy() { this.editor!.destroy(); diff --git a/src/routes/playground/tags/playground/tags/editor/editor.styles.module.scss b/src/routes/playground/tags/playground/tags/editor/editor.styles.module.scss index ddc0e6bebf..eabb2cd0b1 100644 --- a/src/routes/playground/tags/playground/tags/editor/editor.styles.module.scss +++ b/src/routes/playground/tags/playground/tags/editor/editor.styles.module.scss @@ -99,4 +99,36 @@ background: var(--color-blue-dim) !important; } } + + // LSP hover and completion-info popups render arbitrarily long type + // signatures and documentation. Cap their size and wrap long code so a big + // signature (eg hovering a DOM handler like `onClick`) scrolls inside the + // popup instead of overflowing the window. + .cm-lsp-hover, + .cm-lsp-info { + box-sizing: border-box; + max-width: min(48rem, 90vw); + max-height: 60vh; + padding: 0.4rem 0.6rem; + overflow: auto; + line-height: 1.4; + + pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + p { + margin: 0.4rem 0; + overflow-wrap: anywhere; + } + + > :first-child { + margin-top: 0; + } + > :last-child { + margin-bottom: 0; + } + } } diff --git a/src/types/stubs.d.ts b/src/types/stubs.d.ts index e06fe2fe09..bdf7e7f566 100644 --- a/src/types/stubs.d.ts +++ b/src/types/stubs.d.ts @@ -2,3 +2,10 @@ declare module "@ebay/browserslist-config" { const config: string[]; export default config; } + +// The read-only virtual-disk seed (TypeScript libs + Marko type defs), provided +// by the `marko-lsp-assets` Vite plugin. Keyed by absolute virtual path. +declare module "virtual:marko-lsp-assets" { + const assets: Record; + export default assets; +} diff --git a/src/util/codemirror.ts b/src/util/codemirror.ts index c67831f1a5..c0d9d30c3e 100644 --- a/src/util/codemirror.ts +++ b/src/util/codemirror.ts @@ -24,9 +24,7 @@ import { } from "@codemirror/commands"; import { acceptCompletion, - autocompletion, closeBrackets, - completeAnyWord, completionKeymap, } from "@codemirror/autocomplete"; import { foldGutter, foldService } from "@codemirror/language"; @@ -36,8 +34,7 @@ import { styleToClass } from "./style-to-class"; const langConfig = new Compartment(); export function update(view: EditorView, content: string, lang: string) { const curLang = (langConfig.get(view.state) as any)?.lang as - | string - | undefined; + string | undefined; const effects = curLang === lang ? undefined : langConfig.reconfigure(shiki(lang)); @@ -69,7 +66,6 @@ export default [ highlightActiveLine(), highlightSelectionMatches(), highlightActiveLineGutter(), - autocompletion({ override: [completeAnyWord] }), EditorState.allowMultipleSelections.of(true), EditorState.languageData.of(() => baseLanguageData), langConfig.of(shiki()), diff --git a/src/util/lsp/assets-plugin.ts b/src/util/lsp/assets-plugin.ts new file mode 100644 index 0000000000..dc696fa44b --- /dev/null +++ b/src/util/lsp/assets-plugin.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Plugin } from "vite"; + +// Gathers the read-only files the in-browser language server needs on its +// virtual disk -- TypeScript's `lib.*.d.ts`, the Marko type definitions and a +// project `tsconfig.json` -- and exposes them as a single virtual module the +// language-server worker seeds at startup. +// +// The keys are the absolute virtual-disk paths the server reads from. The Marko +// type-definition paths must match `@marko/language-server`'s browser +// `project-defaults` (`Project.setDefaultTypePaths`). +const VIRTUAL_ID = "virtual:marko-lsp-assets"; + +// The website's node_modules (flat install). Resolving package directories from +// here avoids `require.resolve("/package.json")`, which fails for packages +// whose `exports` don't expose `./package.json` (eg typescript). +const lspDir = path.dirname(fileURLToPath(import.meta.url)); +const nodeModules = path.resolve(lspDir, "../../../node_modules"); +const pkgDir = (name: string) => path.join(nodeModules, ...name.split("/")); + +// Node builtins the language server (and its bundled deps) import, mapped to the +// worker-local browser stand-ins in `node-shims/`. Applied only inside browser +// (client/worker) bundles so the Node server build keeps the real modules. +// +// `@marko/language-server/browser` itself resolves from node_modules (the +// published `./browser` export); only the Node builtins it reaches for need +// shimming. +const shim = (name: string) => path.join(lspDir, "node-shims", name); +const WORKER_SHIMS: Record = { + fs: shim("fs.ts"), + "node:fs": shim("fs.ts"), + "fs/promises": shim("fs-promises.ts"), + "node:fs/promises": shim("fs-promises.ts"), + url: shim("url.ts"), + "node:url": shim("url.ts"), + module: shim("module.ts"), + "node:module": shim("module.ts"), +}; + +// Environments that run in Node (the server/prerender build) and must keep the +// real Node builtins. +const NODE_ENVIRONMENTS = new Set(["ssr", "server", "rsc"]); + +/** + * Resolve the Node builtins the language-server worker touches to browser + * stand-ins. Registered in the main `plugins` array (which, unlike + * `worker.plugins`, is applied to worker modules in dev) with an `enforce: pre` + * `resolveId` hook. The builtins are only shimmed in browser environments so the + * Node server build keeps the real modules. + */ +export function markoLspResolve(): Plugin { + return { + name: "marko-lsp-resolve", + enforce: "pre", + resolveId(id) { + const env = this.environment?.name; + if (id in WORKER_SHIMS && (!env || !NODE_ENVIRONMENTS.has(env))) { + return WORKER_SHIMS[id]; + } + }, + }; +} + +// Node builtins whose browser shims the pre-bundled `@marko/compiler` needs at +// dependency-optimization time (see `markoLspOptimizeShims`). Only the `fs` +// family: the compiler's taglib finder scans the disk synchronously for sibling +// `.marko` tags, and the optimizer otherwise externalizes `fs` to a stub that +// throws, so discovery silently finds nothing. +const OPTIMIZE_SHIMS: Record = { + fs: shim("fs.ts"), + "node:fs": shim("fs.ts"), + "fs/promises": shim("fs-promises.ts"), + "node:fs/promises": shim("fs-promises.ts"), +}; + +/** + * Point the dependency optimizer's Node-builtin resolution at the browser + * shims. The optimizer (dev pre-bundle) runs before `markoLspResolve`'s + * `resolveId` hook and externalizes builtins to throwing stubs, so the bundled + * `@marko/compiler` would otherwise get an empty `fs`. Registered as an + * `optimizeDeps.rolldownOptions` plugin (client optimizer only, so the Node + * server build is untouched); the production build has no optimizer and relies + * on `markoLspResolve` instead. The shared virtual disk survives being bundled + * into the pre-bundle because it lives on a `globalThis` singleton (see `vfs`). + */ +export function markoLspOptimizeShims() { + return { + name: "marko-lsp-optimize-shims", + resolveId(id: string) { + return OPTIMIZE_SHIMS[id]; + }, + }; +} + +export function markoLspAssets(): Plugin { + return { + name: "marko-lsp-assets", + resolveId(id) { + if (id === VIRTUAL_ID) return "\0" + VIRTUAL_ID; + }, + load(id) { + if (id !== "\0" + VIRTUAL_ID) return; + return `export default ${JSON.stringify(collectAssets())};`; + }, + }; +} + +function collectAssets(): Record { + const assets: Record = {}; + + // Every `lib.*.d.ts` TypeScript ships, seeded at the virtual root so the + // default-lib resolution (which falls back to `__dirname` === "/") finds them. + const tsLibDir = path.join(pkgDir("typescript"), "lib"); + for (const entry of fs.readdirSync(tsLibDir)) { + if (/^lib\..*\.d\.ts$/.test(entry)) { + assets["/" + entry] = fs.readFileSync(path.join(tsLibDir, entry), "utf8"); + } + } + + // Marko's own type definitions, laid out under a virtual `node_modules/marko` + // so relative (`./tags-html`) and bare (`csstype`) references resolve. + const markoDir = pkgDir("marko"); + assets["/node_modules/marko/index.d.ts"] = fs.readFileSync( + path.join(markoDir, "index.d.ts"), + "utf8", + ); + assets["/node_modules/marko/tags-html.d.ts"] = fs.readFileSync( + path.join(markoDir, "tags-html.d.ts"), + "utf8", + ); + // The core tags (``, ``, ``, ...) carry their own + // `.d.marko` type definitions, which the extractor imports (eg + // `import("marko/tags/let.d.marko")`). Without them a `` value mistypes + // as read-only, so seed the whole `tags/` directory. + const markoTagsDir = path.join(markoDir, "tags"); + for (const entry of fs.readdirSync(markoTagsDir)) { + if (entry.endsWith(".d.marko")) { + assets[`/node_modules/marko/tags/${entry}`] = fs.readFileSync( + path.join(markoTagsDir, entry), + "utf8", + ); + } + } + // A permissive manifest so both `marko/package.json` (used by the extractor's + // `.marko` resolution) and arbitrary `marko/tags/*` subpaths resolve. + assets["/node_modules/marko/package.json"] = JSON.stringify({ + name: "marko", + types: "index.d.ts", + }); + + // csstype backs Marko's `style`/HTML attribute typings. + assets["/node_modules/csstype/index.d.ts"] = fs.readFileSync( + path.join(pkgDir("csstype"), "index.d.ts"), + "utf8", + ); + assets["/node_modules/csstype/package.json"] = JSON.stringify({ + name: "csstype", + types: "index.d.ts", + }); + + // The compiler-internal typing helpers used by the TypeScript extractor output. + assets["/node_modules/@marko/language-tools/marko.internal.d.ts"] = + fs.readFileSync( + path.join(pkgDir("@marko/language-tools"), "marko.internal.d.ts"), + "utf8", + ); + + assets["/tsconfig.json"] = JSON.stringify({ + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["DOM", "DOM.Iterable", "ESNext"], + strict: true, + jsx: "preserve", + allowJs: true, + skipLibCheck: true, + }, + include: [], + }); + + return assets; +} diff --git a/src/util/lsp/client.ts b/src/util/lsp/client.ts new file mode 100644 index 0000000000..dddffd95f8 --- /dev/null +++ b/src/util/lsp/client.ts @@ -0,0 +1,293 @@ +import { URI } from "vscode-uri"; +import type { + CodeAction, + Command, + CompletionItem, + CompletionList, + Definition, + Diagnostic, + Hover, + LocationLink, + Position, + Range, +} from "vscode-languageserver-types"; + +import { rootDir, type File } from "app/util/workspace"; + +/** LSP `CompletionContext` (lives in the protocol package, inlined here). */ +interface CompletionContext { + triggerKind: 1 | 2 | 3; + triggerCharacter?: string; +} + +// Client half of the in-browser language server. Owns the worker, keeps the +// server's virtual documents in sync with the playground files, and turns the +// `postMessage` RPC into promise-returning methods the editor can await. + +type ServerMessage = + | { type: "ready"; id: number } + | { type: "response"; id: number; result?: unknown; error?: string }; + +type RequestMethod = + | "doValidate" + | "doHover" + | "doComplete" + | "doCompletionResolve" + | "doCodeActions" + | "doCodeActionResolve" + | "findDefinition"; + +interface Pending { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +} + +export class LspClient { + #worker: Worker; + #nextId = 1; + #pending = new Map(); + #docs = new Map< + string, + { version: number; content: string; open: boolean } + >(); + #ready: Promise; + + constructor() { + this.#worker = new Worker(new URL("./server.worker.ts", import.meta.url), { + type: "module", + name: "marko-language-server", + }); + this.#worker.addEventListener("message", (event) => + this.#onMessage(event.data as ServerMessage), + ); + this.#worker.addEventListener("error", (e) => + console.error("[lsp] worker error", e.message, e.filename, e.lineno), + ); + this.#worker.addEventListener("messageerror", (e) => + console.error("[lsp] worker messageerror", e), + ); + const id = this.#nextId++; + this.#ready = new Promise((resolve, reject) => { + this.#pending.set(id, { resolve: () => resolve(), reject }); + }); + this.#worker.postMessage({ type: "initialize", id }); + } + + get ready() { + return this.#ready; + } + + /** + * Turn a playground file path into the `file://` URI the server keys on. + * Files are rooted under {@link rootDir} (the same directory the build + * workspace uses) so the server's Marko tag discovery resolves sibling + * `.marko` files as custom tags, exactly as the runtime does. + */ + static uri(path: string) { + return URI.file(rootDir + path.replace(/^\/+/, "")).toString(); + } + + /** Reverse of {@link uri}: the playground file path a `file://` URI names. */ + static pathForUri(uri: string) { + const fsPath = URI.parse(uri).fsPath; + return fsPath.startsWith(rootDir) + ? fsPath.slice(rootDir.length) + : fsPath.replace(/^\/+/, ""); + } + + /** + * Bring the server's open documents in line with the current playground + * files: open new ones, push edits, and close deleted ones. + */ + syncFiles(files: File[]) { + const seen = new Set(); + for (const file of files) { + const uri = LspClient.uri(file.path); + seen.add(uri); + const existing = this.#docs.get(uri); + if (!existing) { + this.#docs.set(uri, { version: 1, content: file.content, open: true }); + this.#worker.postMessage({ + type: "didOpen", + uri, + languageId: languageIdFor(file.path), + version: 1, + text: file.content, + }); + } else if (existing.content !== file.content) { + existing.version++; + existing.content = file.content; + this.#worker.postMessage({ + type: "didChange", + uri, + version: existing.version, + text: file.content, + }); + } + } + + for (const [uri, doc] of this.#docs) { + if (!seen.has(uri) && doc.open) { + doc.open = false; + this.#worker.postMessage({ type: "didClose", uri }); + this.#docs.delete(uri); + } + } + } + + /** Push a single document edit ahead of a request that depends on it. */ + didChange(path: string, content: string) { + const uri = LspClient.uri(path); + const existing = this.#docs.get(uri); + if (existing) { + if (existing.content === content) return; + existing.version++; + existing.content = content; + this.#worker.postMessage({ + type: "didChange", + uri, + version: existing.version, + text: content, + }); + } else { + this.#docs.set(uri, { version: 1, content, open: true }); + this.#worker.postMessage({ + type: "didOpen", + uri, + languageId: languageIdFor(path), + version: 1, + text: content, + }); + } + } + + validate(path: string) { + return this.#request("doValidate", path); + } + + hover(path: string, position: Position) { + return this.#request("doHover", path, { + textDocument: { uri: LspClient.uri(path) }, + position, + }); + } + + complete(path: string, position: Position, context?: CompletionContext) { + return this.#request( + "doComplete", + path, + { + textDocument: { uri: LspClient.uri(path) }, + position, + context, + }, + ); + } + + resolveCompletion(item: CompletionItem) { + return this.#request( + "doCompletionResolve", + undefined, + item, + ); + } + + definition(path: string, position: Position) { + return this.#request( + "findDefinition", + path, + { textDocument: { uri: LspClient.uri(path) }, position }, + ); + } + + codeActions(path: string, range: Range, diagnostics: Diagnostic[]) { + return this.#request<(Command | CodeAction)[] | undefined>( + "doCodeActions", + path, + { + textDocument: { uri: LspClient.uri(path) }, + range, + context: { diagnostics, only: ["quickfix"] }, + }, + ); + } + + resolveCodeAction(action: CodeAction) { + return this.#request( + "doCodeActionResolve", + undefined, + action, + ); + } + + async #request( + method: RequestMethod, + path?: string, + params?: unknown, + ): Promise { + await this.#ready; + const id = this.#nextId++; + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve: resolve as Pending["resolve"], reject }); + this.#worker.postMessage({ + type: "request", + id, + method, + uri: path === undefined ? undefined : LspClient.uri(path), + params, + }); + }); + } + + #onMessage(message: ServerMessage) { + // Negative ids are worker-global error broadcasts (see worker-error-report). + if (message.id < 0) { + console.error( + "[marko language server]", + "error" in message ? message.error : message, + ); + return; + } + const pending = this.#pending.get(message.id); + if (!pending) return; + this.#pending.delete(message.id); + if (message.type === "ready") { + pending.resolve(undefined); + } else if (message.error) { + pending.reject(new Error(message.error)); + } else { + pending.resolve(message.result); + } + } +} + +let client: LspClient | undefined; +/** Lazily start the language server; only the playground needs it. */ +export function getLspClient() { + return (client ??= new LspClient()); +} + +function languageIdFor(path: string) { + switch (path.slice(path.lastIndexOf(".") + 1)) { + case "marko": + return "marko"; + case "ts": + case "mts": + case "cts": + return "typescript"; + case "js": + case "mjs": + case "cjs": + return "javascript"; + case "css": + return "css"; + case "scss": + return "scss"; + case "less": + return "less"; + case "json": + return "json"; + default: + return "plaintext"; + } +} diff --git a/src/util/lsp/codemirror.ts b/src/util/lsp/codemirror.ts new file mode 100644 index 0000000000..a2b6c39724 --- /dev/null +++ b/src/util/lsp/codemirror.ts @@ -0,0 +1,438 @@ +import { + type Completion, + type CompletionContext, + type CompletionResult, + autocompletion, + startCompletion, +} from "@codemirror/autocomplete"; +import { type Diagnostic as CmDiagnostic, linter } from "@codemirror/lint"; +import type { Text } from "@codemirror/state"; +import { EditorView, hoverTooltip, keymap } from "@codemirror/view"; +import { marked } from "marked"; +import type { + CodeAction, + Command, + CompletionItem, + MarkedString, + MarkupContent, + Position, + Range, + TextEdit, + WorkspaceEdit, +} from "vscode-languageserver-types"; + +import { LspClient } from "./client"; + +// Bridges the in-browser language server to CodeMirror: diagnostics (with quick +// fixes), hover, completion and go-to-definition. Positions cross the boundary +// as LSP line/character pairs and are mapped to/from CodeMirror offsets here. +// The server does the embedded-language mapping internally, so `.marko` files +// need no special handling on this side. +// +// `openLocation` navigates to a definition target, which may live in another +// playground file; the editor provides it so it can switch tabs. +export function markoLsp( + client: LspClient, + path: string, + openLocation?: (path: string, position: Position) => void, +) { + return [ + lspDiagnostics(client, path), + lspHover(client, path), + autocompletion({ override: [lspCompletions(client, path)] }), + completionTriggers(), + lspDefinition(client, path, openLocation), + ]; +} + +// CodeMirror only auto-opens completion while typing word characters, so +// member access (`.`), tags (`<`), attribute tags (`@`) and paths (`/`) never +// trigger it. Open completion after one of these is typed, matching the trigger +// characters the language server advertises. +const TRIGGER_CHAR = /^[.<@/]$/; +function completionTriggers() { + return EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + let triggered = false; + update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + if (inserted.length === 1 && TRIGGER_CHAR.test(inserted.sliceString(0))) { + triggered = true; + } + }); + // Dispatching from inside an update is not allowed; defer a tick. + if (triggered) queueMicrotask(() => startCompletion(update.view)); + }); +} + +function lspDiagnostics(client: LspClient, path: string) { + return linter( + async (view): Promise => { + client.didChange(path, view.state.doc.toString()); + const diagnostics = await client.validate(path).catch(() => undefined); + if (!diagnostics) return []; + const { doc } = view.state; + return Promise.all( + diagnostics.map(async (d): Promise => { + const from = posToOffset(doc, d.range.start); + const to = posToOffset(doc, d.range.end); + // Quick fixes for this diagnostic surface as buttons in the lint + // tooltip. + const fixes = await client + .codeActions(path, d.range, [d]) + .catch(() => undefined); + const actions = fixes?.filter(isCodeAction).map((action) => ({ + name: action.title, + apply: () => applyCodeAction(view, client, path, action), + })); + return { + from, + to: to > from ? to : from, + severity: SEVERITY[d.severity ?? 1] ?? "error", + message: d.message, + source: d.source, + actions: actions?.length ? actions : undefined, + }; + }), + ); + }, + { delay: 350 }, + ); +} + +function lspDefinition( + client: LspClient, + path: string, + openLocation?: (path: string, position: Position) => void, +) { + const go = (view: EditorView, offset: number) => { + if (!openLocation) return; + void client + .definition(path, offsetToPos(view.state.doc, offset)) + .then((result) => { + const target = firstDefinition(result); + if (target) { + openLocation(LspClient.pathForUri(target.uri), target.range.start); + } + }) + .catch(() => {}); + }; + return [ + // Cmd/Ctrl+click jumps to the definition under the pointer. + EditorView.domEventHandlers({ + mousedown(event, view) { + if (!openLocation || !(event.metaKey || event.ctrlKey)) return false; + const offset = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (offset == null) return false; + event.preventDefault(); + go(view, offset); + return true; + }, + }), + // F12 jumps to the definition at the cursor. + keymap.of([ + { + key: "F12", + run(view) { + go(view, view.state.selection.main.head); + return true; + }, + }, + ]), + ]; +} + +function firstDefinition( + result: Awaited>, +): { uri: string; range: Range } | undefined { + if (!result) return; + const first = Array.isArray(result) ? result[0] : result; + if (!first) return; + return "targetUri" in first + ? { uri: first.targetUri, range: first.targetSelectionRange } + : { uri: first.uri, range: first.range }; +} + +// A bare `Command` has a string `command`; a `CodeAction` carries an `edit` +// and/or `data`. Only code actions are applied here. +function isCodeAction(action: Command | CodeAction): action is CodeAction { + return typeof (action as Command).command !== "string"; +} + +function applyCodeAction( + view: EditorView, + client: LspClient, + path: string, + action: CodeAction, +) { + if (action.edit) { + applyWorkspaceEdit(view, path, action.edit); + } else if (action.data != null) { + // The edit is filled in lazily by the resolve request. + void client + .resolveCodeAction(action) + .then((resolved) => { + if (resolved?.edit) applyWorkspaceEdit(view, path, resolved.edit); + }) + .catch(() => {}); + } +} + +// Apply the parts of a workspace edit that target the current file. Cross-file +// edits (rare for a quick fix) are left alone. +function applyWorkspaceEdit( + view: EditorView, + path: string, + edit: WorkspaceEdit, +) { + const uri = LspClient.uri(path); + let edits: TextEdit[] | undefined; + if (edit.changes) { + edits = edit.changes[uri]; + } else if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if ("textDocument" in change && change.textDocument.uri === uri) { + edits = (edits ?? []).concat(change.edits); + } + } + } + if (edits?.length) applyTextEdits(view, edits); +} + +function lspHover(client: LspClient, path: string) { + return hoverTooltip(async (view, pos) => { + client.didChange(path, view.state.doc.toString()); + const { doc } = view.state; + const hover = await client.hover(path, offsetToPos(doc, pos)); + const value = hover && renderMarkup(hover.contents); + if (!value) return null; + + const { from, to } = hover.range + ? rangeToOffsets(doc, hover.range) + : { from: pos, to: pos }; + + return { + pos: from, + end: to, + above: true, + create() { + const dom = document.createElement("div"); + dom.className = "cm-lsp-hover shiki"; + dom.innerHTML = value; + return { dom }; + }, + }; + }); +} + +function lspCompletions(client: LspClient, path: string) { + return async (ctx: CompletionContext): Promise => { + client.didChange(path, ctx.state.doc.toString()); + const { doc } = ctx.state; + const result = await client.complete(path, offsetToPos(doc, ctx.pos), { + triggerKind: ctx.explicit ? 1 : 2, + }); + if (!result) return null; + + const items = Array.isArray(result) ? result : result.items; + if (!items?.length) return null; + + const word = ctx.matchBefore(/[\w$@:./-]*/); + // Prefer an explicit edit range from the first item so replacement tokens + // (eg completing over an existing attribute) line up. + const editStart = items.length ? textEditRange(items[0])?.start : undefined; + let from: number; + if (editStart) { + from = posToOffset(doc, editStart); + } else if (word) { + // Anchor filtering at the current token, not across separators: after + // `greeting.` the members (`toUpperCase`, ...) filter on the empty string + // past the dot, not the whole `greeting.` prefix (which matches nothing + // and would hide every result). + const sep = Math.max( + word.text.lastIndexOf("."), + word.text.lastIndexOf("/"), + ); + from = word.from + (sep < 0 ? 0 : sep + 1); + } else { + from = ctx.pos; + } + + return { + from, + options: items.map((item) => toCompletion(client, item)), + validFor: /[\w$@:./-]*/, + }; + }; +} + +function toCompletion(client: LspClient, item: CompletionItem): Completion { + return { + label: item.label, + detail: item.detail || item.labelDetails?.detail, + type: KIND[item.kind ?? 0], + boost: item.preselect ? 1 : 0, + apply(view, _completion, from, to) { + // `from`/`to` come from CodeMirror and track the live edit: `from` is the + // completion anchor, `to` is the current cursor. The server's edit range + // is from request time and goes stale as the user keeps typing (the list + // is filtered locally via `validFor`, not re-requested), so drive the + // replacement end off `to`, extending to the server's end only when it + // reaches further (eg replacing an existing token to the right). + const range = textEditRange(item); + const end = range + ? Math.max(to, posToOffset(view.state.doc, range.end)) + : to; + let insert = textEditText(item) ?? item.insertText ?? item.label; + if (item.insertTextFormat === 2) insert = stripSnippet(insert); + view.dispatch({ + changes: { from, to: end, insert }, + selection: { anchor: from + insert.length }, + }); + // Auto-import completions carry the `import ...` line as + // additionalTextEdits, filled in lazily by the resolve request. Apply them + // after the insert (in a separate transaction so the common, edit-free + // case stays synchronous) so accepting the completion adds the import too. + if (item.additionalTextEdits) { + applyTextEdits(view, item.additionalTextEdits); + } else if (item.detail != null && item.data != null) { + void client + .resolveCompletion(item) + .then((resolved) => { + if (resolved?.additionalTextEdits) { + applyTextEdits(view, resolved.additionalTextEdits); + } + }) + .catch(() => {}); + } + }, + info() { + return renderInfo(client, item); + }, + }; +} + +async function renderInfo(client: LspClient, item: CompletionItem) { + let doc = item.documentation; + let detail = item.detail; + if (!doc || !detail) { + // Docs and signatures are filled in lazily by the resolve request. + const resolved = await client + .resolveCompletion(item) + .catch(() => undefined); + doc = resolved?.documentation ?? doc; + detail = resolved?.detail ?? detail; + } + + const parts: string[] = []; + if (detail) parts.push("```typescript\n" + detail + "\n```"); + if (doc) parts.push(typeof doc === "string" ? doc : doc.value); + if (!parts.length) return null; + + const dom = document.createElement("div"); + dom.className = "cm-lsp-info shiki"; + dom.innerHTML = markedToHtml(parts.join("\n\n")); + return dom; +} + +function renderMarkup( + contents: MarkupContent | MarkedString | MarkedString[], +): string | undefined { + const md = markupToMarkdown(contents).trim(); + return md ? markedToHtml(md) : undefined; +} + +function markupToMarkdown( + contents: MarkupContent | MarkedString | MarkedString[], +): string { + if (typeof contents === "string") return contents; + if (Array.isArray(contents)) + return contents.map(markupToMarkdown).join("\n\n"); + if ("kind" in contents) return contents.value; + return "```" + contents.language + "\n" + contents.value + "\n```"; +} + +function markedToHtml(md: string): string { + return marked.parse(md, { async: false }) as string; +} + +const SEVERITY: Record = { + 1: "error", + 2: "warning", + 3: "info", + 4: "hint", +}; + +// LSP CompletionItemKind -> CodeMirror completion type (drives the icon). +const KIND: Record = { + 1: "text", + 2: "method", + 3: "function", + 4: "function", + 5: "property", + 6: "variable", + 7: "class", + 8: "interface", + 9: "namespace", + 10: "property", + 11: "type", + 12: "enum", + 13: "enum", + 14: "keyword", + 15: "text", + 16: "constant", + 17: "text", + 18: "text", + 19: "namespace", + 20: "enum", + 21: "constant", + 22: "class", + 23: "variable", + 24: "keyword", + 25: "type", +}; + +function textEditRange(item: CompletionItem): Range | undefined { + const edit = item.textEdit; + if (!edit) return undefined; + return "range" in edit ? edit.range : edit.replace; +} + +function textEditText(item: CompletionItem): string | undefined { + return item.textEdit?.newText; +} + +function stripSnippet(text: string): string { + return text + .replace(/\$\{\d+:([^}]*)\}/g, "$1") + .replace(/\$\{\d+\}/g, "") + .replace(/\$\d+/g, ""); +} + +function applyTextEdits(view: EditorView, edits: TextEdit[]) { + if (!edits.length) return; + const { doc } = view.state; + view.dispatch({ + changes: edits.map((edit) => { + const { from, to } = rangeToOffsets(doc, edit.range); + return { from, to, insert: edit.newText }; + }), + }); +} + +function posToOffset(doc: Text, pos: Position): number { + if (pos.line >= doc.lines) return doc.length; + const line = doc.line(pos.line + 1); + return Math.min(line.from + pos.character, line.to); +} + +function offsetToPos(doc: Text, offset: number): Position { + const line = doc.lineAt(offset); + return { line: line.number - 1, character: offset - line.from }; +} + +function rangeToOffsets(doc: Text, range: Range) { + return { + from: posToOffset(doc, range.start), + to: posToOffset(doc, range.end), + }; +} diff --git a/src/util/lsp/globals.ts b/src/util/lsp/globals.ts new file mode 100644 index 0000000000..c6349d6c36 --- /dev/null +++ b/src/util/lsp/globals.ts @@ -0,0 +1,50 @@ +// Minimal globals the Marko compiler and Babel expect from a Node environment. +// Imported for its side effects before anything that pulls in `@marko/compiler`, +// so `process.cwd()` and friends exist by the time that code evaluates. (Vite's +// `define` handles the static `process.env.*` reads; this covers the dynamic +// ones and the worker build, which does not get that `define`.) +const g = globalThis as unknown as { + global?: unknown; + process?: { + browser?: boolean; + platform?: string; + env?: Record; + cwd?: () => string; + argv?: string[]; + nextTick?: (cb: (...args: unknown[]) => void, ...args: unknown[]) => void; + }; +}; + +g.global ??= globalThis; + +// `setImmediate` is used by the notification helper and a few dependencies; +// workers only have `setTimeout`. +const gi = globalThis as unknown as { + setImmediate?: ( + cb: (...args: unknown[]) => void, + ...args: unknown[] + ) => unknown; +}; +gi.setImmediate ??= (cb, ...args) => setTimeout(cb, 0, ...args); + +// Env flags the Marko compiler / bundled Babel branch on (mirrors the website's +// client-side `define`, but at runtime since worker builds do not get it). +const env = { NODE_ENV: "production", BUNDLE: "true" }; + +if (!g.process) { + g.process = { + browser: true, + platform: "browser", + env, + cwd: () => "/", + argv: [], + nextTick: (cb, ...args) => queueMicrotask(() => cb(...args)), + }; +} else { + g.process.browser ??= true; + g.process.env = { ...env, ...g.process.env }; + g.process.cwd ??= () => "/"; + g.process.nextTick ??= (cb, ...args) => queueMicrotask(() => cb(...args)); +} + +export {}; diff --git a/src/util/lsp/node-shims/fs-promises.ts b/src/util/lsp/node-shims/fs-promises.ts new file mode 100644 index 0000000000..f5c672a08a --- /dev/null +++ b/src/util/lsp/node-shims/fs-promises.ts @@ -0,0 +1,66 @@ +// Browser stand-in for the `fs/promises` API. Only the CSS language service's +// file-system provider uses this (for `@import` directory listings); reads are +// served from the virtual disk and missing entries reject like Node would. +import * as vfs from "../vfs"; + +interface Dirent { + name: string; + isFile(): boolean; + isDirectory(): boolean; +} + +class Stats { + constructor(private readonly kind: "file" | "dir") {} + mtimeMs = 0; + ctimeMs = 0; + size = 0; + isFile() { + return this.kind === "file"; + } + isDirectory() { + return this.kind === "dir"; + } +} + +export async function stat(path: string): Promise { + if (vfs.fileExists(path)) return new Stats("file"); + if (vfs.directoryExists(path)) return new Stats("dir"); + throw fileNotFound(path); +} + +export async function readFile(path: string): Promise { + const content = vfs.readFile(path); + if (content === undefined) throw fileNotFound(path); + return content; +} + +export async function readdir(path: string): Promise { + return vfs.readDirectory(path); +} + +export async function opendir(path: string): Promise> { + const names = vfs.readDirectory(path); + const base = path.endsWith("/") ? path : path + "/"; + const entries: Dirent[] = names.map((name) => { + const isFile = vfs.fileExists(base + name); + return { + name, + isFile: () => isFile, + isDirectory: () => !isFile, + }; + }); + return { + async *[Symbol.asyncIterator]() { + yield* entries; + }, + }; +} + +function fileNotFound(path: string) { + return Object.assign( + new Error(`ENOENT: no such file or directory, '${path}'`), + { code: "ENOENT" }, + ); +} + +export default { stat, readFile, readdir, opendir }; diff --git a/src/util/lsp/node-shims/fs.ts b/src/util/lsp/node-shims/fs.ts new file mode 100644 index 0000000000..f9cefb84fc --- /dev/null +++ b/src/util/lsp/node-shims/fs.ts @@ -0,0 +1,76 @@ +// Browser stand-in for the synchronous `fs` API the language server touches: +// reads are served from the virtual disk (`../vfs`), everything else is a +// harmless no-op. `documents.get()` / `documents.exists()` already wrap these in +// try/catch and treat a throw as "file not found", so an absent file simply +// throws. +import * as vfs from "../vfs"; + +class Stats { + constructor(private readonly kind: "file" | "dir") {} + mtimeMs = 0; + ctimeMs = 0; + size = 0; + isFile() { + return this.kind === "file"; + } + isDirectory() { + return this.kind === "dir"; + } + isSymbolicLink() { + return false; + } +} + +export function statSync(path: string): Stats { + if (vfs.fileExists(path)) return new Stats("file"); + if (vfs.directoryExists(path)) return new Stats("dir"); + throw fileNotFound(path); +} + +export const lstatSync = statSync; + +export function readFileSync(path: string, _encoding?: unknown): string { + const content = vfs.readFile(path); + if (content === undefined) throw fileNotFound(path); + return content; +} + +export function accessSync(path: string): void { + if (!vfs.fileExists(path) && !vfs.directoryExists(path)) { + throw fileNotFound(path); + } +} + +export function existsSync(path: string): boolean { + return vfs.fileExists(path) || vfs.directoryExists(path); +} + +export function readdirSync(path: string): string[] { + return vfs.readDirectory(path); +} + +export function realpathSync(path: string): string { + return path; +} + +export function writeFileSync(path: string, content: string): void { + vfs.writeFile(path, content); +} + +function fileNotFound(path: string) { + return Object.assign( + new Error(`ENOENT: no such file or directory, '${path}'`), + { code: "ENOENT" }, + ); +} + +export default { + statSync, + lstatSync, + readFileSync, + accessSync, + existsSync, + readdirSync, + realpathSync, + writeFileSync, +}; diff --git a/src/util/lsp/node-shims/module.ts b/src/util/lsp/node-shims/module.ts new file mode 100644 index 0000000000..8f764ba28c --- /dev/null +++ b/src/util/lsp/node-shims/module.ts @@ -0,0 +1,16 @@ +// Browser stand-in for Node's `module` API. `@marko/language-tools` calls +// `createRequire(...).resolve(...)` to locate a project's own `@marko/compiler` +// install; in the browser there is no such install, so resolution throws and the +// tools fall back to the compiler injected via `Project.setDefaultCompilerMeta` +// (see `../project-defaults`). +export function createRequire(_from: string | URL) { + const require = (id: string) => { + throw new Error(`Cannot require "${id}" in the browser.`); + }; + require.resolve = (id: string): string => { + throw new Error(`Cannot resolve "${id}" in the browser.`); + }; + return require as unknown as NodeJS.Require; +} + +export default { createRequire }; diff --git a/src/util/lsp/node-shims/url.ts b/src/util/lsp/node-shims/url.ts new file mode 100644 index 0000000000..de7e73e525 --- /dev/null +++ b/src/util/lsp/node-shims/url.ts @@ -0,0 +1,26 @@ +// Browser stand-in for the bits of Node's `url` module the language server uses. +// `URL`/`URLSearchParams` are globals in workers; only the `file:` helpers need +// filling in. +export function fileURLToPath(url: string | URL): string { + const href = typeof url === "string" ? url : url.href; + return decodeURIComponent(new URL(href).pathname); +} + +export function pathToFileURL(path: string): URL { + return new URL( + "file://" + (path.startsWith("/") ? path : "/" + path).replace(/\\/g, "/"), + ); +} + +// Re-expose the global constructors as named exports (they are not module +// bindings, so `export { URL }` directly is a syntax error). +const _URL = globalThis.URL; +const _URLSearchParams = globalThis.URLSearchParams; +export { _URL as URL, _URLSearchParams as URLSearchParams }; + +export default { + fileURLToPath, + pathToFileURL, + URL: _URL, + URLSearchParams: _URLSearchParams, +}; diff --git a/src/util/lsp/project-defaults.ts b/src/util/lsp/project-defaults.ts new file mode 100644 index 0000000000..b474f258d6 --- /dev/null +++ b/src/util/lsp/project-defaults.ts @@ -0,0 +1,27 @@ +// Injects the browser-side defaults the language tools would otherwise resolve +// from a project's `node_modules`: the Marko compiler, its translator, and the +// type-definition file paths. On Node these come from disk; in a worker there is +// nothing to resolve, so they are supplied up front. +import "./globals"; + +import * as defaultCompiler from "@marko/compiler"; +import defaultConfig from "@marko/compiler/config"; +import { Project } from "@marko/language-tools"; +import * as defaultTranslator from "marko/translator"; + +// Where the seeded Marko type-definition files live on the virtual disk. The +// asset bundler writes the file contents at exactly these paths (see +// `virtual:marko-lsp-assets` in `assets-plugin.ts`). +export const MARKO_INTERNAL_TYPES = + "/node_modules/@marko/language-tools/marko.internal.d.ts"; +export const MARKO_RUNTIME_TYPES = "/node_modules/marko/index.d.ts"; + +Project.setDefaultTypePaths({ + internalTypesFile: MARKO_INTERNAL_TYPES, + markoTypesFile: MARKO_RUNTIME_TYPES, +}); + +Project.setDefaultCompilerMeta(defaultCompiler, { + ...defaultConfig, + translator: defaultTranslator, +}); diff --git a/src/util/lsp/server.ts b/src/util/lsp/server.ts new file mode 100644 index 0000000000..745c560860 --- /dev/null +++ b/src/util/lsp/server.ts @@ -0,0 +1,263 @@ +// Assembles the in-browser language server: it takes the embedded services from +// `@marko/language-server/browser` and fills the environment seams with the +// worker-local glue (a virtual filesystem, a `ts.System`, the injected Marko +// compiler), then exposes the service over a small `postMessage` RPC. The +// heavy lifting -- extracting Marko into TS/CSS and running TypeScript and the +// CSS language service -- lives in the language server; only the environment is +// provided here. +import "./globals"; +import "./project-defaults"; + +import { Project } from "@marko/language-tools"; +import { + clearMarkoCacheForFile, + createService, + documents, + MarkoPlugin, + ScriptPlugin, + setSystem, + setupMessages, + StylePlugin, + workspace, +} from "@marko/language-server/browser"; +import type { InitializeParams } from "vscode-languageserver"; +import { URI } from "vscode-uri"; +import ts from "typescript/lib/tsserverlibrary"; + +import { createBrowserSystem } from "./system"; +import * as vfs from "./vfs"; + +// TypeScript reaches for a `ts.System` outside the language-service host (config +// discovery, module resolution). The bundled `ts.sys` is a read-only getter, so +// the server reads an injectable system instead; install the virtual-disk one +// now, before any request builds a project. +setSystem(createBrowserSystem(ts)); + +// No LSP connection in the browser: `getConfiguration` yields defaults and +// notifications are dropped. +workspace.setup({ + onDidChangeConfiguration() {}, + workspace: { getConfiguration: async () => ({}) }, +} as never); +setupMessages({ sendNotification() {} } as never); + +// The jsdom-backed HTML (a11y) plugin cannot run in a worker, so the browser +// service composes only Marko, TypeScript and CSS. +const service = createService([MarkoPlugin, ScriptPlugin, StylePlugin]); + +// Marko parse/analysis is cached per document; a `TextDocument` is mutated in +// place on edit, so its cache entry must be invalidated on every change (the +// Node entry does the same via its `didChangeTextDocument` handler). +documents.onFileChange((changeDoc) => { + if (changeDoc) { + clearMarkoCacheForFile(changeDoc); + } else { + Project.clearCaches(); + } +}); + +const notCancelled = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() {} }), +}; + +/** Messages the playground client sends to this worker. */ +export type ClientMessage = + | { type: "initialize"; id: number; params?: Partial } + | { + type: "didOpen"; + uri: string; + languageId: string; + version: number; + text: string; + } + | { type: "didChange"; uri: string; version: number; text: string } + | { type: "didClose"; uri: string } + | { + type: "request"; + id: number; + method: RequestMethod; + uri?: string; + params?: unknown; + }; + +/** Messages this worker sends back to the client. */ +export type ServerMessage = + | { type: "ready"; id: number } + | { type: "response"; id: number; result?: unknown; error?: string }; + +export type RequestMethod = + | "doValidate" + | "doHover" + | "doComplete" + | "doCompletionResolve" + | "doCodeActions" + | "doCodeActionResolve" + | "findDefinition" + | "findReferences" + | "findDocumentSymbols" + | "findDocumentColors" + | "getColorPresentations"; + +interface Scope { + postMessage(message: ServerMessage): void; + addEventListener( + type: "message", + listener: (event: { data: ClientMessage }) => void, + ): void; +} + +export interface StartOptions { + /** + * Virtual-disk seed files, keyed by absolute path: the bundled `lib.*.d.ts`, + * the Marko type definitions and a project `tsconfig.json`. + */ + assets: Record; +} + +/** Wire the language server up to a worker-like scope (`self`). */ +export function start(scope: Scope, { assets }: StartOptions): void { + for (const path in assets) { + vfs.writeFile(path, assets[path]); + // Also seed as a (non-open) document so the TypeScript host reads the libs + // and type definitions directly, without depending on the Node `fs` shim. + documents.preload(pathToUri(path), languageIdForPath(path), assets[path]); + } + + if (!vfs.fileExists("/tsconfig.json")) { + vfs.writeFile("/tsconfig.json", DEFAULT_TSCONFIG); + } + + scope.addEventListener("message", (event) => { + void handle(scope, event.data); + }); +} + +async function handle(scope: Scope, message: ClientMessage): Promise { + switch (message.type) { + case "initialize": { + await service.initialize?.({ + capabilities: {}, + processId: null, + rootUri: null, + workspaceFolders: null, + ...message.params, + } as InitializeParams); + scope.postMessage({ type: "ready", id: message.id }); + break; + } + + case "didOpen": { + vfs.writeFile(uriToPath(message.uri), message.text); + documents.doOpen({ + textDocument: { + uri: message.uri, + languageId: message.languageId, + version: message.version, + text: message.text, + }, + }); + break; + } + + case "didChange": { + vfs.writeFile(uriToPath(message.uri), message.text); + documents.doChange({ + textDocument: { uri: message.uri, version: message.version }, + contentChanges: [{ text: message.text }], + }); + break; + } + + case "didClose": { + documents.doClose({ textDocument: { uri: message.uri } }); + break; + } + + case "request": { + try { + const result = await dispatch(message); + scope.postMessage({ type: "response", id: message.id, result }); + } catch (err) { + scope.postMessage({ + type: "response", + id: message.id, + error: err instanceof Error ? err.message : String(err), + }); + } + break; + } + } +} + +async function dispatch(message: { + method: RequestMethod; + uri?: string; + params?: unknown; +}): Promise { + // Resolve methods take the item/action directly rather than a document. + if (message.method === "doCompletionResolve") { + return service.doCompletionResolve?.(message.params as never, notCancelled); + } + if (message.method === "doCodeActionResolve") { + return service.doCodeActionResolve?.(message.params as never, notCancelled); + } + + const doc = message.uri ? documents.get(message.uri) : undefined; + if (!doc) return undefined; + + if (message.method === "doValidate") { + return service.doValidate?.(doc); + } + + const handler = service[message.method] as + ((doc: unknown, params: unknown, token: unknown) => unknown) | undefined; + return handler?.(doc, message.params, notCancelled); +} + +function uriToPath(uri: string): string { + return URI.parse(uri).fsPath || "/"; +} + +function pathToUri(path: string): string { + return URI.file(path).toString(); +} + +function languageIdForPath(path: string): string { + const ext = path.slice(path.lastIndexOf(".") + 1); + switch (ext) { + case "ts": + case "mts": + case "cts": + return "typescript"; + case "js": + case "mjs": + case "cjs": + return "javascript"; + case "json": + return "json"; + case "marko": + return "marko"; + case "css": + case "scss": + case "less": + return ext; + default: + return "typescript"; + } +} + +const DEFAULT_TSCONFIG = JSON.stringify({ + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["DOM", "DOM.Iterable", "ESNext"], + strict: true, + jsx: "preserve", + allowJs: true, + checkJs: false, + skipLibCheck: true, + }, + include: [], +}); diff --git a/src/util/lsp/server.worker.ts b/src/util/lsp/server.worker.ts new file mode 100644 index 0000000000..448562fbe8 --- /dev/null +++ b/src/util/lsp/server.worker.ts @@ -0,0 +1,18 @@ +// Web Worker that runs the Marko language server in the browser. It assembles +// the language server's embedded services (`./server`) and hands them the +// worker scope and the seeded virtual-disk assets (TypeScript libs + Marko type +// definitions), gathered at build time by the `virtual:marko-lsp-assets` plugin. +import "./worker-error-report"; + +import { start } from "./server"; +import assets from "virtual:marko-lsp-assets"; + +try { + start(self, { assets }); +} catch (err) { + (self as unknown as { postMessage(m: unknown): void }).postMessage({ + type: "response", + id: -1, + error: `start failed: ${(err as Error)?.stack || err}`, + }); +} diff --git a/src/util/lsp/system.ts b/src/util/lsp/system.ts new file mode 100644 index 0000000000..7749786141 --- /dev/null +++ b/src/util/lsp/system.ts @@ -0,0 +1,78 @@ +// A `ts.System` backed by the virtual disk. TypeScript uses `ts.sys` directly +// (outside the language-service host) for config discovery and module +// resolution, so the browser build installs this in place of the Node system +// that does not exist in a worker (via `setSystem` from +// `@marko/language-server/browser`). +import type ts from "typescript/lib/tsserverlibrary"; + +import * as vfs from "./vfs"; + +export function createBrowserSystem( + typescript: typeof ts, + currentDirectory = "/", +): ts.System { + const system: ts.System = { + args: [], + newLine: "\n", + useCaseSensitiveFileNames: true, + write() {}, + readFile(path) { + return vfs.readFile(path); + }, + writeFile(path, data) { + vfs.writeFile(path, data); + }, + fileExists(path) { + return vfs.fileExists(path); + }, + directoryExists(path) { + return vfs.directoryExists(path); + }, + createDirectory() {}, + getExecutingFilePath() { + return currentDirectory; + }, + getCurrentDirectory() { + return currentDirectory; + }, + getDirectories(path) { + return vfs.getDirectories(path); + }, + readDirectory(path, extensions, excludes, includes, depth) { + // Reuse TypeScript's own glob matcher against the virtual disk so that + // `include`/`exclude` in tsconfig behave exactly as they do on Node. + return (typescript as any).matchFiles( + path, + extensions, + excludes, + includes, + /* useCaseSensitiveFileNames */ true, + currentDirectory, + depth, + getAccessibleFileSystemEntries, + (p: string) => p, + (p: string) => vfs.directoryExists(p), + ); + }, + exit() {}, + resolvePath(path) { + return path; + }, + realpath(path) { + return path; + }, + }; + + return system; + + function getAccessibleFileSystemEntries(path: string) { + const files: string[] = []; + const directories: string[] = []; + const base = path.endsWith("/") ? path : path + "/"; + for (const entry of vfs.readDirectory(path)) { + if (vfs.fileExists(base + entry)) files.push(entry); + else directories.push(entry); + } + return { files, directories }; + } +} diff --git a/src/util/lsp/vfs.ts b/src/util/lsp/vfs.ts new file mode 100644 index 0000000000..0c2cf5757f --- /dev/null +++ b/src/util/lsp/vfs.ts @@ -0,0 +1,100 @@ +// A tiny in-memory "disk" for the in-browser language server. It stands in for +// the pieces of Node's `fs` that the server and TypeScript touch when running in +// a worker: the bundled `lib.*.d.ts` files, the Marko type definitions, a +// project `tsconfig.json`, and the user's open project files. +// +// The server keeps reading through the normal `documents`/`ts.System` paths -- +// those bottom out at the Node `fs` shim (`node-shims/fs.ts`) and the `ts.System` +// shim (`system.ts`), both of which read from this map. + +// Backed by a `globalThis` singleton so every bundled copy of this module reads +// and writes the same virtual disk. The worker imports it directly, but the +// pre-bundled `@marko/compiler` reaches its own copy through the `fs` shim (its +// taglib finder scans the disk for sibling `.marko` tags); both must see the +// same files. +const store = (( + globalThis as typeof globalThis & { + __markoLspVfs?: { + files: Map; + directories: Set; + }; + } +).__markoLspVfs ??= { + files: new Map(), + directories: new Set(["/"]), +}); +const files = store.files; +const directories = store.directories; + +/** Absolute, POSIX-style path -- the only kind that exists on the virtual disk. */ +export function writeFile(path: string, content: string): void { + path = normalize(path); + files.set(path, content); + registerDirs(path); +} + +export function deleteFile(path: string): void { + files.delete(normalize(path)); +} + +export function readFile(path: string): string | undefined { + return files.get(normalize(path)); +} + +export function fileExists(path: string): boolean { + return files.has(normalize(path)); +} + +export function directoryExists(path: string): boolean { + return directories.has(ensureTrailingSlash(normalize(path))); +} + +/** Immediate children (files and directories) of a directory. */ +export function readDirectory(path: string): string[] { + const dir = ensureTrailingSlash(normalize(path)); + const seen = new Set(); + for (const file of files.keys()) { + if (file.startsWith(dir)) { + const rest = file.slice(dir.length); + const slash = rest.indexOf("/"); + seen.add(slash === -1 ? rest : rest.slice(0, slash)); + } + } + return [...seen]; +} + +export function getDirectories(path: string): string[] { + const dir = ensureTrailingSlash(normalize(path)); + const seen = new Set(); + for (const file of files.keys()) { + if (file.startsWith(dir)) { + const rest = file.slice(dir.length); + const slash = rest.indexOf("/"); + if (slash !== -1) seen.add(rest.slice(0, slash)); + } + } + return [...seen]; +} + +export function allFiles(): IterableIterator { + return files.keys(); +} + +function registerDirs(path: string) { + let index = path.indexOf("/", 1); + while (index !== -1) { + directories.add(path.slice(0, index + 1)); + index = path.indexOf("/", index + 1); + } +} + +function normalize(path: string) { + // Collapse `\` to `/` and strip a `file://` scheme if one slipped through. + path = path.replace(/\\/g, "/"); + if (path.startsWith("file://")) path = path.slice("file://".length); + return path.startsWith("/") ? path : "/" + path; +} + +function ensureTrailingSlash(path: string) { + return path.endsWith("/") ? path : path + "/"; +} diff --git a/src/util/lsp/worker-error-report.ts b/src/util/lsp/worker-error-report.ts new file mode 100644 index 0000000000..674bf5b148 --- /dev/null +++ b/src/util/lsp/worker-error-report.ts @@ -0,0 +1,22 @@ +// Registers worker-global error handlers before the language server modules +// evaluate, so an import-time or async crash is reported to the client instead +// of silently killing the worker. Imported first by `server.worker.ts`. +const report = (error: string) => + (self as unknown as { postMessage(m: unknown): void }).postMessage({ + type: "response", + id: -1, + error, + }); + +self.addEventListener("error", (event) => { + report( + `worker error: ${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`, + ); +}); + +self.addEventListener("unhandledrejection", (event) => { + const reason = (event as PromiseRejectionEvent).reason; + report(`worker rejection: ${reason?.stack || reason?.message || reason}`); +}); + +export {}; diff --git a/src/util/workspace.ts b/src/util/workspace.ts index f6e45451bb..22c7cc97aa 100644 --- a/src/util/workspace.ts +++ b/src/util/workspace.ts @@ -10,6 +10,7 @@ import { cssPlugin } from "./workspace/css-plugin"; import { mainPlugin } from "./workspace/main-plugin"; import { markoPlugin } from "./workspace/marko-plugin"; import { minifyScriptPlugin } from "./workspace/minify-script-plugin"; +import { scriptPlugin } from "./workspace/script-plugin"; import { toByteSizes, type Sizes } from "./sizes"; import { FileSystem } from "./workspace/fs"; @@ -46,7 +47,14 @@ export interface Workspace { let workspace: Workspace | undefined; const encoder = new TextEncoder(); -const rootDir = "/tags/"; +/** + * Virtual directory every playground file lives under. Rooting the files in a + * `tags/` directory makes Marko's tag discovery treat them as mutually + * referenceable custom tags, so `` resolves to a sibling `foo.marko`. The + * language server keys on the same root (see `util/lsp/client`) so its analysis + * matches what the runtime actually builds. + */ +export const rootDir = "/tags/"; const subs = new Set<(workspace: Workspace) => void>(); function formatLogArgs(args: unknown[]): string { return args @@ -159,6 +167,7 @@ export async function update( ws, browser: false, }), + scriptPlugin(), cssPlugin({ browser: false }), cdnPlugin(), minifyScriptPlugin(), @@ -210,6 +219,7 @@ export async function update( code: `import "${rootDir}index.marko?hydrate"`, }), markoPlugin({ ws, browser: true }), + scriptPlugin(), cssPlugin({ browser: true }), cdnPlugin(), minifyScriptPlugin(), diff --git a/src/util/workspace/main-plugin.ts b/src/util/workspace/main-plugin.ts index dbd67cb92f..b7e49e1e04 100644 --- a/src/util/workspace/main-plugin.ts +++ b/src/util/workspace/main-plugin.ts @@ -12,6 +12,19 @@ export interface MainPluginOptions { const mainId = "\0main"; const excludeId = "\0exclude"; +// Extension search order for extensionless imports. Extends resolve-sync's +// `.js`/`.json` default with the TypeScript variants the script plugin +// transpiles, so a `.marko` file can import a sibling `./util` (util.ts). +const resolveExtensions = [ + ".js", + ".mjs", + ".cjs", + ".ts", + ".mts", + ".cts", + ".json", +]; + export function mainPlugin({ ws: { fs }, code, @@ -72,6 +85,7 @@ export function mainPlugin({ silent: true, fs: resolveFs, from: importer || mainId, + exts: resolveExtensions, }); if (resolved === false) { diff --git a/src/util/workspace/script-plugin.ts b/src/util/workspace/script-plugin.ts new file mode 100644 index 0000000000..6e96eb245d --- /dev/null +++ b/src/util/workspace/script-plugin.ts @@ -0,0 +1,39 @@ +import type { Plugin } from "@rollup/browser"; +import { + pluginTransformTypeScript, + transformSync, +} from "@marko/compiler/internal/babel"; + +// Standalone script modules the preview must strip types from before bundling. +// `.marko` carries its own types (compiled by the Marko plugin) and plain `.js` +// needs no transform, so only bare TypeScript files match here. +const TS_FILE = /\.[cm]?ts$/; + +/** + * Transpile standalone `.ts`/`.mts`/`.cts` modules to JavaScript for the + * in-browser preview. `@rollup/browser` parses JavaScript only, so a `.marko` + * file importing a sibling `.ts` helper would build in the language server yet + * fail to run in the preview without this. Reuses the Babel bundled with + * `@marko/compiler` (already loaded to compile `.marko`) so it adds no weight. + */ +export function scriptPlugin(): Plugin { + return { + name: "script", + transform(code, id) { + const suffix = /\?.*$/.exec(id)?.[0]; + const file = suffix ? id.slice(0, -suffix.length) : id; + if (!TS_FILE.test(file)) return; + + const result = transformSync(code, { + filename: file, + babelrc: false, + configFile: false, + sourceMaps: true, + plugins: [[pluginTransformTypeScript, {}]], + }); + + if (result?.code == null) return; + return { code: result.code, map: result.map }; + }, + }; +} diff --git a/vite.config.ts b/vite.config.ts index cc38b3434f..e410ec7fa9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,11 @@ import path from "node:path"; import { defineConfig } from "vite"; import marko from "@marko/run/vite"; import markodown from "./src/util/markodown"; +import { + markoLspAssets, + markoLspOptimizeShims, + markoLspResolve, +} from "./src/util/lsp/assets-plugin"; export default defineConfig({ // BASE_URL is set to "/previews/pr-N/" by the PR Preview workflow so the site can be @@ -25,6 +30,13 @@ export default defineConfig({ external: ["browserslist"], }, }, + // The playground language server runs in a Web Worker built from + // `@marko/language-server`'s browser entry. These plugins are scoped to worker + // bundles so the Node builtin shims never leak into the client or server build. + worker: { + format: "es", + plugins: () => [markoLspResolve(), markoLspAssets()], + }, resolve: { alias: [ // marko/translator uses `node:path` which must be shimmed for browser environments. @@ -45,8 +57,15 @@ export default defineConfig({ }, optimizeDeps: { exclude: ["@rollup/browser", "lightningcss-wasm"], + // The browser language server bundles `@marko/compiler`; its taglib finder + // scans the virtual disk for sibling `.marko` tags through `require("fs")`, + // which the optimizer would otherwise stub out. Shim it here so custom-tag + // discovery works in the pre-bundled worker. + rolldownOptions: { + plugins: [markoLspOptimizeShims()], + }, }, - plugins: [markodown(), marko()], + plugins: [markoLspResolve(), markoLspAssets(), markodown(), marko()], css: { modules: { generateScopedName: