`)
+
+await rm(outputDirectory, { force: true, recursive: true })
+await mkdir(outputDirectory, { recursive: true })
+const libFileNames = (await readdir(libDirectory)).filter(fileName => /^lib(?:\..+)?\.d\.ts$/.test(fileName)).sort()
+const libFiles = Object.fromEntries(
+ await Promise.all(
+ libFileNames.map(async fileName => [
+ `/${basename(fileName)}`,
+ await readFile(resolve(libDirectory, fileName), "utf8"),
+ ])
+ )
+)
+const libFilesJSON = JSON.stringify(libFiles)
+const [wasmBytes, schemaBytes] = await Promise.all([readFile(wasmFile), readFile(configSchema)])
+const assetCacheVersion = createHash("sha256")
+ .update(wasmBytes)
+ .update(libFilesJSON)
+ .update(schemaBytes)
+ .digest("hex")
+ .slice(0, 16)
+await Promise.all([
+ writeFile(resolve(outputDirectory, "index.html"), indexHtml),
+ cp(coiServiceWorker, resolve(outputDirectory, "coi-serviceworker.min.js")),
+ cp(configSchema, resolve(outputDirectory, "tsconfig.schema.json")),
+ cp(releaseIndex, resolve(outputDirectory, "versions.json")),
+ cp(examplesIndex, resolve(outputDirectory, "examples.json")),
+ cp(helpIndex, resolve(outputDirectory, "help.json")),
+ cp(wasmFile, resolve(outputDirectory, "tsc.wasm")),
+ writeFile(resolve(outputDirectory, "lib-files.json"), libFilesJSON),
+])
+
+const buildContext = await context({
+ absWorkingDir: packageDirectory,
+ bundle: true,
+ conditions: ["browser", "default"],
+ define: {
+ __ASSET_CACHE_VERSION__: JSON.stringify(assetCacheVersion),
+ __LOAD_ASSET_SIZES__: JSON.stringify({
+ libraries: Buffer.byteLength(libFilesJSON),
+ schema: schemaBytes.byteLength,
+ wasm: wasmBytes.byteLength,
+ }),
+ __TS_VERSION__: JSON.stringify(version),
+ },
+ entryNames: "[name]",
+ entryPoints: {
+ main: resolve(packageDirectory, "src/main.ts"),
+ "editor.worker": editorWorker,
+ "strada.worker": resolve(packageDirectory, "src/strada.worker.ts"),
+ "native.worker": resolve(packageDirectory, "src/native.worker.ts"),
+ "tsgo-lsp.worker": resolve(packageDirectory, "src/tsgo-lsp.worker.ts"),
+ },
+ format: "esm",
+ loader: {
+ ".ttf": "file",
+ ".woff": "file",
+ ".woff2": "file",
+ },
+ outdir: outputDirectory,
+ platform: "browser",
+ sourcemap: true,
+ target: ["es2022"],
+})
+
+if (serve) {
+ await buildContext.watch()
+ const server = await buildContext.serve({
+ host: "127.0.0.1",
+ port: 4173,
+ servedir: outputDirectory,
+ })
+ console.log(`TypeScript Playground v2: http://${server.host}:${server.port}`)
+} else {
+ await buildContext.rebuild()
+ await buildContext.dispose()
+ await rm(websiteStaticDirectory, { force: true, recursive: true })
+ await cp(outputDirectory, websiteStaticDirectory, { recursive: true })
+ await Promise.all(
+ compatibilityDirectories.map(async directory => {
+ await mkdir(directory, { recursive: true })
+ await writeFile(resolve(directory, "index.html"), compatibilityHtml)
+ })
+ )
+ console.log(`Built TypeScript ${version} playground in ${outputDirectory}`)
+}
diff --git a/packages/playground-v2/scripts/routes.mjs b/packages/playground-v2/scripts/routes.mjs
new file mode 100644
index 000000000000..2b3d6014aed3
--- /dev/null
+++ b/packages/playground-v2/scripts/routes.mjs
@@ -0,0 +1,12 @@
+export const playgroundPath = "/play/"
+export const compatibilityPaths = ["play/v2", "play/7", "v2"]
+export const compatibilityScript = `location.replace(${JSON.stringify(playgroundPath)} + location.search + location.hash)`
+
+export const compatibilityHtml = `
+
+TypeScript Playground v2
+
+Continue to TypeScript Playground v2
+`
diff --git a/packages/playground-v2/scripts/smoke-test.mjs b/packages/playground-v2/scripts/smoke-test.mjs
new file mode 100644
index 000000000000..50454211cc06
--- /dev/null
+++ b/packages/playground-v2/scripts/smoke-test.mjs
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict"
+import { readFile, readdir } from "node:fs/promises"
+import { resolve } from "node:path"
+import { runInNewContext } from "node:vm"
+import { compatibilityHtml, compatibilityPaths, compatibilityScript, playgroundPath } from "./routes.mjs"
+import { API } from "@typescript/typescript/unstable/sync"
+import { instantiateWasm, WasmTransport, wasmURL } from "@typescript/typescript-wasip1-wasm"
+
+const packageDirectory = resolve(import.meta.dirname, "..")
+const wasm = await readFile(wasmURL)
+const module = await WebAssembly.compile(wasm)
+const instance = await instantiateWasm(module)
+const transport = new WasmTransport({ instance, cwd: "/workspace" })
+const api = new API({ transport })
+
+try {
+ assert(compatibilityHtml.includes(``))
+ for (const path of compatibilityPaths) {
+ const original = new URL(`https://example.test/${path}/?ts=6.0.3&target=99#code/v2/encoded+project`)
+ let redirected
+ runInNewContext(compatibilityScript, {
+ location: {
+ search: original.search,
+ hash: original.hash,
+ replace(value) {
+ redirected = value
+ },
+ },
+ })
+ assert.equal(redirected, `${playgroundPath}${original.search}${original.hash}`)
+ }
+
+ const libDirectory = resolve(packageDirectory, "vendor/lib")
+ const libFileNames = (await readdir(libDirectory)).filter(fileName => /^lib(?:\..+)?\.d\.ts$/.test(fileName))
+ for (const fileName of libFileNames) {
+ transport.setFile(`/${fileName}`, await readFile(resolve(libDirectory, fileName), "utf8"))
+ }
+
+ const files = {
+ "/workspace/tsconfig.json": JSON.stringify({
+ compilerOptions: {
+ declaration: true,
+ module: "CommonJS",
+ strict: true,
+ target: "ES2022",
+ },
+ include: ["./src/**/*"],
+ }),
+ "/workspace/src/greet.ts": "export const greet = (name: string) => `Hello, ${name}!`;",
+ "/workspace/src/index.ts": 'import { greet } from "./greet"; console.log(greet("TS7"));',
+ }
+ for (const [fileName, source] of Object.entries(files)) {
+ transport.setFile(fileName, source)
+ }
+
+ const config = api.readConfigFile("/workspace/tsconfig.json")
+ assert.equal(config.error, undefined)
+ const parsed = api.parseJsonConfigFileContent(config.config, {
+ configFileName: "/workspace/tsconfig.json",
+ })
+ assert.deepEqual(parsed.fileNames, ["/workspace/src/greet.ts", "/workspace/src/index.ts"])
+ const program = api.createProgram(parsed.fileNames, parsed.options, {
+ projectReferences: parsed.projectReferences,
+ configFileParsingDiagnostics: parsed.errors,
+ })
+ try {
+ assert.equal(program.getSyntacticDiagnostics().length, 0)
+ assert.equal(program.getSemanticDiagnostics().length, 0)
+ const emit = program.emitToString()
+ assert.equal(emit.emitSkipped, false)
+ assert.deepEqual(
+ [...emit.outputFiles.keys()],
+ ["/workspace/src/greet.d.ts", "/workspace/src/greet.js", "/workspace/src/index.d.ts", "/workspace/src/index.js"]
+ )
+ assert.match(emit.outputFiles.get("/workspace/src/index.js").text, /require\("\.\/greet"\)/)
+ } finally {
+ program.dispose()
+ }
+} finally {
+ api.close()
+}
+
+console.log("TypeScript WASM API smoke test passed")
diff --git a/packages/playground-v2/scripts/test-ata.mjs b/packages/playground-v2/scripts/test-ata.mjs
new file mode 100644
index 000000000000..c8c91a2e6ee1
--- /dev/null
+++ b/packages/playground-v2/scripts/test-ata.mjs
@@ -0,0 +1,31 @@
+import { build } from "esbuild"
+import { spawnSync } from "node:child_process"
+import { mkdir, rm } from "node:fs/promises"
+import { resolve } from "node:path"
+
+const packageDirectory = resolve(import.meta.dirname, "..")
+const testFiles = ["ata.test.mjs", "native-compiler.test.mjs"].map(name => resolve(packageDirectory, "dist", name))
+await mkdir(resolve(packageDirectory, "dist"), { recursive: true })
+try {
+ await build({
+ absWorkingDir: packageDirectory,
+ bundle: true,
+ conditions: ["browser"],
+ entryPoints: ["src/ata/ata.test.ts", "src/native-compiler.test.ts"],
+ entryNames: "[name]",
+ outExtension: { ".js": ".mjs" },
+ external: ["jsonc-parser"],
+ format: "esm",
+ outdir: resolve(packageDirectory, "dist"),
+ platform: "node",
+ target: ["es2022"],
+ })
+ const result = spawnSync(process.execPath, ["--test", ...testFiles], {
+ env: { ...process.env, PLAYGROUND_DIR: packageDirectory },
+ stdio: "inherit",
+ })
+ if (result.error) throw result.error
+ if (result.status !== 0) process.exitCode = result.status ?? 1
+} finally {
+ await Promise.all(testFiles.map(file => rm(file, { force: true })))
+}
diff --git a/packages/playground-v2/scripts/vendor-resources.mjs b/packages/playground-v2/scripts/vendor-resources.mjs
new file mode 100644
index 000000000000..61c14889c80a
--- /dev/null
+++ b/packages/playground-v2/scripts/vendor-resources.mjs
@@ -0,0 +1,82 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises"
+import { dirname, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..")
+const websiteDirectory = resolve(packageDirectory, "../..")
+const examplesDirectory = resolve(websiteDirectory, "packages/typescriptlang-org/static/js/examples")
+const examplesIndex = JSON.parse(await readFile(resolve(examplesDirectory, "en.json"), "utf8"))
+const helpIndex = {
+ docs: [
+ {
+ title: "Projects and files",
+ legacyIndexes: [6, 7],
+ html: "The playground is a virtual project rooted at /workspace. Use + File to add source or configuration files, select files in the project tree, and use Delete to remove the active project file.
Automatic compilation and native package discovery run in workers so editing remains responsive.
The project is saved in the page URL and browser storage as you edit.
",
+ },
+ {
+ title: "Compiler versions",
+ legacyIndexes: [8],
+ html: "The selector offers TypeScript 7.1, the latest patch release for each previous major.minor line, and custom playground-CDN build IDs.
Changing compiler versions reloads the page while preserving the project.
",
+ },
+ {
+ title: "Compiler settings",
+ legacyIndexes: [0],
+ html: "Edit tsconfig.json directly. The editor provides schema completion, hover documentation, and validation for compiler options.
Emit and diagnostics use the files and options selected by that configuration.
",
+ },
+ {
+ title: "Package types",
+ legacyIndexes: [4],
+ html: "The selected compiler's parser discovers package imports and type references. Automatic acquisition downloads bundled declarations or matching @types packages, caches them, and supplies them to diagnostics, completion, hover, and go-to-definition. Node builtins use @types/node; package JavaScript is never downloaded or executed.
Acquisition runs in the background without delaying compiler startup or initial emit. The status shows pending packages and file progress, including metadata resolution. Missing-type diagnostics are updated after acquisition finishes.
typeAcquisition.enable, include, and exclude control the project. Disabling automatic package types in Playground Settings prevents all acquisition. Removing imports or changing these options removes unneeded acquired files.
When compilerOptions.types is not specified, acquired @types packages are added to the effective configuration. TSConfig explains this addition and links to the effective config. An explicit types list, including an empty list, is preserved.
",
+ },
+ {
+ title: "Editor navigation",
+ legacyIndexes: [11],
+ html: "Use hover, completion, references, rename, formatting, quick fixes, and F12 go-to-definition as in an editor. When viewing a file outside the project explorer, the back arrow returns to your project file and selection.
Place // ^? beneath an expression to display its inferred type.
",
+ },
+ {
+ title: "Emit and Run",
+ legacyIndexes: [2, 3],
+ html: "The Emit panel shows every generated JavaScript and declaration file. Run executes the emitted project with an in-browser CommonJS loader and captures console output.
Set compilerOptions.module to CommonJS when running a project.
",
+ },
+ {
+ title: "Examples",
+ legacyIndexes: [1],
+ html: "Use the Examples button to browse the bundled TypeScript and JavaScript examples. Choosing an example replaces the current project, applies its compiler settings, and selects its requested compiler version.
Links written as // example:example-id are clickable in the editor.
",
+ },
+ {
+ title: "URLs and compatibility",
+ legacyIndexes: [9, 10],
+ html: "The playground is served at /play/. Previous /play/v2/, /play/7/, and /v2/ URLs redirect here without changing their query or hash. The legacy playground remains at /oldplay/.
New projects use the versioned #code/v2/ format. Old #code/ source links, #src=, compiler-option queries, selections, filename directives, and legacy example and handbook hashes remain supported.
Project links and browser storage update after a brief pause in typing or cursor movement. Pending edits are saved when leaving or reloading.
",
+ },
+ {
+ title: "Settings",
+ legacyIndexes: [5],
+ html: "Use Settings to control automatic package type acquisition, URL updates while typing, font size, tab size, theme, desktop word wrapping, the minimap, and font ligatures.
Changing automatic type acquisition or the theme reloads the page so the language-service filesystem and editor colors are initialized consistently.
",
+ },
+ ],
+}
+
+const examples = await Promise.all(
+ examplesIndex.examples.map(async example => {
+ const fileName = resolve(examplesDirectory, example.lang, ...example.path, example.name)
+ let code = await readFile(fileName, "utf8")
+ if (code.startsWith("//// {")) code = code.split(/\r?\n/).slice(1).join("\n").trim()
+ return { ...example, code }
+ })
+)
+
+const vendorDirectory = resolve(packageDirectory, "vendor")
+await mkdir(vendorDirectory, { recursive: true })
+await Promise.all([
+ writeFile(
+ resolve(vendorDirectory, "examples.json"),
+ `${JSON.stringify({
+ examples,
+ sections: examplesIndex.sections,
+ sortedSubSections: examplesIndex.sortedSubSections,
+ })}\n`
+ ),
+ writeFile(resolve(vendorDirectory, "help.json"), `${JSON.stringify(helpIndex)}\n`),
+])
+
+console.log(`Vendored ${examples.length} playground examples and ${helpIndex.docs.length} help topics`)
diff --git a/packages/playground-v2/scripts/vendor-typescript.mjs b/packages/playground-v2/scripts/vendor-typescript.mjs
new file mode 100644
index 000000000000..0928af06347e
--- /dev/null
+++ b/packages/playground-v2/scripts/vendor-typescript.mjs
@@ -0,0 +1,96 @@
+import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
+import { dirname, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+import { spawnSync } from "node:child_process"
+
+const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..")
+const websiteDirectory = resolve(packageDirectory, "../..")
+const typescriptDirectory = resolve(process.env.TYPESCRIPT_REPO || resolve(websiteDirectory, "../TypeScript"))
+const vendorDirectory = resolve(packageDirectory, "vendor")
+const typescriptPackage = resolve(typescriptDirectory, "packages/typescript")
+const wasmPackage = resolve(typescriptDirectory, "packages/typescript-wasip1-wasm")
+const libDirectory = resolve(typescriptDirectory, "built/local")
+const packageNames = ["typescript", "typescript-wasip1-wasm"]
+const legalFiles = ["LICENSE.txt", "NOTICE.txt"]
+
+await Promise.all([
+ rm(resolve(vendorDirectory, "lib"), { force: true, recursive: true }),
+ rm(resolve(vendorDirectory, "typescript"), { force: true, recursive: true }),
+ rm(resolve(vendorDirectory, "typescript-wasip1-wasm"), { force: true, recursive: true }),
+ rm(resolve(vendorDirectory, "source-revision.txt"), { force: true }),
+ rm(resolve(vendorDirectory, "version.txt"), { force: true }),
+])
+await Promise.all(packageNames.map(packageName => mkdir(resolve(vendorDirectory, packageName), { recursive: true })))
+await Promise.all([
+ cp(resolve(typescriptPackage, "dist"), resolve(vendorDirectory, "typescript/dist"), {
+ recursive: true,
+ }),
+ cp(resolve(wasmPackage, "dist"), resolve(vendorDirectory, "typescript-wasip1-wasm/dist"), {
+ recursive: true,
+ }),
+ mkdir(resolve(vendorDirectory, "typescript-wasip1-wasm/lib"), { recursive: true }).then(() =>
+ cp(resolve(wasmPackage, "lib/tsc.wasm"), resolve(vendorDirectory, "typescript-wasip1-wasm/lib/tsc.wasm"))
+ ),
+ writeVendorManifest(typescriptPackage, "typescript"),
+ writeVendorManifest(wasmPackage, "typescript-wasip1-wasm"),
+ ...packageNames.flatMap(packageName =>
+ legalFiles.map(fileName =>
+ cp(resolve(typescriptDirectory, fileName), resolve(vendorDirectory, packageName, fileName))
+ )
+ ),
+])
+
+const vendorLibDirectory = resolve(vendorDirectory, "lib")
+await mkdir(vendorLibDirectory, { recursive: true })
+const libFileNames = (await readdir(libDirectory)).filter(fileName => /^lib(?:\..+)?\.d\.ts$/.test(fileName))
+await Promise.all(
+ libFileNames.map(fileName => cp(resolve(libDirectory, fileName), resolve(vendorLibDirectory, fileName)))
+)
+
+const versionResult = spawnSync(resolve(typescriptDirectory, "built/local/tsc"), ["--version"], { encoding: "utf8" })
+if (versionResult.status !== 0) {
+ throw new Error(versionResult.stderr || "Unable to read the TypeScript version")
+}
+const version = versionResult.stdout.trim().replace(/^Version\s+/, "")
+const revisionResult = spawnSync("git", ["-C", typescriptDirectory, "rev-parse", "HEAD"], {
+ encoding: "utf8",
+})
+if (revisionResult.status !== 0) {
+ throw new Error(revisionResult.stderr || "Unable to read the TypeScript source revision")
+}
+await Promise.all([
+ writeFile(resolve(vendorDirectory, "source-revision.txt"), revisionResult.stdout.trim() + "\n"),
+ writeFile(resolve(vendorDirectory, "version.txt"), `${version}\n`),
+])
+
+console.log(`Vendored TypeScript ${version} from ${typescriptDirectory}`)
+
+async function writeVendorManifest(sourceDirectory, vendorName) {
+ const manifest = JSON.parse(await readFile(resolve(sourceDirectory, "package.json"), "utf8"))
+ const exports = { ...manifest.exports }
+ const imports = { ...manifest.imports }
+ if (vendorName === "typescript") {
+ delete exports["."]
+ delete imports["#getExePath"]
+ delete imports["#vscode-jsonrpc/node"]
+ imports["#asyncClient"] = "./dist/api/async/browserClient.js"
+ imports["#syncClient"] = "./dist/api/sync/browserClient.js"
+ imports["#enums/*"] = {
+ types: "./dist/enums/*.enum.d.ts",
+ default: "./dist/enums/*.js",
+ }
+ }
+ const vendoredManifest = {
+ name: manifest.name,
+ version: manifest.version,
+ license: manifest.license,
+ type: manifest.type,
+ files: vendorName === "typescript-wasip1-wasm" ? ["dist", "lib/tsc.wasm", ...legalFiles] : ["dist", ...legalFiles],
+ exports,
+ imports,
+ }
+ await writeFile(
+ resolve(vendorDirectory, vendorName, "package.json"),
+ `${JSON.stringify(vendoredManifest, undefined, 2)}\n`
+ )
+}
diff --git a/packages/playground-v2/src/ata/README.md b/packages/playground-v2/src/ata/README.md
new file mode 100644
index 000000000000..e24e718b6c58
--- /dev/null
+++ b/packages/playground-v2/src/ata/README.md
@@ -0,0 +1,82 @@
+# Playground v2 package type acquisition
+
+This is an independent browser ATA implementation, not an npm installer.
+It downloads declarations and package metadata only. The old Playground's
+`@typescript/ata` implementation is unchanged.
+
+`PackageTypeAcquirer.acquire` takes source files, a parser-backed dependency
+discovery callback (synchronous or asynchronous), project acquisition options,
+and an abort signal. It returns
+a complete snapshot of virtual files, ambient type package names, resolved
+package versions, and per-package errors. Hosts apply only the current result;
+superseded requests cannot publish stale files.
+
+## Discovery
+
+Corsa runs parser-backed discovery in the native compiler worker, using
+`API.createSourceFile` and the compiler's imports and type-reference
+arrays, plus AST traversal for literal `require` calls. Strada traverses the
+selected compiler's parsed AST. Both ignore comments, ordinary strings, local
+path references, and standard-library references. Literal dynamic imports,
+import types, re-exports, and import-equals are supported. Existing
+`// types: VERSION` annotations are preserved.
+
+## Resolution
+
+- Node builtin imports resolve directly to `@types/node`.
+- Package subpaths resolve to their npm package; scoped DefinitelyTyped names
+ use the `scope__package` convention.
+- Packages with declarations supply their own types; otherwise ATA tries
+ DefinitelyTyped. Registry absence permits fallback, but a broken declaration
+ download or network outage is reported rather than disguised as missing types.
+- Package metadata supplies dependency version ranges; runtime implementations
+ and installation scripts are never fetched or run.
+- All declaration variants and nested package metadata are retained so the
+ compiler can interpret exports and `typesVersions`.
+
+This intentionally uses one flat version per package, with project requests
+before transitive requests. It is not a full nested npm dependency resolver.
+DefinitelyTyped fallback currently uses its latest version; matching historical
+compiler/package versions is a separate limitation.
+
+## Lifecycle and policy
+
+The global Playground setting is a network veto. Otherwise the root TSConfig's
+`typeAcquisition.enable`, `include`, and `exclude` are honored. Exclusion wins
+over inclusion and also filters transitive acquisition. Explicit
+`compilerOptions.types` is not acquisition policy and is never overwritten.
+There is no filename-based inference, so
+`disableFilenameBasedTypeAcquisition` requires no extra behavior.
+The host currently reads acquisition policy from the root configuration, not
+from inherited configurations; the engine accepts already-resolved options.
+
+Each run computes a new reachable snapshot. Successful packages are reused
+within the session; failures are not memoized and can be retried. Exact-version
+files/indexes use Cache Storage; tags are not persisted as immutable data.
+Downloads use a shared concurrency limit, validate virtual paths, and enforce
+package/file/response/project byte limits.
+
+Compiler startup and initial emit do not wait for acquisition.
+Native emit, diagnostics, type queries, and dependency discovery run in a
+dedicated worker, separate from the language-server worker. No native compiler
+instance or `window.ts` API is created on the page. Strada keeps its parser
+internal for dependency discovery. Native snapshots are updated across edits,
+pending compile requests are coalesced, and stale responses cannot replace
+current results.
+Whole-project diagnostics remain independent of Monaco's per-document reports.
+Compilation reuses the snapshot's parsed configuration, and files outside the
+configured project only need an extra program when they contain type queries.
+Diagnostics may initially report missing package types; the host recompiles
+after applying the current acquisition snapshot. Emit still follows compiler
+options, including `noEmitOnError`. The host also refreshes LSP diagnostics
+after package files or effective options change, so initial missing-package
+markers disappear without editing the source. Progress includes pending
+package names and completed/total package counts during metadata resolution,
+as well as downloaded/total declaration and metadata file counts.
+Both totals can increase as transitive dependencies are discovered.
+Unchanged declaration packages reuse their discovered dependencies, and an
+unchanged acquisition snapshot does not trigger another compilation.
+
+The browser tests and offline fixtures exercise parser parity, Node imports,
+DefinitelyTyped fallback, version ranges, config policy, cycles, retries,
+partial failures, cancellation, and resource bounds.
diff --git a/packages/playground-v2/src/ata/ata.test.ts b/packages/playground-v2/src/ata/ata.test.ts
new file mode 100644
index 000000000000..16ac3579a3ed
--- /dev/null
+++ b/packages/playground-v2/src/ata/ata.test.ts
@@ -0,0 +1,435 @@
+import assert from "node:assert/strict"
+import { readFile } from "node:fs/promises"
+import { createRequire } from "node:module"
+import { test } from "node:test"
+import { API } from "@typescript/typescript/unstable/sync"
+import { instantiateWasm, WasmTransport } from "@typescript/typescript-wasip1-wasm"
+import { acquisitionOptions } from "./config"
+import { corsaDiscovery, stradaDiscovery, PackageTypeAcquirer, type AcquisitionProgress, type Dependency } from "./index"
+import { packageName, safePackagePath } from "./packages"
+
+const require = createRequire(import.meta.url)
+const ts: typeof import("typescript") = require("typescript")
+const discover = stradaDiscovery(ts)
+
+type Fixture = { files: Record; dependencies?: Record }
+function registry(fixtures: Record) {
+ const calls: string[] = []
+ const failures = new Set()
+ let active = 0
+ let maximum = 0
+ const fetcher: typeof fetch = async (input, init) => {
+ const url = String(input)
+ calls.push(url)
+ active++
+ maximum = Math.max(active, maximum)
+ try {
+ init?.signal?.throwIfAborted()
+ await new Promise(resolve => setTimeout(resolve, 1))
+ init?.signal?.throwIfAborted()
+ if (failures.delete(url)) return new Response("", { status: 503 })
+ const resolve = /^https:\/\/data\.jsdelivr\.com\/v1\/package\/resolve\/npm\/(.+)@([^/]+)$/.exec(url)
+ const tree = /^https:\/\/data\.jsdelivr\.com\/v1\/package\/npm\/(.+)@1\.0\.0\/flat$/.exec(url)
+ const file = /^https:\/\/cdn\.jsdelivr\.net\/npm\/(.+)@1\.0\.0(\/.+)$/.exec(url)
+ const name = resolve?.[1] ?? tree?.[1] ?? file?.[1]
+ const fixture = name ? fixtures[name] : undefined
+ if (!fixture) return new Response("", { status: 404 })
+ if (resolve) return Response.json({ version: "1.0.0" })
+ if (tree) {
+ return Response.json({ files: ["/package.json", ...Object.keys(fixture.files)].map(name => ({ name })) })
+ }
+ if (file?.[2] === "/package.json") {
+ return Response.json({ name, version: "1.0.0", types: "./index.d.ts", dependencies: fixture.dependencies })
+ }
+ const text = file && fixture.files[file[2]]
+ return text === undefined ? new Response("", { status: 404 }) : new Response(text)
+ } finally {
+ active--
+ }
+ }
+ return { fetcher, calls, failures, maximum: () => maximum }
+}
+
+function source(text: string) {
+ return [{ path: "/workspace/src/index.ts", text }]
+}
+
+test("compiler parsers agree on dependencies, references, and version comments", async () => {
+ const module = await WebAssembly.compile(
+ await readFile(`${process.env.PLAYGROUND_DIR}/vendor/typescript-wasip1-wasm/lib/tsc.wasm`)
+ )
+ const api = new API({ transport: new WasmTransport({ instance: await instantiateWasm(module), cwd: "/workspace" }) })
+ try {
+ const file = {
+ path: "/workspace/input.ts",
+ text: `///
+///
+///
+import { x } from "pkg"; // types: 1.2.3
+import "hereby";
+import "node:url";
+export { y } from "exports";
+import z = require("import-equals");
+const a = import("dynamic");
+type T = import("type-import").T;
+const b = require("required");
+const c = require(variable);
+// import "comment";
+const text = 'import "text"';
+declare module "ambient" {}
+`,
+ }
+ const sort = (items: readonly Dependency[]) => [...items].sort((a, b) => a.specifier.localeCompare(b.specifier))
+ const expected: Dependency[] = [
+ { kind: "types", specifier: "node" },
+ { kind: "import", specifier: "pkg", version: "1.2.3" },
+ ...["hereby", "node:url", "exports", "import-equals", "dynamic", "type-import", "required"].map(
+ (specifier): Dependency => ({
+ kind: "import",
+ specifier,
+ })
+ ),
+ ]
+ assert.deepEqual(sort(await discover(file)), sort(expected))
+ assert.deepEqual(sort(await corsaDiscovery(api)(file)), sort(expected))
+ } finally {
+ api.close()
+ }
+})
+
+test("normalizes Node builtins, DT scopes, and ignores local/URL imports", () => {
+ for (const name of ["node:url", "readline/promises", "stream/iter"]) assert.equal(packageName(name), "@types/node")
+ for (const name of ["./local", "/local", "#alias", "https://example.test/code", "data:text/plain,x"]) {
+ assert.equal(packageName(name), undefined)
+ }
+ assert.equal(packageName("@scope/pkg/subpath"), "@scope/pkg")
+ assert.equal(packageName("test"), "test")
+ for (const path of ["/../outside", "/folder/../outside", "/%2e%2e/outside", "/file?x", "/\\file"]) {
+ assert.equal(safePackagePath(path), false)
+ }
+})
+
+test("accepts asynchronous compiler discovery adapters", async () => {
+ const mock = registry({ pkg: { files: { "/index.d.ts": "export {}" } } })
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('import "pkg"'),
+ discover: async file => discover(file),
+ })
+ assert.equal(result.packages.length, 1)
+ assert.equal(result.errors.length, 0)
+})
+
+test("reports pending metadata, declaration downloads, and cached package completion", async () => {
+ const mock = registry({ pkg: { files: { "/index.d.ts": "export {}", "/extra.d.ts": "export {}" } } })
+ let release!: () => void
+ let started!: () => void
+ const gate = new Promise(resolve => {
+ release = resolve
+ })
+ const metadataStarted = new Promise(resolve => {
+ started = resolve
+ })
+ const engine = new PackageTypeAcquirer({
+ fetcher: async (input, init) => {
+ if (String(input).includes("/resolve/npm/")) {
+ started()
+ await gate
+ }
+ return mock.fetcher(input, init)
+ },
+ })
+ const progress: AcquisitionProgress[] = []
+ const input = {
+ files: source('import "pkg"'),
+ discover,
+ onProgress: (value: AcquisitionProgress) => progress.push(value),
+ }
+ const acquisition = engine.acquire(input)
+ try {
+ await metadataStarted
+ assert.deepEqual(progress.at(-1), {
+ downloaded: 0,
+ total: 0,
+ completedPackages: 0,
+ totalPackages: 1,
+ pendingPackages: ["pkg"],
+ })
+ } finally {
+ release()
+ }
+ await acquisition
+ assert.deepEqual(progress.at(-1), {
+ downloaded: 2,
+ total: 2,
+ completedPackages: 1,
+ totalPackages: 1,
+ pendingPackages: [],
+ })
+ const calls = mock.calls.length
+ await engine.acquire(input)
+ assert.equal(mock.calls.length, calls)
+ assert.deepEqual(progress.at(-1), {
+ downloaded: 0,
+ total: 0,
+ completedPackages: 1,
+ totalPackages: 1,
+ pendingPackages: [],
+ })
+})
+
+test("reuses declaration discovery for unchanged packages but not different compilers", async () => {
+ const mock = registry({
+ pkg: { files: { "/index.d.ts": 'export * from "dep"' } },
+ dep: { files: { "/index.d.ts": "export const value: string" } },
+ })
+ const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher })
+ const parsed: string[] = []
+ const parser = async (file: { path: string; text: string }) => {
+ parsed.push(file.path)
+ return discover(file)
+ }
+ const input = { files: source('import "pkg"'), discover: parser }
+ await engine.acquire(input)
+ const declarations = parsed.filter(path => path.includes("/node_modules/")).length
+ assert.equal(declarations, 2)
+ await engine.acquire({ ...input, files: source('import "pkg"; const edited = true;') })
+ assert.equal(parsed.filter(path => path.includes("/node_modules/")).length, declarations)
+ await engine.acquire({ ...input, discover: file => parser(file) })
+ assert.equal(parsed.filter(path => path.includes("/node_modules/")).length, declarations * 2)
+})
+
+test("downloads declarations and package metadata, never JavaScript", async () => {
+ const mock = registry({
+ pkg: {
+ files: {
+ "/index.d.ts": "export const value: string",
+ "/esm/index.d.mts": "export const value: string",
+ "/cjs/index.d.cts": "export const value: string",
+ "/esm/package.json": '{"type":"module"}',
+ "/index.js": "doNotRun()",
+ },
+ },
+ })
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('import "pkg/subpath"'),
+ discover,
+ })
+ assert.equal(result.errors.length, 0)
+ assert.equal(result.files.size, 5)
+ assert(result.files.has("/workspace/node_modules/pkg/esm/package.json"))
+ assert(!mock.calls.some(url => url.endsWith(".js")))
+})
+
+test("type-reference subpaths can refer to packages shipping their own declarations", async () => {
+ const mock = registry({ vite: { files: { "/client.d.ts": "declare const environment: string;" } } })
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('/// '),
+ discover,
+ })
+ assert.equal(result.errors.length, 0)
+ assert(result.files.has("/workspace/node_modules/vite/client.d.ts"))
+ assert(!mock.calls.some(url => url.includes("@types/vite")))
+})
+
+test("falls back to DT with the actual declaration package metadata", async () => {
+ const mock = registry({
+ plain: { files: { "/index.js": "runtime" } },
+ "@types/plain": { files: { "/index.d.ts": 'declare module "plain" {}' } },
+ "@types/scope__pkg": { files: { "/index.d.ts": "export const value: string" } },
+ })
+ const progress: AcquisitionProgress[] = []
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('import "plain"; import "@scope/pkg"'),
+ discover,
+ onProgress: value => progress.push(value),
+ })
+ assert.deepEqual(result.ambientTypes, ["plain", "scope__pkg"])
+ assert(result.files.has("/workspace/node_modules/@types/plain/package.json"))
+ assert(!result.files.has("/workspace/node_modules/plain/package.json"))
+ assert.equal(progress.at(-1)?.totalPackages, 2)
+ assert.equal(progress.at(-1)?.completedPackages, 2)
+})
+
+test("Node requests go directly to @types/node and do not recurse into builtins", async () => {
+ const mock = registry({
+ "@types/node": { files: { "/index.d.ts": 'import "readline/promises"; import "stream/iter"' } },
+ })
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('import "node:url"'),
+ discover,
+ })
+ assert.deepEqual(result.ambientTypes, ["node"])
+ assert.equal(result.packages.length, 1)
+ assert(!mock.calls.some(url => /\/npm\/(?:node|readline|stream)@/.test(url)))
+})
+
+test("honors include, exclude, and enable with complete snapshots", async () => {
+ const mock = registry({
+ included: { files: { "/index.d.ts": "export {}" } },
+ blocked: { files: { "/index.d.ts": "export {}" } },
+ })
+ const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher })
+ const result = await engine.acquire({
+ files: source('import "blocked"'),
+ discover,
+ options: { include: ["included"], exclude: ["blocked"] },
+ })
+ assert.deepEqual(
+ result.packages.map(pkg => pkg.name),
+ ["included"]
+ )
+ const disabled = await engine.acquire({ files: [], discover, options: { enable: false, include: ["included"] } })
+ assert.equal(disabled.files.size, 0)
+ const empty = await engine.acquire({ files: [], discover })
+ assert.equal(empty.files.size, 0)
+})
+
+test("preserves package dependency ranges, deduplicates cycles, and bounds concurrency", async () => {
+ const mock = registry({
+ pkg: { files: { "/index.d.ts": 'export * from "dep"' }, dependencies: { dep: "^2.0.0" } },
+ dep: { files: { "/index.d.ts": 'export * from "pkg"' } },
+ })
+ const progress: AcquisitionProgress[] = []
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { concurrency: 2 } }).acquire({
+ files: source('import "pkg"'),
+ discover,
+ onProgress: value => progress.push(value),
+ })
+ assert.equal(result.packages.length, 2)
+ assert(mock.calls.some(url => url.includes("dep@%5E2.0.0")))
+ assert(mock.maximum() <= 2)
+ assert.equal(progress.at(-1)?.completedPackages, 2)
+ assert.equal(progress.at(-1)?.totalPackages, 2)
+ assert.deepEqual(progress.at(-1)?.pendingPackages, [])
+})
+
+test("source version annotations take precedence over an unversioned include", async () => {
+ const mock = registry({ pkg: { files: { "/index.d.ts": "export {}" } } })
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('import "pkg"; // types: 1.2.3'),
+ discover,
+ options: { include: ["pkg"] },
+ })
+ assert.equal(result.packages.length, 1)
+ assert(mock.calls.some(url => url.includes("pkg@1.2.3")))
+ assert(!mock.calls.some(url => url.includes("pkg@latest")))
+})
+
+test("isolates package errors, caches successes, and retries failures", async () => {
+ const mock = registry({
+ good: { files: { "/index.d.ts": "export {}" } },
+ retry: { files: { "/index.d.ts": "export {}" } },
+ })
+ mock.failures.add("https://cdn.jsdelivr.net/npm/retry@1.0.0/index.d.ts")
+ const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher })
+ const progress: AcquisitionProgress[] = []
+ const input = {
+ files: source('import "good"; import "retry"'),
+ discover,
+ onProgress: (value: AcquisitionProgress) => progress.push(value),
+ }
+ const first = await engine.acquire(input)
+ assert.equal(first.errors[0].packageName, "retry")
+ assert(first.files.has("/workspace/node_modules/good/index.d.ts"))
+ assert(!first.files.has("/workspace/node_modules/retry/index.d.ts"))
+ assert(!mock.calls.some(url => url.includes("@types/retry")))
+ assert.equal(progress.at(-1)?.completedPackages, 2)
+ assert.deepEqual(progress.at(-1)?.pendingPackages, [])
+ const calls = mock.calls.filter(url => url.includes("good@")).length
+ const second = await engine.acquire(input)
+ assert.equal(second.errors.length, 0)
+ assert.equal(mock.calls.filter(url => url.includes("good@")).length, calls)
+})
+
+test("aborted acquisition cannot return a stale success or poison retries", async () => {
+ const mock = registry({ pkg: { files: { "/index.d.ts": "export {}" } } })
+ const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher })
+ const controller = new AbortController()
+ const promise = engine.acquire({ files: source('import "pkg"'), discover, signal: controller.signal })
+ controller.abort()
+ await assert.rejects(promise, { name: "AbortError" })
+ const result = await engine.acquire({ files: source('import "pkg"'), discover })
+ assert.equal(result.errors.length, 0)
+})
+
+test("queued downloads release their permits when an acquisition is cancelled", async () => {
+ const fixtures: Record = {}
+ for (const name of ["one", "two", "three", "four"]) {
+ fixtures[name] = { files: { "/index.d.ts": "export {}", "/extra.d.ts": "export {}" } }
+ }
+ const mock = registry(fixtures)
+ const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { concurrency: 2 } })
+ const controller = new AbortController()
+ const input = { files: source('import "one"; import "two"; import "three"; import "four";'), discover }
+ let progressCount = 0
+ let countAtAbort = 0
+ const cancelled = engine.acquire({
+ ...input,
+ signal: controller.signal,
+ onProgress: () => { progressCount++ },
+ })
+ setTimeout(() => {
+ controller.abort()
+ countAtAbort = progressCount
+ }, 5)
+ await assert.rejects(cancelled, { name: "AbortError" })
+ assert(progressCount > 0)
+ assert.equal(progressCount, countAtAbort)
+ const retried = await engine.acquire(input)
+ assert.equal(retried.errors.length, 0)
+ assert.equal(retried.packages.length, 4)
+ assert(mock.maximum() <= 2)
+})
+
+test("a DT exclusion also suppresses scoped runtime package requests", async () => {
+ const mock = registry({})
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({
+ files: source('import "@scope/pkg"'),
+ discover,
+ options: { exclude: ["@types/scope__pkg"] },
+ })
+ assert.equal(result.files.size, 0)
+ assert.equal(mock.calls.length, 0)
+})
+
+test("enforces graph-wide package and declaration byte budgets", async () => {
+ const mock = registry({
+ one: { files: { "/index.d.ts": "x".repeat(500) } },
+ two: { files: { "/index.d.ts": "x".repeat(500) } },
+ })
+ const input = { files: source('import "one"; import "two"'), discover }
+ await assert.rejects(
+ new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { packages: 1 } }).acquire(input),
+ /package limit/
+ )
+ await assert.rejects(
+ new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { bytes: 900 } }).acquire(input),
+ /byte limit/
+ )
+})
+
+test("rejects oversized responses and unsafe package paths", async () => {
+ const mock = registry({ huge: { files: { "/index.d.ts": "x".repeat(4000) } } })
+ const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { responseBytes: 1024 } }).acquire({
+ files: source('import "huge"'),
+ discover,
+ })
+ assert.match(result.errors[0].message, /too large/)
+ const unsafe = registry({ bad: { files: { "/../bad.d.ts": "export {}" } } })
+ const invalid = await new PackageTypeAcquirer({ fetcher: unsafe.fetcher }).acquire({
+ files: source('import "bad"'),
+ discover,
+ })
+ assert.match(invalid.errors[0].message, /Invalid file path/)
+})
+
+test("project options accept JSONC, preserve explicit choices, and report invalid values", () => {
+ assert.deepEqual(acquisitionOptions('{// hi\n"typeAcquisition":{"enable":false,"include":["node"],},}', true), {
+ enable: false,
+ include: ["node"],
+ exclude: undefined,
+ })
+ assert.deepEqual(acquisitionOptions("invalid", false), { enable: false })
+ assert.throws(() => acquisitionOptions('{"typeAcquisition":{"exclude":[1]}}', true), /array of package names/)
+ assert.throws(() => acquisitionOptions("invalid", true), /syntax/)
+})
diff --git a/packages/playground-v2/src/ata/cache.ts b/packages/playground-v2/src/ata/cache.ts
new file mode 100644
index 000000000000..695d62315d3c
--- /dev/null
+++ b/packages/playground-v2/src/ata/cache.ts
@@ -0,0 +1,50 @@
+const cacheName = "playground-v2-package-types-v2"
+
+export class PackageTypeCache {
+ private cachePromise: Promise | undefined
+
+ private getCache() {
+ this.cachePromise ??= (async () => {
+ if (!("caches" in globalThis)) return undefined
+ try {
+ return await caches.open(cacheName)
+ } catch (error) {
+ console.warn("Could not open the package type cache", error)
+ return undefined
+ }
+ })()
+ return this.cachePromise
+ }
+
+ private immutable(url: string) {
+ return !url.includes("/resolve/") && /@[^/]+\//.test(new URL(url).pathname)
+ }
+
+ async read(url: string, signal: AbortSignal) {
+ const cache = this.immutable(url) ? await this.getCache() : undefined
+ if (cache) {
+ try {
+ const cached = await cache.match(url)
+ signal.throwIfAborted()
+ return cached ? await cached.text() : undefined
+ } catch (error) {
+ signal.throwIfAborted()
+ console.warn("Could not read the package type cache", error)
+ }
+ }
+ return undefined
+ }
+
+ async write(url: string, text: string, signal: AbortSignal) {
+ const cache = this.immutable(url) ? await this.getCache() : undefined
+ signal.throwIfAborted()
+ if (cache) {
+ try {
+ await cache.put(url, new Response(text))
+ } catch (error) {
+ signal.throwIfAborted()
+ console.warn("Could not cache package types", error)
+ }
+ }
+ }
+}
diff --git a/packages/playground-v2/src/ata/config.ts b/packages/playground-v2/src/ata/config.ts
new file mode 100644
index 000000000000..c4519a9db052
--- /dev/null
+++ b/packages/playground-v2/src/ata/config.ts
@@ -0,0 +1,38 @@
+import { parse, type ParseError } from "jsonc-parser"
+import type { AcquisitionOptions } from "./types"
+
+function object(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+}
+
+export function acquisitionOptions(configText: string, globalEnable: boolean): AcquisitionOptions {
+ if (!globalEnable) return { enable: false }
+ const errors: ParseError[] = []
+ const config: unknown = parse(configText, errors, { allowTrailingComma: true, disallowComments: false })
+ if (errors.length) throw new Error("Fix TSConfig syntax errors before acquiring package types")
+ if (!object(config)) {
+ throw new Error("TSConfig must be an object")
+ }
+ const value: unknown = "typeAcquisition" in config ? config.typeAcquisition : undefined
+ if (value === undefined || value === null) return {}
+ if (!object(value)) {
+ throw new Error("typeAcquisition must be an object")
+ }
+ const enable = "enable" in value ? value.enable : undefined
+ if (enable !== undefined && enable !== null && typeof enable !== "boolean") {
+ throw new Error("typeAcquisition.enable must be a boolean")
+ }
+ const list = (name: "include" | "exclude"): string[] | undefined => {
+ const items: unknown = name in value ? value[name] : undefined
+ if (items === undefined || items === null) return undefined
+ if (!Array.isArray(items) || items.some(item => typeof item !== "string")) {
+ throw new Error(`typeAcquisition.${name} must be an array of package names`)
+ }
+ return items
+ }
+ return {
+ enable: typeof enable === "boolean" ? enable : undefined,
+ include: list("include"),
+ exclude: list("exclude"),
+ }
+}
diff --git a/packages/playground-v2/src/ata/discovery.ts b/packages/playground-v2/src/ata/discovery.ts
new file mode 100644
index 000000000000..e65dd4f836a3
--- /dev/null
+++ b/packages/playground-v2/src/ata/discovery.ts
@@ -0,0 +1,90 @@
+import type { API } from "@typescript/typescript/unstable/sync"
+import { isCallExpression, isIdentifier, isStringLiteral, type Node } from "@typescript/typescript/unstable/ast"
+import type { Dependency, DiscoverDependencies, SourceFile } from "./types"
+
+function dependency(specifier: string, kind: Dependency["kind"], text: string, end: number): Dependency {
+ const lineEnd = text.slice(end).split(/\r?\n/, 1)[0]
+ const version = /\/\/\s*types:\s*(\S+)/.exec(lineEnd)?.[1]
+ return { kind, specifier, ...(version ? { version } : {}) }
+}
+
+function unique(dependencies: readonly Dependency[]) {
+ return [...new Map(dependencies.map(item => [`${item.kind}:${item.specifier}:${item.version ?? ""}`, item])).values()]
+}
+
+export function corsaDiscovery(api: API): (file: SourceFile) => readonly Dependency[] {
+ return file => {
+ const retained = api.createSourceFile(file.path, file.text)
+ try {
+ const source = retained.sourceFile
+ const dependencies: Dependency[] = source.typeReferenceDirectives.map(reference =>
+ dependency(reference.fileName, "types", file.text, reference.end)
+ )
+ for (const node of source.imports) {
+ if (isStringLiteral(node)) dependencies.push(dependency(node.text, "import", file.text, node.end))
+ }
+ const visit = (node: Node): void => {
+ if (
+ isCallExpression(node) &&
+ isIdentifier(node.expression) &&
+ node.expression.text === "require" &&
+ node.arguments.length === 1 &&
+ isStringLiteral(node.arguments[0])
+ ) {
+ dependencies.push(dependency(node.arguments[0].text, "import", file.text, node.arguments[0].end))
+ }
+ node.forEachChild(visit)
+ }
+ visit(source)
+ return unique(dependencies)
+ } finally {
+ retained.dispose()
+ }
+ }
+}
+
+export function stradaDiscovery(ts: typeof import("typescript")): DiscoverDependencies {
+ return file => {
+ const source = ts.createSourceFile(file.path, file.text, ts.ScriptTarget.Latest, true)
+ const dependencies: Dependency[] = source.typeReferenceDirectives.map(reference =>
+ dependency(reference.fileName, "types", file.text, reference.end)
+ )
+ const add = (node: import("typescript").Node | undefined) => {
+ if (node?.kind === ts.SyntaxKind.StringLiteral) {
+ dependencies.push(dependency((node as import("typescript").StringLiteral).text, "import", file.text, node.end))
+ }
+ }
+ const visit = (node: import("typescript").Node): void => {
+ switch (node.kind) {
+ case ts.SyntaxKind.ImportDeclaration:
+ case ts.SyntaxKind.ExportDeclaration:
+ add((node as import("typescript").ImportDeclaration | import("typescript").ExportDeclaration).moduleSpecifier)
+ break
+ case ts.SyntaxKind.ExternalModuleReference:
+ add((node as import("typescript").ExternalModuleReference).expression)
+ break
+ case ts.SyntaxKind.ImportType: {
+ const argument = (node as import("typescript").ImportTypeNode).argument
+ if (argument.kind === ts.SyntaxKind.LiteralType) {
+ add((argument as import("typescript").LiteralTypeNode).literal)
+ }
+ break
+ }
+ case ts.SyntaxKind.CallExpression: {
+ const call = node as import("typescript").CallExpression
+ if (
+ call.arguments.length === 1 &&
+ (call.expression.kind === ts.SyntaxKind.ImportKeyword ||
+ (call.expression.kind === ts.SyntaxKind.Identifier &&
+ (call.expression as import("typescript").Identifier).text === "require"))
+ ) {
+ add(call.arguments[0])
+ }
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(source)
+ return unique(dependencies)
+ }
+}
diff --git a/packages/playground-v2/src/ata/index.ts b/packages/playground-v2/src/ata/index.ts
new file mode 100644
index 000000000000..1b755b9a543f
--- /dev/null
+++ b/packages/playground-v2/src/ata/index.ts
@@ -0,0 +1,447 @@
+import { PackageTypeCache } from "./cache"
+import { definitelyTypedName, isDeclaration, packageName, runtimePackageName, safePackagePath } from "./packages"
+import type {
+ AcquisitionOptions,
+ AcquisitionProgress,
+ AcquisitionResult,
+ AcquiredPackage,
+ Dependency,
+ DiscoverDependencies,
+ SourceFile,
+} from "./types"
+
+export type {
+ AcquisitionOptions,
+ AcquisitionProgress,
+ AcquisitionResult,
+ Dependency,
+ DiscoverDependencies,
+ SourceFile,
+} from "./types"
+export { corsaDiscovery, stradaDiscovery } from "./discovery"
+
+type PackageRequest = { name: string; version: string; requestedBy: string }
+type PackageData = {
+ name: string
+ version: string
+ files: ReadonlyMap
+ dependencies: ReadonlyMap
+}
+
+export type AcquisitionLimits = {
+ concurrency: number
+ packages: number
+ files: number
+ bytes: number
+ responseBytes: number
+}
+
+const defaultLimits: AcquisitionLimits = {
+ concurrency: 6,
+ packages: 100,
+ files: 4000,
+ bytes: 32 * 1024 * 1024,
+ responseBytes: 8 * 1024 * 1024,
+}
+
+function record(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+}
+
+class HttpError extends Error {
+ constructor(readonly status: number, url: string) {
+ super(`HTTP ${status} fetching ${url}`)
+ }
+}
+
+class DownloadPool {
+ private active = 0
+ private readonly waiting: Array<() => void> = []
+
+ constructor(private readonly concurrency: number) {}
+
+ async run(signal: AbortSignal, operation: () => Promise): Promise {
+ signal.throwIfAborted()
+ if (this.active >= this.concurrency) {
+ await new Promise((resolve, reject) => {
+ const abort = () => {
+ const index = this.waiting.indexOf(resume)
+ if (index !== -1) this.waiting.splice(index, 1)
+ reject(signal.reason)
+ }
+ const resume = () => {
+ signal.removeEventListener("abort", abort)
+ resolve()
+ }
+ this.waiting.push(resume)
+ signal.addEventListener("abort", abort, { once: true })
+ })
+ } else {
+ this.active++
+ }
+ try {
+ signal.throwIfAborted()
+ return await operation()
+ } finally {
+ const next = this.waiting.shift()
+ if (next) next()
+ else this.active--
+ }
+ }
+}
+
+export class PackageTypeAcquirer {
+ private readonly fetcher: typeof fetch
+ private readonly limits: AcquisitionLimits
+ private readonly pool: DownloadPool
+ private readonly packages = new Map()
+ private cachedBytes = 0
+ private readonly cache = new PackageTypeCache()
+ private readonly discoveries = new WeakMap>>()
+
+ constructor(options: { fetcher?: typeof fetch; limits?: Partial } = {}) {
+ this.fetcher = options.fetcher ?? ((input, init) => fetch(input, init))
+ this.limits = { ...defaultLimits, ...options.limits }
+ if (Object.values(this.limits).some(value => !Number.isSafeInteger(value) || value < 1)) {
+ throw new Error("ATA limits must be positive integers")
+ }
+ this.pool = new DownloadPool(this.limits.concurrency)
+ }
+
+ async acquire(input: {
+ files: readonly SourceFile[]
+ discover: DiscoverDependencies
+ options?: AcquisitionOptions
+ signal?: AbortSignal
+ onProgress?(progress: AcquisitionProgress): void
+ }): Promise {
+ const signal = input.signal ?? new AbortController().signal
+ signal.throwIfAborted()
+ const files = new Map()
+ const ambientTypes = new Set()
+ const packages: AcquiredPackage[] = []
+ const errors: Array<{ packageName: string; requestedBy: string; message: string }> = []
+ if (input.options?.enable === false) return { files, ambientTypes: [], packages, errors }
+
+ const excluded = new Set()
+ for (const specifier of input.options?.exclude ?? []) {
+ const name = packageName(specifier)
+ if (name) {
+ excluded.add(name)
+ excluded.add(definitelyTypedName(name))
+ if (name.startsWith("@types/")) excluded.add(name.slice("@types/".length))
+ }
+ }
+ const queued = new Set()
+ const queue: PackageRequest[] = []
+ const pendingPackages = new Set()
+ let completedPackages = 0
+ let totalPackages = 0
+ let downloaded = 0
+ let total = 0
+ const progress = () => {
+ if (!signal.aborted) {
+ input.onProgress?.({
+ downloaded,
+ total,
+ completedPackages,
+ totalPackages,
+ pendingPackages: [...pendingPackages],
+ })
+ }
+ }
+ const enqueue = (dependency: Dependency, requestedBy: string, versions?: ReadonlyMap) => {
+ const name = packageName(dependency.specifier)
+ if (!name || excluded.has(name) || excluded.has(definitelyTypedName(name)) || queued.has(name)) return
+ if (queued.size >= this.limits.packages) throw new Error(`ATA exceeded its ${this.limits.packages}-package limit`)
+ queued.add(name)
+ queue.push({ name, requestedBy, version: dependency.version ?? versions?.get(name) ?? "latest" })
+ totalPackages++
+ progress()
+ }
+ for (const file of [...input.files].sort((a, b) => a.path.localeCompare(b.path))) {
+ const dependencies = await input.discover(file)
+ signal.throwIfAborted()
+ for (const dependency of dependencies) enqueue(dependency, file.path)
+ }
+ for (const specifier of input.options?.include ?? [])
+ enqueue({ kind: "import", specifier }, "typeAcquisition.include")
+
+ let bytes = 0
+ if (queue.length) progress()
+ while (queue.length) {
+ signal.throwIfAborted()
+ // Resolve a deterministic wave before adding its transitive dependencies.
+ const wave = queue.splice(0, this.limits.concurrency)
+ const results = await Promise.all(
+ wave.map(
+ async (
+ request
+ ): Promise<{ request: PackageRequest; data: PackageData } | { request: PackageRequest; error: string }> => {
+ pendingPackages.add(request.name)
+ progress()
+ try {
+ const data = await this.loadPackage(
+ request,
+ signal,
+ count => {
+ total += count
+ progress()
+ },
+ () => {
+ downloaded++
+ progress()
+ }
+ )
+ return { request, data }
+ } catch (error) {
+ signal.throwIfAborted()
+ return { request, error: error instanceof Error ? error.message : String(error) }
+ } finally {
+ pendingPackages.delete(request.name)
+ completedPackages++
+ progress()
+ }
+ }
+ )
+ )
+ for (const result of results) {
+ if ("error" in result) {
+ errors.push({
+ packageName: result.request.name,
+ requestedBy: result.request.requestedBy,
+ message: result.error,
+ })
+ continue
+ }
+ const { data, request } = result
+ queued.add(data.name)
+ packages.push({ name: data.name, version: data.version, requestedBy: request.requestedBy })
+ if (data.name.startsWith("@types/")) {
+ ambientTypes.add(data.name.slice("@types/".length))
+ queued.add(runtimePackageName(data.name))
+ }
+ for (const [path, text] of data.files) {
+ const fileName = `/workspace/node_modules/${data.name}${path}`
+ if (files.has(fileName)) continue
+ bytes += new TextEncoder().encode(text).byteLength
+ if (files.size >= this.limits.files || bytes > this.limits.bytes) {
+ throw new Error("ATA exceeded the project declaration file or byte limit")
+ }
+ files.set(fileName, text)
+ if (isDeclaration(path)) {
+ let cachedPackages = this.discoveries.get(input.discover)
+ if (!cachedPackages) {
+ cachedPackages = new WeakMap()
+ this.discoveries.set(input.discover, cachedPackages)
+ }
+ let declarations = cachedPackages.get(data)
+ if (!declarations) {
+ declarations = new Map()
+ cachedPackages.set(data, declarations)
+ }
+ let dependencies = declarations.get(path)
+ if (!dependencies) {
+ dependencies = await input.discover({ path: fileName, text })
+ signal.throwIfAborted()
+ declarations.set(path, dependencies)
+ }
+ signal.throwIfAborted()
+ for (const dependency of dependencies) {
+ enqueue(dependency, `${data.name}@${data.version}${path}`, data.dependencies)
+ }
+ }
+ }
+ }
+ }
+ signal.throwIfAborted()
+ return {
+ files,
+ ambientTypes: [...ambientTypes].sort(),
+ packages,
+ errors,
+ }
+ }
+
+ private async request(url: string, signal: AbortSignal, cacheResult = true) {
+ return this.pool.run(signal, async () => {
+ const cached = await this.cache.read(url, signal)
+ signal.throwIfAborted()
+ if (cached !== undefined) {
+ if (new TextEncoder().encode(cached).byteLength > this.limits.responseBytes) {
+ throw new Error(`ATA response is too large: ${url}`)
+ }
+ return cached
+ }
+ const response = await this.fetcher(url, { signal })
+ if (!response.ok) {
+ await response.body?.cancel()
+ throw new HttpError(response.status, url)
+ }
+ if (Number(response.headers.get("content-length")) > this.limits.responseBytes) {
+ await response.body?.cancel()
+ throw new Error(`ATA response is too large: ${url}`)
+ }
+ if (!response.body) {
+ const text = await response.text()
+ if (new TextEncoder().encode(text).byteLength > this.limits.responseBytes) {
+ throw new Error(`ATA response is too large: ${url}`)
+ }
+ if (cacheResult) await this.cache.write(url, text, signal)
+ return text
+ }
+ const reader = response.body.getReader()
+ const chunks: Uint8Array[] = []
+ let length = 0
+ try {
+ for (;;) {
+ signal.throwIfAborted()
+ const { done, value } = await reader.read()
+ if (done) break
+ length += value.byteLength
+ if (length > this.limits.responseBytes) throw new Error(`ATA response is too large: ${url}`)
+ chunks.push(value)
+ }
+ } catch (error) {
+ await reader.cancel()
+ throw error
+ } finally {
+ reader.releaseLock()
+ }
+ const bytes = new Uint8Array(length)
+ let offset = 0
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset)
+ offset += chunk.length
+ }
+ const text = new TextDecoder().decode(bytes)
+ if (cacheResult) await this.cache.write(url, text, signal)
+ return text
+ })
+ }
+
+ private async json(url: string, signal: AbortSignal): Promise {
+ const text = await this.request(url, signal, false)
+ let value: unknown
+ try {
+ value = JSON.parse(text)
+ } catch (error) {
+ throw new Error(`Invalid JSON from ${url}`, { cause: error })
+ }
+ await this.cache.write(url, text, signal)
+ return value
+ }
+
+ private async loadPackage(
+ request: PackageRequest,
+ signal: AbortSignal,
+ onFiles: (count: number) => void,
+ onFile: () => void
+ ): Promise {
+ const key = `${request.name}@${request.version}`
+ const cached = this.packages.get(key)
+ if (cached) return cached.data
+ const data = await this.downloadPackage(request.name, request.version, signal, onFiles, onFile)
+ if (data) {
+ this.remember(key, data)
+ return data
+ }
+ if (request.name.startsWith("@types/")) throw new Error(`${request.name} does not contain declarations`)
+ const fallback = await this.downloadPackage(definitelyTypedName(request.name), "latest", signal, onFiles, onFile)
+ if (!fallback) throw new Error(`No declarations found for ${request.name}`)
+ this.remember(key, fallback)
+ return fallback
+ }
+
+ private remember(key: string, data: PackageData) {
+ const bytes = [...data.files.values()].reduce((sum, text) => sum + new TextEncoder().encode(text).byteLength, 0)
+ while (this.cachedBytes + bytes > this.limits.bytes || this.packages.size >= this.limits.packages) {
+ const oldest = this.packages.entries().next().value
+ if (!oldest) return
+ this.packages.delete(oldest[0])
+ this.cachedBytes -= oldest[1].bytes
+ }
+ this.packages.set(key, { data, bytes })
+ this.cachedBytes += bytes
+ }
+
+ private async downloadPackage(
+ name: string,
+ selector: string,
+ signal: AbortSignal,
+ onFiles: (count: number) => void,
+ onFile: () => void
+ ): Promise {
+ let resolved: unknown
+ try {
+ resolved = await this.json(
+ `https://data.jsdelivr.com/v1/package/resolve/npm/${name}@${encodeURIComponent(selector)}`,
+ signal
+ )
+ } catch (error) {
+ signal.throwIfAborted()
+ if (error instanceof HttpError && error.status === 404 && !name.startsWith("@types/")) return undefined
+ throw error
+ }
+ if (
+ !record(resolved) ||
+ typeof resolved.version !== "string" ||
+ !/^\d+\.\d+\.\d+[-+\w.]*$/.test(resolved.version)
+ ) {
+ throw new Error(`Could not resolve ${name}@${selector} to an exact version`)
+ }
+ const version = resolved.version
+ const base = `https://cdn.jsdelivr.net/npm/${name}@${version}`
+ const tree = await this.json(`https://data.jsdelivr.com/v1/package/npm/${name}@${version}/flat`, signal)
+ if (!record(tree) || !Array.isArray(tree.files)) throw new Error(`Invalid file index for ${name}@${version}`)
+ const paths = tree.files
+ .map(entry => {
+ if (!record(entry) || typeof entry.name !== "string" || !safePackagePath(entry.name)) {
+ throw new Error(`Invalid file path in ${name}@${version}`)
+ }
+ return entry.name
+ })
+ .filter(path => !path.includes("/node_modules/"))
+ if (!paths.some(isDeclaration)) return undefined
+ const metadata = await this.json(`${base}/package.json`, signal)
+ if (!record(metadata)) throw new Error(`Invalid package.json for ${name}@${version}`)
+ const dependencies = new Map()
+ for (const field of ["peerDependencies", "dependencies"]) {
+ const entries = metadata[field]
+ if (entries !== undefined && entries !== null) {
+ if (!record(entries)) throw new Error(`Invalid ${field} in ${name}@${version}`)
+ for (const [dependency, value] of Object.entries(entries)) {
+ if (typeof value !== "string") throw new Error(`Invalid dependency ${dependency} in ${name}@${version}`)
+ dependencies.set(dependency, value)
+ }
+ }
+ }
+ const declarationPaths = [...new Set(paths.filter(path => isDeclaration(path) || path.endsWith("/package.json")))]
+ .filter(path => path !== "/package.json")
+ .sort()
+ if (declarationPaths.length > this.limits.files) throw new Error(`Too many declaration files in ${name}@${version}`)
+ onFiles(declarationPaths.length)
+ const files = new Map([["/package.json", JSON.stringify(metadata)]])
+ let bytes = new TextEncoder().encode(JSON.stringify(metadata)).byteLength
+ // Keep fan-out bounded even before requests enter the shared network pool.
+ for (let start = 0; start < declarationPaths.length; start += this.limits.concurrency) {
+ const results = await Promise.allSettled(
+ declarationPaths.slice(start, start + this.limits.concurrency).map(async path => {
+ const text = await this.request(`${base}${path}`, signal)
+ onFile()
+ return [path, text] as const
+ })
+ )
+ const entries = results.map(result => {
+ if (result.status === "rejected") throw result.reason
+ return result.value
+ })
+ for (const [path, text] of entries) {
+ bytes += new TextEncoder().encode(text).byteLength
+ if (bytes > this.limits.bytes) throw new Error(`Declarations in ${name}@${version} exceed the ATA byte limit`)
+ files.set(path, text)
+ }
+ }
+ return { name, version, files, dependencies }
+ }
+}
diff --git a/packages/playground-v2/src/ata/packages.ts b/packages/playground-v2/src/ata/packages.ts
new file mode 100644
index 000000000000..17b861bb65b5
--- /dev/null
+++ b/packages/playground-v2/src/ata/packages.ts
@@ -0,0 +1,81 @@
+const nodeModules = new Set([
+ "assert",
+ "async_hooks",
+ "buffer",
+ "child_process",
+ "cluster",
+ "console",
+ "constants",
+ "crypto",
+ "dgram",
+ "diagnostics_channel",
+ "dns",
+ "domain",
+ "events",
+ "fs",
+ "http",
+ "http2",
+ "https",
+ "inspector",
+ "module",
+ "net",
+ "os",
+ "path",
+ "perf_hooks",
+ "process",
+ "punycode",
+ "querystring",
+ "readline",
+ "repl",
+ "stream",
+ "string_decoder",
+ "sys",
+ "timers",
+ "tls",
+ "trace_events",
+ "tty",
+ "url",
+ "util",
+ "v8",
+ "vm",
+ "wasi",
+ "worker_threads",
+ "zlib",
+])
+
+export function packageName(specifier: string): string | undefined {
+ if (specifier === "node" || specifier.startsWith("node:") || nodeModules.has(specifier.split("/")[0]))
+ return "@types/node"
+ if (/^(?:[./#]|[a-z][a-z\d+.-]*:)/i.test(specifier)) return undefined
+ const parts = specifier.split("/")
+ const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
+ if (!/^(?:@[a-z\d._-]+\/)?[a-z\d_][a-z\d._-]*$/i.test(name)) {
+ throw new Error(`Invalid package specifier: ${specifier}`)
+ }
+ return name
+}
+
+export function definitelyTypedName(name: string) {
+ if (name.startsWith("@types/")) return name
+ return `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`
+}
+
+export function runtimePackageName(typesName: string) {
+ const name = typesName.slice("@types/".length)
+ return name.includes("__") ? `@${name.replace("__", "/")}` : name
+}
+
+export function isDeclaration(path: string) {
+ return /\.d\.(?:ts|mts|cts)$/i.test(path)
+}
+
+export function safePackagePath(path: string) {
+ return (
+ path.startsWith("/") &&
+ !/[\\%?#\u0000-\u001f]/.test(path) &&
+ !path
+ .slice(1)
+ .split("/")
+ .some(part => part === ".." || part === "." || part === "")
+ )
+}
diff --git a/packages/playground-v2/src/ata/types.ts b/packages/playground-v2/src/ata/types.ts
new file mode 100644
index 000000000000..a5757171c850
--- /dev/null
+++ b/packages/playground-v2/src/ata/types.ts
@@ -0,0 +1,45 @@
+export type SourceFile = {
+ path: string
+ text: string
+}
+
+export type Dependency = {
+ specifier: string
+ kind: "import" | "types"
+ version?: string
+}
+
+export type DiscoverDependencies = (file: SourceFile) => readonly Dependency[] | Promise
+
+export type AcquisitionOptions = {
+ enable?: boolean
+ include?: readonly string[]
+ exclude?: readonly string[]
+}
+
+export type AcquiredPackage = {
+ name: string
+ version: string
+ requestedBy: string
+}
+
+export type AcquisitionError = {
+ packageName: string
+ requestedBy: string
+ message: string
+}
+
+export type AcquisitionResult = {
+ files: ReadonlyMap
+ ambientTypes: readonly string[]
+ packages: readonly AcquiredPackage[]
+ errors: readonly AcquisitionError[]
+}
+
+export type AcquisitionProgress = {
+ downloaded: number
+ total: number
+ completedPackages: number
+ totalPackages: number
+ pendingPackages: readonly string[]
+}
diff --git a/packages/playground-v2/src/compiler-overrides.ts b/packages/playground-v2/src/compiler-overrides.ts
new file mode 100644
index 000000000000..953a8a8ca6c6
--- /dev/null
+++ b/packages/playground-v2/src/compiler-overrides.ts
@@ -0,0 +1,155 @@
+import { applyEdits, findNodeAtLocation, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
+
+export type CompilerOverride = {
+ applied: boolean
+ end: number
+ fileName: string
+ lineNumber: number
+ option: string
+ rawValue: string
+ start: number
+ tsconfigValue: unknown
+ value: unknown
+}
+
+export type OverrideDiagnostic = {
+ end: number
+ fileName: string
+ message: string
+ start: number
+}
+
+export type CompilerOverrideState = {
+ diagnostics: OverrideDiagnostic[]
+ effectiveConfigText: string
+ overrides: CompilerOverride[]
+}
+
+const reservedDirectives = new Set(["errors", "filename", "noerrors", "showemit", "showemittedfile", "showoutput"])
+const directivePattern = /^(\s*\/\/\s*)@([A-Za-z][\w-]*):\s*(.*?)\s*$/
+const formattingOptions = {
+ insertSpaces: true,
+ tabSize: 2,
+}
+
+export function computeCompilerOverrides(
+ files: ReadonlyMap,
+ configText: string
+): CompilerOverrideState {
+ const configErrors: ParseError[] = []
+ const config = parse(configText, configErrors, { allowTrailingComma: true, disallowComments: false }) ?? {}
+ const compilerOptions =
+ config.compilerOptions && typeof config.compilerOptions === "object" ? config.compilerOptions : {}
+ const overrides: CompilerOverride[] = []
+ const diagnostics: OverrideDiagnostic[] = []
+
+ for (const [fileName, text] of [...files].sort(([left], [right]) => left.localeCompare(right))) {
+ if (!/\.[cm]?[jt]sx?$/i.test(fileName)) continue
+ let lineNumber = 0
+ for (const lineMatch of text.matchAll(/[^\r\n]*(?:\r\n|\r|\n|$)/g)) {
+ if (lineMatch[0] === "") break
+ lineNumber++
+ const offset = lineMatch.index
+ const line = lineMatch[0].replace(/\r\n$|\r$|\n$/, "")
+ const match = directivePattern.exec(line)
+ const lineEnd = offset + line.length
+ if (match) {
+ const option = match[2]
+ if (!reservedDirectives.has(option.toLowerCase())) {
+ const rawValue = match[3]
+ if (rawValue === "") {
+ diagnostics.push({
+ end: lineEnd,
+ fileName,
+ message: `Compiler override @${option} requires a value.`,
+ start: offset,
+ })
+ } else {
+ overrides.push({
+ applied: false,
+ end: lineEnd,
+ fileName,
+ lineNumber,
+ option,
+ rawValue,
+ start: offset,
+ tsconfigValue: compilerOptions[option],
+ value: parseDirectiveValue(rawValue),
+ })
+ }
+ }
+ }
+ }
+ }
+
+ const byOption = new Map()
+ for (const override of overrides) {
+ const key = override.option.toLowerCase()
+ const entries = byOption.get(key) ?? []
+ entries.push(override)
+ byOption.set(key, entries)
+ }
+ let effectiveConfigText = configText
+ for (const duplicates of byOption.values()) {
+ if (duplicates.length > 1) {
+ for (const duplicate of duplicates) {
+ diagnostics.push({
+ end: duplicate.end,
+ fileName: duplicate.fileName,
+ message: `Compiler option @${duplicate.option} is overridden in multiple files. Keep only one project-wide directive.`,
+ start: duplicate.start,
+ })
+ }
+ continue
+ }
+
+ const override = duplicates[0]
+ try {
+ effectiveConfigText = setCompilerOption(effectiveConfigText, override.option, override.value)
+ override.applied = true
+ } catch {
+ diagnostics.push({
+ end: override.end,
+ fileName: override.fileName,
+ message: `Compiler override @${override.option} cannot be applied while tsconfig.json is invalid.`,
+ start: override.start,
+ })
+ }
+ }
+
+ return {
+ diagnostics,
+ effectiveConfigText,
+ overrides,
+ }
+}
+
+export function setCompilerOption(configText: string, option: string, value: unknown) {
+ return applyEdits(
+ configText,
+ modify(configText, ["compilerOptions", option], value, {
+ formattingOptions,
+ })
+ )
+}
+
+export function configOptionNode(configText: string, option: string): Node | undefined {
+ const tree = parseTree(configText, [], { allowTrailingComma: true, disallowComments: false })
+ return tree ? findNodeAtLocation(tree, ["compilerOptions", option]) : undefined
+}
+
+export function compilerOptionsNode(configText: string): Node | undefined {
+ const tree = parseTree(configText, [], { allowTrailingComma: true, disallowComments: false })
+ return tree ? findNodeAtLocation(tree, ["compilerOptions"]) : undefined
+}
+
+function parseDirectiveValue(rawValue: string) {
+ if (/^(?:true|false|null|-?\d+(?:\.\d+)?)$/.test(rawValue) || /^["[{]/.test(rawValue)) {
+ try {
+ return JSON.parse(rawValue)
+ } catch {
+ // Treat invalid JSON-like values as compiler option strings so the selected compiler reports them.
+ }
+ }
+ return rawValue
+}
diff --git a/packages/playground-v2/src/config-schema.ts b/packages/playground-v2/src/config-schema.ts
new file mode 100644
index 000000000000..2614d27c2487
--- /dev/null
+++ b/packages/playground-v2/src/config-schema.ts
@@ -0,0 +1,209 @@
+import {
+ CompletionItemKind,
+ DiagnosticSeverity,
+ getLanguageService,
+ InsertTextFormat,
+ MarkupKind,
+ type CompletionItem,
+ type Diagnostic,
+ type Hover,
+ type JSONSchema,
+ type MarkedString,
+ type MarkupContent,
+ type Range,
+} from "vscode-json-languageservice"
+import { TextDocument } from "vscode-languageserver-textdocument"
+import { monaco } from "./tsgo-lsp"
+
+const schemaUri = "https://json.schemastore.org/tsconfig"
+const markerOwner = "tsconfig-schema"
+
+export function registerConfigSchema(schema: JSONSchema) {
+ const service = getLanguageService({})
+ service.configure({
+ allowComments: true,
+ schemas: [
+ {
+ fileMatch: ["**/tsconfig.json", "**/jsconfig.json"],
+ schema,
+ uri: schemaUri,
+ },
+ ],
+ validate: true,
+ })
+
+ monaco.languages.registerCompletionItemProvider("json", {
+ triggerCharacters: ['"', ":"],
+ async provideCompletionItems(model, position) {
+ if (!isConfigModel(model)) return { suggestions: [] }
+ const document = createDocument(model)
+ const jsonDocument = service.parseJSONDocument(document)
+ const completions = await service.doComplete(document, toLspPosition(position), jsonDocument)
+ return {
+ incomplete: completions?.isIncomplete,
+ suggestions: (completions?.items ?? []).map(item => toMonacoCompletion(model, position, item)),
+ }
+ },
+ })
+
+ monaco.languages.registerHoverProvider("json", {
+ async provideHover(model, position) {
+ if (!isConfigModel(model)) return undefined
+ const document = createDocument(model)
+ const hover = await service.doHover(document, toLspPosition(position), service.parseJSONDocument(document))
+ return hover ? toMonacoHover(hover) : undefined
+ },
+ })
+
+ const timers = new Map()
+ const registerModel = (model: monaco.editor.ITextModel) => {
+ if (!isConfigModel(model)) return
+ const validate = () => {
+ window.clearTimeout(timers.get(model.uri.toString()))
+ timers.set(
+ model.uri.toString(),
+ window.setTimeout(async () => {
+ if (model.isDisposed()) return
+ const document = createDocument(model)
+ const diagnostics = (
+ await service.doValidation(document, service.parseJSONDocument(document), {
+ comments: "ignore",
+ trailingCommas: "ignore",
+ })
+ ).filter(diagnostic => !isCaseInsensitiveEnumMatch(document, diagnostic))
+ monaco.editor.setModelMarkers(model, markerOwner, diagnostics.map(toMonacoDiagnostic))
+ }, 150)
+ )
+ }
+ model.onDidChangeContent(validate)
+ validate()
+ }
+
+ monaco.editor.getModels().forEach(registerModel)
+ monaco.editor.onDidCreateModel(registerModel)
+}
+
+function isCaseInsensitiveEnumMatch(document: TextDocument, diagnostic: Diagnostic) {
+ const message = typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value
+ if (!message.startsWith("Value is not accepted. Valid values:")) return false
+ const source = document.getText(diagnostic.range)
+ let value: unknown
+ try {
+ value = JSON.parse(source)
+ } catch {
+ return false
+ }
+ if (typeof value !== "string") return false
+ const allowed = [...message.matchAll(/"([^"]+)"/g)].map(match => match[1])
+ return allowed.some(candidate => candidate.toLowerCase() === value.toLowerCase())
+}
+
+function isConfigModel(model: monaco.editor.ITextModel) {
+ return /\/(?:js|ts)config\.json$/i.test(model.uri.path)
+}
+
+function createDocument(model: monaco.editor.ITextModel) {
+ return TextDocument.create(model.uri.toString(), "json", model.getVersionId(), model.getValue())
+}
+
+function toMonacoCompletion(
+ model: monaco.editor.ITextModel,
+ position: monaco.Position,
+ item: CompletionItem
+): monaco.languages.CompletionItem {
+ const word = model.getWordUntilPosition(position)
+ const fallbackRange = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn)
+ const textEdit = item.textEdit && "range" in item.textEdit ? item.textEdit : undefined
+ return {
+ detail: item.detail,
+ documentation: toMarkdown(item.documentation),
+ filterText: item.filterText,
+ insertText: textEdit?.newText ?? item.insertText ?? item.label,
+ insertTextRules:
+ item.insertTextFormat === InsertTextFormat.Snippet
+ ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
+ : undefined,
+ kind: toMonacoCompletionKind(item.kind),
+ label: item.label,
+ range: textEdit ? toMonacoRange(textEdit.range) : fallbackRange,
+ sortText: item.sortText,
+ }
+}
+
+function toMonacoCompletionKind(kind: CompletionItemKind | undefined) {
+ switch (kind) {
+ case CompletionItemKind.Value:
+ return monaco.languages.CompletionItemKind.Value
+ case CompletionItemKind.Enum:
+ case CompletionItemKind.EnumMember:
+ return monaco.languages.CompletionItemKind.Enum
+ case CompletionItemKind.Keyword:
+ return monaco.languages.CompletionItemKind.Keyword
+ case CompletionItemKind.Property:
+ case CompletionItemKind.Field:
+ return monaco.languages.CompletionItemKind.Property
+ default:
+ return monaco.languages.CompletionItemKind.Text
+ }
+}
+
+function toMonacoHover(hover: Hover): monaco.languages.Hover {
+ return {
+ contents: Array.isArray(hover.contents)
+ ? hover.contents.map(toMarkdown).filter(isMarkdown)
+ : [toMarkdown(hover.contents)].filter(isMarkdown),
+ range: hover.range ? toMonacoRange(hover.range) : undefined,
+ }
+}
+
+function toMarkdown(value: string | MarkedString | MarkupContent | undefined): monaco.IMarkdownString | undefined {
+ if (value === undefined) return undefined
+ if (typeof value === "string") return { value }
+ if ("language" in value) {
+ return { value: `\`\`\`${value.language}\n${value.value}\n\`\`\`` }
+ }
+ return {
+ value: value.kind === MarkupKind.Markdown ? value.value : value.value.replace(/[\\`*_{}[\]()#+\-.!]/g, "\\$&"),
+ }
+}
+
+function isMarkdown(value: monaco.IMarkdownString | undefined): value is monaco.IMarkdownString {
+ return value !== undefined
+}
+
+function toMonacoDiagnostic(diagnostic: Diagnostic): monaco.editor.IMarkerData {
+ return {
+ code: diagnostic.code?.toString(),
+ endColumn: diagnostic.range.end.character + 1,
+ endLineNumber: diagnostic.range.end.line + 1,
+ message: typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value,
+ severity: toMonacoSeverity(diagnostic.severity),
+ source: diagnostic.source ?? "TSConfig",
+ startColumn: diagnostic.range.start.character + 1,
+ startLineNumber: diagnostic.range.start.line + 1,
+ }
+}
+
+function toMonacoSeverity(severity: DiagnosticSeverity | undefined) {
+ switch (severity) {
+ case DiagnosticSeverity.Error:
+ return monaco.MarkerSeverity.Error
+ case DiagnosticSeverity.Warning:
+ return monaco.MarkerSeverity.Warning
+ case DiagnosticSeverity.Information:
+ return monaco.MarkerSeverity.Info
+ default:
+ return monaco.MarkerSeverity.Hint
+ }
+}
+
+function toLspPosition(position: monaco.Position) {
+ return {
+ character: position.column - 1,
+ line: position.lineNumber - 1,
+ }
+}
+
+function toMonacoRange(range: Range) {
+ return new monaco.Range(range.start.line + 1, range.start.character + 1, range.end.line + 1, range.end.character + 1)
+}
diff --git a/packages/playground-v2/src/global.d.ts b/packages/playground-v2/src/global.d.ts
new file mode 100644
index 000000000000..a06f797e5c12
--- /dev/null
+++ b/packages/playground-v2/src/global.d.ts
@@ -0,0 +1,16 @@
+declare module "*.css"
+declare module "*.json" {
+ const value: any
+ export default value
+}
+declare module "monaco-editor/editor/contrib/links/browser/links.js"
+
+declare module "monaco-editor/languages/definitions/javascript/javascript.js" {
+ export const conf: import("monaco-editor-core").languages.LanguageConfiguration
+ export const language: import("monaco-editor-core").languages.IMonarchLanguage
+}
+
+declare module "monaco-editor/languages/definitions/typescript/typescript.js" {
+ export const conf: import("monaco-editor-core").languages.LanguageConfiguration
+ export const language: import("monaco-editor-core").languages.IMonarchLanguage
+}
diff --git a/packages/playground-v2/src/index.html b/packages/playground-v2/src/index.html
new file mode 100644
index 000000000000..50d243f65cf4
--- /dev/null
+++ b/packages/playground-v2/src/index.html
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+
+
+
+
+ TypeScript Playground v2
+
+
+
+
+
+
+
+
+
+
Project
+ /workspace
+
+
+ + File
+ ⇤
+
+
+
+
+
+
+
+
+ ⇥
+ ←
+
index.ts
+
+
+ Type query: align ^? below an expression
+ Apply overrides
+ ⇤
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Downloading TypeScript...
+
+
Preparing downloads
+
+
+
+
+
+
+
+
+
diff --git a/packages/playground-v2/src/main.ts b/packages/playground-v2/src/main.ts
new file mode 100644
index 000000000000..aea12dcd115a
--- /dev/null
+++ b/packages/playground-v2/src/main.ts
@@ -0,0 +1,3144 @@
+import { DiagnosticCategory, type Diagnostic } from "@typescript/typescript/unstable/sync"
+import "monaco-editor/editor/contrib/links/browser/links.js"
+import LZString from "lz-string"
+import "reflect-metadata"
+import examplesCatalog from "../vendor/examples.json"
+import {
+ compilerOptionsNode,
+ computeCompilerOverrides,
+ configOptionNode,
+ setCompilerOption,
+ type CompilerOverride,
+ type CompilerOverrideState,
+} from "./compiler-overrides"
+import { registerConfigSchema } from "./config-schema"
+import { StradaBackend } from "./strada"
+import { NativeBackend } from "./native"
+import {
+ PackageTypeAcquirer,
+ stradaDiscovery,
+ type AcquisitionResult,
+ type AcquisitionProgress,
+ type DiscoverDependencies,
+} from "./ata"
+import { acquisitionOptions } from "./ata/config"
+import { monaco, registerPlaygroundLanguages, startTsgoLsp, type TsgoLspController, type TsgoStatus } from "./tsgo-lsp"
+import "./styles.css"
+
+declare const __TS_VERSION__: string
+declare const __ASSET_CACHE_VERSION__: string
+declare const __LOAD_ASSET_SIZES__: {
+ libraries: number
+ schema: number
+ wasm: number
+}
+
+function remapCompilerOverrideDiagnostics(diagnostics: readonly Diagnostic[]) {
+ return diagnostics.map(diagnostic => {
+ const text = diagnostic.text.toLowerCase()
+ const matchingOverride = compilerOverrideState.overrides.find(override => {
+ if (!override.applied) return false
+ if (
+ text.includes(`'${override.option.toLowerCase()}'`) ||
+ text.includes(`"--${override.option.toLowerCase()}"`) ||
+ text.includes(`'--${override.option.toLowerCase()}'`)
+ ) {
+ return true
+ }
+ if (diagnostic.fileName !== configFileName) return false
+ const node = configOptionNode(compilerOverrideState.effectiveConfigText, override.option)
+ return node !== undefined && diagnostic.pos <= node.offset + node.length && diagnostic.end >= node.offset
+ })
+ if (!matchingOverride) return diagnostic
+ return {
+ ...diagnostic,
+ end: matchingOverride.end,
+ fileName: matchingOverride.fileName,
+ pos: matchingOverride.start,
+ source: "Playground",
+ }
+ })
+}
+
+type TypeQuery = {
+ lineNumber: number
+ column: number
+ label: string
+}
+
+type ProjectFile = {
+ path: string
+ language: "javascript" | "json" | "typescript"
+ text: string
+}
+
+type ProjectState = {
+ activeFile?: string
+ files: Record
+ selection?: {
+ positionColumn: number
+ positionLineNumber: number
+ selectionStartColumn: number
+ selectionStartLineNumber: number
+ }
+ useDefaults?: boolean
+}
+
+type VersionedProjectState = ProjectState & {
+ version: 2
+}
+
+type PlaygroundExample = {
+ code: string
+ compilerSettings?: Record
+ id: string
+ name: string
+ path: string[]
+ title: string
+}
+
+type PlaygroundExamples = {
+ examples: PlaygroundExample[]
+}
+
+type PlaygroundHelp = {
+ docs: Array<{ html: string; legacyIndexes?: number[]; title: string }>
+}
+
+type LayoutState = {
+ emitVisible: boolean
+ filesVisible: boolean
+ filesWidth: number
+ outputVisible: boolean
+ outputWidth: number
+ runVisible: boolean
+}
+
+type PlaygroundSettings = {
+ automaticTypeAcquisition: boolean
+ fontLigatures: boolean
+ fontSize: number
+ minimap: boolean
+ saveToUrl: boolean
+ tabSize: number
+ theme: "dark" | "light" | "system"
+ wordWrap: boolean
+}
+
+type RuntimeLog = {
+ level: "debug" | "error" | "info" | "log" | "warn"
+ text: string
+}
+
+declare global {
+ interface Window {
+ ts: any
+ }
+}
+
+;(
+ self as typeof self & {
+ MonacoEnvironment: { getWorker(): Worker }
+ }
+).MonacoEnvironment = {
+ getWorker() {
+ return new Worker(new URL("./editor.worker.js", import.meta.url), { type: "module" })
+ },
+}
+
+const projectRoot = "/workspace"
+const configFileName = `${projectRoot}/tsconfig.json`
+const entryFileName = `${projectRoot}/src/index.ts`
+const storageKey = "ts7-playground-project"
+const pendingReloadKey = "ts7-playground-pending-reload"
+const projectHashPrefix = "#code/v2/"
+const legacyCodeHashPrefix = "#code/"
+const projectStateVersion = 2
+const bundledExamples = examplesCatalog as PlaygroundExamples
+const initialHash = location.hash
+const initialLegacyExample = legacyExampleFromHash(initialHash)
+if (initialLegacyExample?.compilerSettings?.ts && !new URLSearchParams(location.search).has("ts")) {
+ const url = new URL(location.href)
+ url.searchParams.set("ts", String(initialLegacyExample.compilerSettings.ts))
+ history.replaceState({}, "", url)
+}
+const selectedCompiler = new URLSearchParams(location.search).get("ts")
+const useNativeCompiler = isNativeCompilerVersion(selectedCompiler)
+const defaultFiles: ProjectFile[] = [
+ {
+ path: configFileName,
+ language: "json",
+ text: `{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "strict": true,
+ "declaration": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["./src/**/*"]
+}
+`,
+ },
+ {
+ path: entryFileName,
+ language: "typescript",
+ text: `import { greet } from "./greet"
+
+const message = greet("TypeScript Playground v2")
+// ^?
+
+console.log(message)
+`,
+ },
+ {
+ path: `${projectRoot}/src/greet.ts`,
+ language: "typescript",
+ text: `export function greet(name: string) {
+ return \`Hello, \${name}!\`
+}
+`,
+ },
+]
+
+const inputElement = getElement("input-editor")
+const workspace = document.querySelector(".workspace")!
+const fileExplorer = getElement("file-explorer")
+const fileList = getElement("file-list")
+const fileResizer = getElement("file-resizer")
+const outputResizer = getElement("output-resizer")
+const compilerVersion = getElement("compiler-version")
+const compilerVersionButton = getElement("compiler-version-button")
+const compilerVersionLabel = getElement("compiler-version-label")
+const compilerVersionMenu = getElement("compiler-version-menu")
+const newFileButton = getElement("new-file-button")
+const resetProjectButton = getElement("reset-project-button")
+const applyCompilerOverridesButton = getElement("apply-compiler-overrides-button")
+const toggleFilesButton = getElement("toggle-files-button")
+const toggleOutputButton = getElement("toggle-output-button")
+const showFilesButton = getElement("show-files-button")
+const showOutputButton = getElement("show-output-button")
+const toggleEmitButton = getElement("toggle-emit-button")
+const toggleRunOutputButton = getElement("toggle-run-output-button")
+const navigateBackButton = getElement("navigate-back-button")
+const currentFile = getElement("current-file")
+const editorHint = getElement("editor-hint")
+const emitOutput = getElement("emit-output")
+const emitSummary = getElement("emit-summary")
+const diagnosticsPanel = getElement("diagnostics-panel")
+const diagnosticsSummary = getElement("diagnostics-summary")
+const diagnosticsList = getElement("diagnostics-list")
+const runButton = getElement("run-button")
+const examplesButton = getElement("examples-button")
+const helpButton = getElement("help-button")
+const settingsButton = getElement("settings-button")
+const settingsDialog = getElement("settings-dialog")
+const settingsForm = getElement("settings-form")
+const settingsCancelButton = getElement("settings-cancel-button")
+const settingsResetButton = getElement("settings-reset-button")
+const settingAta = getElement("setting-ata")
+const settingSaveUrl = getElement("setting-save-url")
+const settingFontSize = getElement("setting-font-size")
+const settingTabSize = getElement("setting-tab-size")
+const settingTheme = getElement("setting-theme")
+const settingWordWrap = getElement("setting-word-wrap")
+const settingMinimap = getElement("setting-minimap")
+const settingLigatures = getElement("setting-ligatures")
+const confirmationDialog = getElement("confirmation-dialog")
+const confirmationForm = getElement("confirmation-form")
+const confirmationTitle = getElement("confirmation-title")
+const confirmationMessage = getElement("confirmation-message")
+const confirmationCancelButton = getElement("confirmation-cancel-button")
+const confirmationSubmitButton = getElement("confirmation-submit-button")
+const textInputDialog = getElement("text-input-dialog")
+const textInputForm = getElement("text-input-form")
+const textInputTitle = getElement("text-input-title")
+const textInputLabel = getElement("text-input-label")
+const textInputValue = getElement("text-input-value")
+const textInputError = getElement("text-input-error")
+const textInputCancelButton = getElement("text-input-cancel-button")
+const textInputSubmitButton = getElement("text-input-submit-button")
+const resourcesDialog = getElement("resources-dialog")
+const resourcesTitle = getElement("resources-title")
+const resourcesCloseButton = getElement("resources-close-button")
+const newFileDialog = getElement("new-file-dialog")
+const newFileForm = getElement("new-file-form")
+const newFilePath = getElement("new-file-path")
+const newFileError = getElement("new-file-error")
+const newFileCancelButton = getElement("new-file-cancel-button")
+const examplesView = getElement("examples-view")
+const examplesSearch = getElement("examples-search")
+const examplesList = getElement("examples-list")
+const helpView = getElement("help-view")
+const helpList = getElement("help-list")
+const clearRunOutput = getElement("clear-run-output")
+const runOutput = getElement("run-output")
+const runLog = getElement("run-log")
+const status = getElement("status")
+const loader = getElement("loader")
+const loadingAnimation = getElement("loading-animation")
+const loadingCopy = getElement("loading-copy")
+const loadingMessage = getElement("loading-message")
+const loadingProgress = getElement("loading-progress")
+const loadingDetail = getElement("loading-detail")
+const downloadConsent = getElement("download-consent")
+const downloadSize = getElement("download-size")
+const rememberDownloadConsent = getElement("remember-download-consent")
+const confirmDownloadButton = getElement("confirm-download-button")
+
+let compilerReady = false
+let lspReady = false
+let lspStatus: TsgoStatus = "mounting files"
+let lspServerInfo: string | undefined
+let diagnosticCount = 0
+let compilerFailure: string | undefined
+let lspFailure: string | undefined
+let projectFailure: string | undefined
+let languageServer: TsgoLspController | undefined
+let stradaBackend: StradaBackend | undefined
+let nativeBackend: NativeBackend | undefined
+let nativeCompileVersion = 0
+let projectPersistTimer = 0
+let projectPersistenceEnabled = true
+let lastStoredProject: string | undefined
+let lastUrlProject: string | undefined
+let compileActiveProject: (() => Promise | void) | undefined
+let emittedFiles = new Map()
+let emitRenderVersion = 0
+let hasShownDiagnostics = false
+let typeAcquisitionFailure: string | undefined
+let typeAcquisitionProgress: AcquisitionProgress | undefined
+const typeAcquirer = new PackageTypeAcquirer()
+let discoverDependencies: DiscoverDependencies | undefined
+let typeAcquisitionController: AbortController | undefined
+let typeAcquisitionTimer = 0
+const acquiredTypeFiles = new Map()
+const acquiredTypePackages = new Set()
+const acquiredTypeModels = new Map()
+const pendingAcquiredTypeModels = new Set()
+let acquiredTypeMount: Promise | undefined
+let examplesPromise: Promise | undefined
+let helpPromise: Promise | undefined
+let confirmationResolver: ((value: boolean) => void) | undefined
+let textInputResolver: ((value: string | null) => void) | undefined
+const downloadedAssets = new Map()
+const cachedAssets = new Map()
+const assetCachePrefix = "ts7-playground-assets-"
+let assetCachePromise: Promise | undefined
+const layoutStorageKey = "ts7-playground-layout"
+const downloadConsentStorageKey = "ts7-playground-skip-download-warning"
+const settingsStorageKey = "ts7-playground-settings"
+let compilerOverrideState: CompilerOverrideState
+let playgroundSettings = loadPlaygroundSettings()
+
+const darkMode =
+ playgroundSettings.theme === "dark" ||
+ (playgroundSettings.theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
+monaco.editor.defineTheme("typescript-playground", {
+ base: darkMode ? "vs-dark" : "vs",
+ inherit: true,
+ rules: [
+ { token: "comment", foreground: darkMode ? "7caf3d" : "6c6f2d" },
+ { token: "keyword", foreground: darkMode ? "7fc7ff" : "2b45c7" },
+ { token: "type", foreground: darkMode ? "4ec9b0" : "1142af" },
+ { token: "class", foreground: darkMode ? "4ec9b0" : "267f99" },
+ { token: "enum", foreground: darkMode ? "4ec9b0" : "267f99" },
+ { token: "interface", foreground: darkMode ? "4ec9b0" : "267f99" },
+ { token: "function", foreground: darkMode ? "dcdcaa" : "795e26" },
+ { token: "method", foreground: darkMode ? "dcdcaa" : "795e26" },
+ { token: "parameter", foreground: darkMode ? "9cdcfe" : "001080" },
+ { token: "property", foreground: darkMode ? "9cdcfe" : "001080" },
+ { token: "variable", foreground: darkMode ? "9cdcfe" : "001080" },
+ ],
+ colors: {
+ "editor.background": darkMode ? "#1e1e1e" : "#fafafa",
+ "editor.inactiveSelectionBackground": darkMode ? "#3a3d41" : "#e8e8e8",
+ "editor.selectionBackground": darkMode ? "#264f78" : "#dbeafe",
+ "editorInlayHint.background": darkMode ? "#333333" : "#eeeeee",
+ "editorInlayHint.foreground": darkMode ? "#d4d4d4" : "#333333",
+ },
+})
+
+registerPlaygroundLanguages()
+registerExampleLinks()
+const initialState = loadProjectState()
+const initialFiles =
+ initialState.useDefaults === false
+ ? { ...initialState.files }
+ : {
+ ...Object.fromEntries(defaultFiles.map(file => [file.path, file.text])),
+ ...initialState.files,
+ }
+applyLegacyCompilerOptions(initialFiles)
+const projectModels = new Map(
+ Object.entries(initialFiles).map(([fileName, text]) => {
+ const model = monaco.editor.createModel(text, languageForFile(fileName), monaco.Uri.parse(`file://${fileName}`))
+ return [fileName, model] as const
+ })
+)
+compilerOverrideState = computeCompilerOverrides(
+ projectTextMap(),
+ projectModels.get(configFileName)?.getValue() ?? "{}"
+)
+const fileButtons = new Map()
+const inputEditor = monaco.editor.create(inputElement, {
+ automaticLayout: true,
+ fontFamily: "Hack, monospace",
+ fontLigatures: playgroundSettings.fontLigatures,
+ fontSize: playgroundSettings.fontSize,
+ inlayHints: { enabled: "on" },
+ minimap: { enabled: playgroundSettings.minimap },
+ model: projectModels.get(initialState.activeFile ?? entryFileName) ?? projectModels.get(entryFileName),
+ padding: { top: 10 },
+ scrollBeyondLastLine: false,
+ "semanticHighlighting.enabled": true,
+ tabSize: playgroundSettings.tabSize,
+ theme: "typescript-playground",
+})
+let effectiveConfigModel: monaco.editor.ITextModel | undefined
+
+type EditorLocation = {
+ selection: monaco.Selection
+ uri: string
+}
+
+let projectReturnLocation: EditorLocation | undefined
+let trackedEditorLocation: EditorLocation | undefined
+const mobileLayout = matchMedia("(max-width: 700px), (max-width: 900px) and (max-height: 600px)")
+const layoutState = loadLayoutState()
+applyLayoutState()
+setupWorkspaceResizer(fileResizer, "files")
+setupWorkspaceResizer(outputResizer, "output")
+window.addEventListener("resize", applyLayoutState)
+updateResponsiveEditorOptions()
+mobileLayout.addEventListener("change", updateResponsiveEditorOptions)
+
+renderFileList()
+restoreProjectSelection(initialState)
+restoreLegacySelection()
+updateActiveFile()
+trackedEditorLocation = getEditorLocation()
+updateNavigationButtons()
+inputEditor.onDidChangeModel(() => {
+ const nextLocation = getEditorLocation()
+ if (
+ trackedEditorLocation &&
+ nextLocation &&
+ trackedEditorLocation.uri !== nextLocation.uri &&
+ projectModels.get(monaco.Uri.parse(trackedEditorLocation.uri).path)
+ ) {
+ projectReturnLocation = trackedEditorLocation
+ }
+ trackedEditorLocation = nextLocation
+ updateActiveFile()
+ updateNavigationButtons()
+})
+inputEditor.onDidChangeCursorSelection(() => {
+ trackedEditorLocation = getEditorLocation()
+ scheduleProjectPersistence()
+})
+window.addEventListener("pagehide", persistProjectState)
+document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "hidden") persistProjectState()
+})
+inputEditor.addAction({
+ id: "run-project",
+ label: "Run Project",
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
+ run: runProject,
+})
+inputEditor.onMouseDown(event => {
+ if (!event.target.position || (!event.event.ctrlKey && !event.event.metaKey)) {
+ return
+ }
+ const position = event.target.position
+ const example = exampleAtPosition(inputEditor.getModel(), position)
+ if (example) {
+ navigateToExample(example)
+ return
+ }
+ window.setTimeout(() => {
+ inputEditor.setPosition(position)
+ if (stradaBackend) {
+ const model = inputEditor.getModel()
+ if (model) void stradaBackend.goToDefinition(model, position)
+ } else {
+ void inputEditor.getAction("editor.action.revealDefinition")?.run()
+ }
+ })
+})
+
+const inlayEmitter = new monaco.Emitter()
+const typeQueries = new Map()
+const compilerOverrideHints = new Map()
+const overrideCodeLensEmitter = new monaco.Emitter()
+let overrideCodeLensProvider: monaco.languages.CodeLensProvider | undefined
+registerCompilerOverrideFeatures()
+refreshCompilerOverrides()
+
+function loadLayoutState(): LayoutState {
+ const defaults: LayoutState = {
+ emitVisible: true,
+ filesVisible: true,
+ filesWidth: 208,
+ outputVisible: true,
+ outputWidth: 440,
+ runVisible: true,
+ }
+ try {
+ const stored = JSON.parse(localStorage.getItem(layoutStorageKey) ?? "{}")
+ return {
+ emitVisible: typeof stored.emitVisible === "boolean" ? stored.emitVisible : defaults.emitVisible,
+ filesVisible: typeof stored.filesVisible === "boolean" ? stored.filesVisible : defaults.filesVisible,
+ filesWidth: typeof stored.filesWidth === "number" ? stored.filesWidth : defaults.filesWidth,
+ outputVisible: typeof stored.outputVisible === "boolean" ? stored.outputVisible : defaults.outputVisible,
+ outputWidth: typeof stored.outputWidth === "number" ? stored.outputWidth : defaults.outputWidth,
+ runVisible: typeof stored.runVisible === "boolean" ? stored.runVisible : defaults.runVisible,
+ }
+ } catch {
+ return defaults
+ }
+}
+
+function defaultPlaygroundSettings(): PlaygroundSettings {
+ return {
+ automaticTypeAcquisition: true,
+ fontLigatures: true,
+ fontSize: 14,
+ minimap: false,
+ saveToUrl: true,
+ tabSize: 2,
+ theme: "system",
+ wordWrap: false,
+ }
+}
+
+function loadPlaygroundSettings() {
+ const defaults = defaultPlaygroundSettings()
+ try {
+ const stored = JSON.parse(localStorage.getItem(settingsStorageKey) ?? "{}")
+ return {
+ automaticTypeAcquisition:
+ typeof stored.automaticTypeAcquisition === "boolean"
+ ? stored.automaticTypeAcquisition
+ : defaults.automaticTypeAcquisition,
+ fontLigatures: typeof stored.fontLigatures === "boolean" ? stored.fontLigatures : defaults.fontLigatures,
+ fontSize: [12, 14, 16, 18, 20].includes(stored.fontSize) ? stored.fontSize : defaults.fontSize,
+ minimap: typeof stored.minimap === "boolean" ? stored.minimap : defaults.minimap,
+ saveToUrl: typeof stored.saveToUrl === "boolean" ? stored.saveToUrl : defaults.saveToUrl,
+ tabSize: stored.tabSize === 4 ? 4 : defaults.tabSize,
+ theme: stored.theme === "light" || stored.theme === "dark" ? stored.theme : defaults.theme,
+ wordWrap: typeof stored.wordWrap === "boolean" ? stored.wordWrap : defaults.wordWrap,
+ } satisfies PlaygroundSettings
+ } catch {
+ return defaults
+ }
+}
+
+function openSettings() {
+ populateSettingsForm(playgroundSettings)
+ settingsDialog.showModal()
+ settingAta.focus()
+}
+
+function populateSettingsForm(settings: PlaygroundSettings) {
+ settingAta.checked = settings.automaticTypeAcquisition
+ settingSaveUrl.checked = settings.saveToUrl
+ settingFontSize.value = String(settings.fontSize)
+ settingTabSize.value = String(settings.tabSize)
+ settingTheme.value = settings.theme
+ settingWordWrap.checked = settings.wordWrap
+ settingMinimap.checked = settings.minimap
+ settingLigatures.checked = settings.fontLigatures
+}
+
+function saveSettings() {
+ const next: PlaygroundSettings = {
+ automaticTypeAcquisition: settingAta.checked,
+ fontLigatures: settingLigatures.checked,
+ fontSize: Number(settingFontSize.value),
+ minimap: settingMinimap.checked,
+ saveToUrl: settingSaveUrl.checked,
+ tabSize: Number(settingTabSize.value),
+ theme: settingTheme.value === "light" || settingTheme.value === "dark" ? settingTheme.value : "system",
+ wordWrap: settingWordWrap.checked,
+ }
+ const ataChanged = next.automaticTypeAcquisition !== playgroundSettings.automaticTypeAcquisition
+ const themeChanged = next.theme !== playgroundSettings.theme
+ playgroundSettings = next
+ try {
+ localStorage.setItem(settingsStorageKey, JSON.stringify(playgroundSettings))
+ } catch (error) {
+ console.warn("Could not save playground settings", error)
+ }
+ settingsDialog.close()
+ applyEditorSettings()
+ if (playgroundSettings.saveToUrl) persistProjectState()
+ if (ataChanged || themeChanged) {
+ if (playgroundSettings.saveToUrl) {
+ location.reload()
+ } else {
+ const url = new URL(location.href)
+ url.hash = ""
+ location.replace(url)
+ }
+ }
+}
+
+function applyEditorSettings() {
+ inputEditor.updateOptions({
+ fontLigatures: playgroundSettings.fontLigatures,
+ fontSize: playgroundSettings.fontSize,
+ minimap: { enabled: playgroundSettings.minimap },
+ tabSize: playgroundSettings.tabSize,
+ })
+ updateResponsiveEditorOptions()
+}
+
+function requestConfirmation(options: {
+ cancelLabel?: string | null
+ confirmLabel: string
+ danger?: boolean
+ message: string
+ title: string
+}) {
+ if (confirmationResolver) finishConfirmation(false)
+ confirmationTitle.textContent = options.title
+ confirmationMessage.textContent = options.message
+ confirmationCancelButton.hidden = options.cancelLabel === null
+ confirmationCancelButton.textContent = options.cancelLabel ?? "Cancel"
+ confirmationSubmitButton.textContent = options.confirmLabel
+ confirmationSubmitButton.classList.toggle("danger", options.danger === true)
+ confirmationDialog.showModal()
+ confirmationSubmitButton.focus()
+ return new Promise(resolve => {
+ confirmationResolver = resolve
+ })
+}
+
+function finishConfirmation(value: boolean) {
+ const resolve = confirmationResolver
+ confirmationResolver = undefined
+ if (confirmationDialog.open) confirmationDialog.close()
+ resolve?.(value)
+}
+
+function requestTextInput(options: { initialValue?: string; label: string; submitLabel: string; title: string }) {
+ if (textInputResolver) finishTextInput(null)
+ textInputTitle.textContent = options.title
+ textInputLabel.textContent = options.label
+ textInputValue.value = options.initialValue ?? ""
+ textInputError.hidden = true
+ textInputError.textContent = ""
+ textInputSubmitButton.textContent = options.submitLabel
+ textInputDialog.showModal()
+ textInputValue.focus()
+ textInputValue.select()
+ return new Promise(resolve => {
+ textInputResolver = resolve
+ })
+}
+
+function finishTextInput(value: string | null) {
+ const resolve = textInputResolver
+ textInputResolver = undefined
+ if (textInputDialog.open) textInputDialog.close()
+ resolve?.(value)
+}
+
+function updateResponsiveEditorOptions() {
+ const mobile = mobileLayout.matches
+ inputEditor.updateOptions({
+ folding: !mobile,
+ lineNumbersMinChars: mobile ? 3 : 5,
+ scrollbar: {
+ horizontal: mobile ? "hidden" : "auto",
+ horizontalScrollbarSize: mobile ? 0 : 12,
+ verticalScrollbarSize: mobile ? 8 : 14,
+ },
+ scrollBeyondLastColumn: mobile ? 0 : 5,
+ wordWrap: mobile || playgroundSettings.wordWrap ? "on" : "off",
+ wrappingIndent: "indent",
+ })
+ requestAnimationFrame(() => inputEditor.layout())
+}
+
+function applyLayoutState() {
+ if (!matchMedia("(max-width: 1000px)").matches) {
+ layoutState.filesWidth = clampPanelWidth("files", layoutState.filesWidth)
+ layoutState.outputWidth = clampPanelWidth("output", layoutState.outputWidth)
+ }
+ workspace.style.setProperty("--files-width", layoutState.filesVisible ? `${layoutState.filesWidth}px` : "0px")
+ workspace.style.setProperty("--output-width", layoutState.outputVisible ? `${layoutState.outputWidth}px` : "0px")
+ workspace.dataset.filesCollapsed = String(!layoutState.filesVisible)
+ workspace.dataset.outputCollapsed = String(!layoutState.outputVisible)
+ emitOutput.hidden = !layoutState.emitVisible
+ runOutput.dataset.collapsed = String(!layoutState.runVisible)
+
+ showFilesButton.hidden = layoutState.filesVisible
+ showOutputButton.hidden = layoutState.outputVisible
+ showFilesButton.setAttribute("aria-expanded", String(layoutState.filesVisible))
+ showOutputButton.setAttribute("aria-expanded", String(layoutState.outputVisible))
+ toggleFilesButton.setAttribute("aria-expanded", String(layoutState.filesVisible))
+ toggleOutputButton.setAttribute("aria-expanded", String(layoutState.outputVisible))
+ toggleEmitButton.textContent = layoutState.emitVisible ? "▾" : "▸"
+ toggleEmitButton.title = layoutState.emitVisible ? "Collapse Emit" : "Expand Emit"
+ toggleEmitButton.setAttribute("aria-label", toggleEmitButton.title)
+ toggleEmitButton.setAttribute("aria-expanded", String(layoutState.emitVisible))
+ toggleRunOutputButton.textContent = layoutState.runVisible ? "▾" : "▸"
+ toggleRunOutputButton.title = layoutState.runVisible ? "Collapse Run output" : "Expand Run output"
+ toggleRunOutputButton.setAttribute("aria-label", toggleRunOutputButton.title)
+ toggleRunOutputButton.setAttribute("aria-expanded", String(layoutState.runVisible))
+
+ fileResizer.setAttribute("aria-valuenow", String(Math.round(layoutState.filesWidth)))
+ outputResizer.setAttribute("aria-valuenow", String(Math.round(layoutState.outputWidth)))
+ requestAnimationFrame(() => inputEditor.layout())
+}
+
+function persistLayoutState() {
+ try {
+ localStorage.setItem(layoutStorageKey, JSON.stringify(layoutState))
+ } catch (error) {
+ console.warn("Could not save playground layout", error)
+ }
+}
+
+function toggleWorkspacePanel(panel: "files" | "output") {
+ if (panel === "files") layoutState.filesVisible = !layoutState.filesVisible
+ else layoutState.outputVisible = !layoutState.outputVisible
+ applyLayoutState()
+ persistLayoutState()
+ const button = panel === "files"
+ ? (layoutState.filesVisible ? toggleFilesButton : showFilesButton)
+ : (layoutState.outputVisible ? toggleOutputButton : showOutputButton)
+ button.focus()
+}
+
+function setupWorkspaceResizer(element: HTMLElement, target: "files" | "output") {
+ element.addEventListener("pointerdown", event => {
+ if (matchMedia("(max-width: 1000px)").matches) return
+ event.preventDefault()
+ const startX = event.clientX
+ const startWidth = target === "files" ? layoutState.filesWidth : layoutState.outputWidth
+ element.setPointerCapture(event.pointerId)
+ document.body.dataset.resizing = target
+
+ const move = (moveEvent: PointerEvent) => {
+ const delta = moveEvent.clientX - startX
+ const width = target === "files" ? startWidth + delta : startWidth - delta
+ if (target === "files") layoutState.filesWidth = width
+ else layoutState.outputWidth = width
+ applyLayoutState()
+ }
+ const stop = () => {
+ element.removeEventListener("pointermove", move)
+ element.removeEventListener("pointerup", stop)
+ element.removeEventListener("pointercancel", stop)
+ delete document.body.dataset.resizing
+ persistLayoutState()
+ }
+ element.addEventListener("pointermove", move)
+ element.addEventListener("pointerup", stop)
+ element.addEventListener("pointercancel", stop)
+ })
+
+ element.addEventListener("keydown", event => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
+ event.preventDefault()
+ const direction = event.key === "ArrowRight" ? 1 : -1
+ if (target === "files") layoutState.filesWidth += direction * 16
+ else layoutState.outputWidth -= direction * 16
+ applyLayoutState()
+ persistLayoutState()
+ })
+}
+
+function clampPanelWidth(target: "files" | "output", width: number) {
+ const workspaceWidth = workspace.clientWidth || innerWidth
+ if (target === "files") {
+ const maximum = Math.max(160, workspaceWidth - (layoutState.outputVisible ? layoutState.outputWidth : 0) - 420)
+ return Math.min(Math.max(width, 144), Math.min(360, maximum))
+ }
+ const maximum = Math.max(240, workspaceWidth - (layoutState.filesVisible ? layoutState.filesWidth : 0) - 480)
+ return Math.min(Math.max(width, 240), Math.min(640, maximum))
+}
+
+for (const language of ["javascript", "typescript"]) {
+ monaco.languages.registerInlayHintsProvider(language, {
+ onDidChangeInlayHints: inlayEmitter.event,
+ provideInlayHints(model) {
+ return {
+ hints: [
+ ...(typeQueries.get(model.uri.toString()) ?? []).map(query => ({
+ kind: monaco.languages.InlayHintKind.Type,
+ position: new monaco.Position(query.lineNumber, query.column),
+ label: query.label,
+ paddingLeft: true,
+ })),
+ ...(compilerOverrideHints.get(model.uri.toString()) ?? []),
+ ],
+ dispose() {},
+ }
+ },
+ })
+}
+monaco.languages.registerInlayHintsProvider("json", {
+ onDidChangeInlayHints: inlayEmitter.event,
+ provideInlayHints(model) {
+ return {
+ dispose() {},
+ hints: compilerOverrideHints.get(model.uri.toString()) ?? [],
+ }
+ },
+})
+
+let updateTimer = 0
+for (const model of projectModels.values()) {
+ registerProjectModel(model)
+}
+newFileButton.addEventListener("click", createNewFile)
+newFileCancelButton.addEventListener("click", () => newFileDialog.close())
+newFileForm.addEventListener("submit", event => {
+ event.preventDefault()
+ finishCreatingFile()
+})
+resetProjectButton.addEventListener("click", () => void resetProject())
+applyCompilerOverridesButton.addEventListener("click", applyCompilerOverridesToConfig)
+toggleFilesButton.addEventListener("click", () => toggleWorkspacePanel("files"))
+showFilesButton.addEventListener("click", () => toggleWorkspacePanel("files"))
+toggleOutputButton.addEventListener("click", () => toggleWorkspacePanel("output"))
+showOutputButton.addEventListener("click", () => toggleWorkspacePanel("output"))
+toggleEmitButton.addEventListener("click", () => {
+ layoutState.emitVisible = !layoutState.emitVisible
+ applyLayoutState()
+ persistLayoutState()
+})
+toggleRunOutputButton.addEventListener("click", () => {
+ layoutState.runVisible = !layoutState.runVisible
+ applyLayoutState()
+ persistLayoutState()
+})
+navigateBackButton.addEventListener("click", navigateBack)
+runButton.addEventListener("click", runProject)
+examplesButton.addEventListener("click", () => void openExamples())
+helpButton.addEventListener("click", () => void openHelp())
+settingsButton.addEventListener("click", openSettings)
+settingsCancelButton.addEventListener("click", () => settingsDialog.close())
+settingsResetButton.addEventListener("click", () => populateSettingsForm(defaultPlaygroundSettings()))
+settingsForm.addEventListener("submit", event => {
+ event.preventDefault()
+ saveSettings()
+})
+resourcesCloseButton.addEventListener("click", () => resourcesDialog.close())
+examplesSearch.addEventListener("input", () => void renderExamples())
+clearRunOutput.addEventListener("click", () => renderRunLogs([]))
+confirmationCancelButton.addEventListener("click", () => finishConfirmation(false))
+confirmationForm.addEventListener("submit", event => {
+ event.preventDefault()
+ finishConfirmation(true)
+})
+confirmationDialog.addEventListener("cancel", event => {
+ event.preventDefault()
+ finishConfirmation(false)
+})
+textInputCancelButton.addEventListener("click", () => finishTextInput(null))
+textInputForm.addEventListener("submit", event => {
+ event.preventDefault()
+ const value = textInputValue.value.trim()
+ if (value === "") {
+ textInputError.textContent = "Enter a value."
+ textInputError.hidden = false
+ textInputValue.focus()
+ return
+ }
+ finishTextInput(value)
+})
+textInputDialog.addEventListener("cancel", event => {
+ event.preventDefault()
+ finishTextInput(null)
+})
+
+void openLegacyResourceRoute(initialHash)
+void initializeVersionSelector()
+void (useNativeCompiler ? initializeNativeCompiler() : initializeStradaCompiler(selectedCompiler!))
+
+async function initializeNativeCompiler() {
+ try {
+ await confirmLargeDownloadIfNeeded()
+ setLoadingProgress(0, "Downloading TypeScript...", "Preparing downloads")
+ const [wasmBytes, libFilesBytes, configSchemaBytes] = await Promise.all([
+ downloadAsset("wasm", new URL("./tsc.wasm", import.meta.url)),
+ downloadAsset("libraries", new URL("./lib-files.json", import.meta.url)),
+ downloadAsset("schema", new URL("./tsconfig.schema.json", import.meta.url)),
+ ])
+
+ setLoadingIndeterminate("Compiling TypeScript...", "")
+ const module = await WebAssembly.compile(wasmBytes)
+ setLoadingProgress(78, "Starting compiler worker...", "Preparing WebAssembly")
+ const libFiles = JSON.parse(new TextDecoder().decode(libFilesBytes)) as Record
+ const configSchema = JSON.parse(new TextDecoder().decode(configSchemaBytes))
+ registerConfigSchema(configSchema)
+ nativeBackend = await NativeBackend.create(module, libFiles)
+ const backend = nativeBackend
+ discoverDependencies = file => backend.discover(file)
+ compileActiveProject = compileNativeProject
+ compilerReady = true
+ startLanguageServer(module, libFiles)
+ await compileNativeProject()
+ void refreshTypeAcquisition()
+ inputEditor.focus()
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ compilerFailure = message
+ renderStatus()
+ console.error(error)
+ }
+}
+
+async function initializeStradaCompiler(requestedVersion: string) {
+ try {
+ setLoadingIndeterminate("Loading TypeScript...", requestedVersion)
+ const version = await resolveStradaVersion(requestedVersion)
+ const compilerUrl = `https://playgroundcdn.typescriptlang.org/cdn/${version}/typescript/lib/typescript.js`
+ const compilerResponse = await fetch(compilerUrl)
+ if (!compilerResponse.ok) {
+ throw new Error(`Unable to load TypeScript ${version}: ${compilerResponse.status} ${compilerResponse.statusText}`)
+ }
+ const compilerSource = await compilerResponse.text()
+ const classicTS = new Function(`${compilerSource}\nreturn ts;`)()
+ discoverDependencies = stradaDiscovery(classicTS)
+ stradaBackend = await StradaBackend.create({
+ baseUrl: `https://playgroundcdn.typescriptlang.org/cdn/${version}/typescript/lib/`,
+ compilerSource,
+ editor: inputEditor,
+ files: compilerFileContents,
+ models: projectModels,
+ onNavigate: navigateToModel,
+ version,
+ })
+ compileActiveProject = compileStradaProject
+ compilerReady = true
+ lspReady = true
+ lspServerInfo = `TypeScript ${version}`
+ await compileStradaProject()
+ void refreshTypeAcquisition()
+ renderStatus()
+ inputEditor.focus()
+ } catch (error) {
+ compilerFailure = error instanceof Error ? error.message : String(error)
+ renderStatus()
+ console.error(error)
+ }
+}
+
+function startLanguageServer(module: WebAssembly.Module, libraries: Record) {
+ try {
+ languageServer = startTsgoLsp({
+ configFileName,
+ editor: inputEditor,
+ effectiveConfigText: effectiveCompilerConfigText(),
+ libraries,
+ models: [...projectModels.values()],
+ module,
+ onError(message) {
+ lspFailure = message
+ renderStatus()
+ },
+ onNavigate: navigateToModel,
+ shouldHandleDiagnostics: model => projectModels.get(model.uri.path) === model,
+ onStatus(nextStatus, serverInfo) {
+ lspStatus = nextStatus
+ lspReady = nextStatus === "ready"
+ if (lspReady) {
+ void mountPendingAcquiredTypeModels().then(
+ () => languageServer?.refreshDiagnostics(),
+ error => {
+ lspFailure = error instanceof Error ? error.message : String(error)
+ console.error("Could not synchronize package types", error)
+ renderStatus()
+ }
+ )
+ }
+ lspServerInfo = serverInfo ?? lspServerInfo
+ const progress = {
+ "mounting files": 92,
+ "starting tsc.wasm": 95,
+ "initializing LSP": 98,
+ ready: 100,
+ }[nextStatus]
+ setLoadingProgress(
+ progress,
+ nextStatus === "ready" ? "TypeScript is ready" : `Starting language server: ${nextStatus}`,
+ nextStatus === "ready" ? lspServerInfo ?? __TS_VERSION__ : ""
+ )
+ renderStatus()
+ },
+ })
+ } catch (error) {
+ lspFailure = error instanceof Error ? error.message : String(error)
+ renderStatus()
+ }
+}
+
+async function initializeVersionSelector() {
+ compilerVersionButton.setAttribute("aria-disabled", "true")
+ try {
+ const response = await fetch(new URL("./versions.json", import.meta.url))
+ if (!response.ok) throw new Error(`Could not load versions: ${response.status}`)
+ const releases = (await response.json()) as { versions: string[] }
+ const unsupported = new Set(["3.1.6", "3.0.1", "2.8.1", "2.7.2", "2.4.1"])
+ const seenMinorVersions = new Set()
+ const versions: Array<{ label: string; value: string }> = [{ label: __TS_VERSION__, value: "native" }]
+ for (const version of releases.versions) {
+ if (unsupported.has(version)) continue
+ const minorVersion = version.split(".").slice(0, 2).join(".")
+ if (seenMinorVersions.has(minorVersion)) continue
+ seenMinorVersions.add(minorVersion)
+ versions.push({ label: version, value: version })
+ }
+ const selectedValue = useNativeCompiler ? "native" : normalizeRequestedVersion(selectedCompiler!)
+ if (!versions.some(version => version.value === selectedValue)) {
+ versions.push({ label: selectedCompiler!, value: selectedValue })
+ }
+ compilerVersionMenu.replaceChildren()
+ for (const version of versions) {
+ compilerVersionMenu.appendChild(createVersionMenuItem(version.label, version.value, selectedValue))
+ }
+ const separator = document.createElement("div")
+ separator.className = "version-menu-separator"
+ separator.setAttribute("role", "separator")
+ compilerVersionMenu.appendChild(separator)
+ compilerVersionMenu.appendChild(createVersionMenuItem("Custom / PR build…", "__custom__", selectedValue))
+
+ compilerVersionLabel.textContent =
+ versions.find(version => version.value === selectedValue)?.label ?? selectedCompiler ?? __TS_VERSION__
+ compilerVersion.addEventListener("toggle", () => {
+ compilerVersionButton.setAttribute("aria-expanded", String(compilerVersion.open))
+ if (compilerVersion.open) {
+ compilerVersionMenu.querySelector('[aria-checked="true"]')?.focus()
+ }
+ })
+ compilerVersionMenu.addEventListener("keydown", handleVersionMenuKeydown)
+ document.addEventListener("pointerdown", event => {
+ if (compilerVersion.open && !compilerVersion.contains(event.target as Node)) compilerVersion.open = false
+ })
+ } catch (error) {
+ console.warn("Could not initialize the compiler version selector", error)
+ } finally {
+ compilerVersionButton.removeAttribute("aria-disabled")
+ }
+}
+
+function createVersionMenuItem(label: string, value: string, selectedValue: string) {
+ const button = document.createElement("button")
+ button.type = "button"
+ button.className = "version-menu-item"
+ button.dataset.value = value
+ button.setAttribute("role", "menuitemradio")
+ button.setAttribute("aria-checked", String(value === selectedValue))
+ button.appendChild(createText("span", label))
+ button.addEventListener("click", () => void selectCompilerVersion(value))
+ return button
+}
+
+async function selectCompilerVersion(value: string) {
+ compilerVersion.open = false
+ persistProjectState()
+ const url = new URL(location.href)
+ if (value === "native") {
+ url.searchParams.delete("ts")
+ } else if (value === "__custom__") {
+ const custom = await requestTextInput({
+ label: "Playground CDN build ID",
+ submitLabel: "Load build",
+ title: "Custom TypeScript build",
+ })
+ if (!custom) {
+ compilerVersionButton.focus()
+ return
+ }
+ url.searchParams.set("ts", custom)
+ } else {
+ url.searchParams.set("ts", value)
+ }
+ location.href = url.href
+}
+
+function handleVersionMenuKeydown(event: KeyboardEvent) {
+ const items = [...compilerVersionMenu.querySelectorAll(".version-menu-item")]
+ const current = items.indexOf(document.activeElement as HTMLButtonElement)
+ let next = current
+ if (event.key === "ArrowDown") next = (current + 1) % items.length
+ else if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length
+ else if (event.key === "Home") next = 0
+ else if (event.key === "End") next = items.length - 1
+ else if (event.key === "Escape") {
+ compilerVersion.open = false
+ compilerVersionButton.focus()
+ event.preventDefault()
+ return
+ } else {
+ return
+ }
+ items[next]?.focus()
+ event.preventDefault()
+}
+
+function isNativeCompilerVersion(version: string | null) {
+ return (
+ version === null ||
+ version === "" ||
+ version === "native" ||
+ version === "7" ||
+ version === "7.1" ||
+ version === __TS_VERSION__
+ )
+}
+
+function normalizeRequestedVersion(version: string) {
+ return version === "Nightly" ? "next" : version
+}
+
+async function resolveStradaVersion(requestedVersion: string) {
+ const normalized = normalizeRequestedVersion(requestedVersion)
+ if (normalized !== "next" && normalized !== "latest") return normalized
+ const index = normalized === "next" ? "next.json" : "releases.json"
+ const response = await fetch(`https://playgroundcdn.typescriptlang.org/indexes/${index}`, { cache: "no-cache" })
+ if (!response.ok) {
+ throw new Error(`Could not resolve TypeScript ${requestedVersion}: ${response.status}`)
+ }
+ const result = await response.json()
+ return normalized === "next"
+ ? (result.version as string)
+ : [...(result.versions as string[])].sort(compareVersions).at(-1)!
+}
+
+function compareVersions(left: string, right: string) {
+ const leftParts = left.split(/[.-]/).map(part => Number(part) || 0)
+ const rightParts = right.split(/[.-]/).map(part => Number(part) || 0)
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) {
+ const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0)
+ if (difference !== 0) return difference
+ }
+ return 0
+}
+
+async function openExamples() {
+ resourcesTitle.textContent = "Examples"
+ examplesView.hidden = false
+ helpView.hidden = true
+ resourcesDialog.showModal()
+ await renderExamples()
+ examplesSearch.focus()
+}
+
+async function renderExamples() {
+ try {
+ const examples = await getExamples()
+ const query = examplesSearch.value.trim().toLowerCase()
+ const filtered = examples.examples
+ .filter(example => {
+ const searchText = `${example.title} ${example.path.join(" ")}`.toLowerCase()
+ return query === "" || searchText.includes(query)
+ })
+ .sort(
+ (left, right) =>
+ left.path.join("/").localeCompare(right.path.join("/")) || left.title.localeCompare(right.title)
+ )
+ examplesList.replaceChildren()
+ if (filtered.length === 0) {
+ examplesList.appendChild(createText("p", "No matching examples.", "empty-message"))
+ return
+ }
+ for (const example of filtered) {
+ const button = document.createElement("button")
+ button.type = "button"
+ button.appendChild(createText("strong", example.title))
+ button.appendChild(createText("small", example.path.join(" / ")))
+ button.addEventListener("click", () => void loadExample(example))
+ examplesList.appendChild(button)
+ }
+ } catch (error) {
+ examplesList.replaceChildren(
+ createText("p", error instanceof Error ? error.message : String(error), "resource-error")
+ )
+ }
+}
+
+async function openHelp() {
+ resourcesTitle.textContent = "Playground help"
+ examplesView.hidden = true
+ helpView.hidden = false
+ resourcesDialog.showModal()
+ try {
+ const help = await getHelp()
+ helpList.replaceChildren()
+ for (const topic of help.docs) {
+ const details = document.createElement("details")
+ details.className = "help-topic"
+ details.dataset.title = topic.title
+ details.appendChild(createText("summary", topic.title))
+ const content = document.createElement("article")
+ content.innerHTML = topic.html
+ details.appendChild(content)
+ helpList.appendChild(details)
+ }
+ } catch (error) {
+ helpList.replaceChildren(createText("p", error instanceof Error ? error.message : String(error), "resource-error"))
+ }
+}
+
+async function openLegacyResourceRoute(hash: string) {
+ if (hash === "#show-examples") {
+ await openExamples()
+ return
+ }
+ const handbook = /^#handbook(?:-(\d+))?$/.exec(hash)
+ if (!handbook) return
+ await openHelp()
+ const index = Number(handbook[1] ?? 0)
+ const help = await getHelp()
+ const topic = help.docs.find(candidate => candidate.legacyIndexes?.includes(index))
+ if (topic) showHelpDocument(topic)
+}
+
+function showHelpDocument(topic: PlaygroundHelp["docs"][number]) {
+ const details = [...helpList.querySelectorAll(".help-topic")]
+ .find(element => element.dataset.title === topic.title)
+ if (!details) return
+ details.open = true
+ details.querySelector("summary")?.focus()
+ details.scrollIntoView({ block: "nearest" })
+}
+
+async function loadExample(example: PlaygroundExample) {
+ const confirmed = await requestConfirmation({
+ confirmLabel: "Replace project",
+ danger: true,
+ message: `Replace the current project with “${example.title}”? Unsaved project files will be replaced.`,
+ title: "Open example",
+ })
+ if (!confirmed) return
+ navigateToExample(example)
+}
+
+function createExampleProjectState(example: PlaygroundExample) {
+ const settings = example.compilerSettings ?? {}
+ const fileType = exampleFileType(example, settings)
+ const state = createLegacyProjectState(example.code, fileType)
+ const config = JSON.parse(state.files[configFileName] ?? defaultFiles[0].text)
+ config.compilerOptions ??= {}
+ for (const [originalKey, value] of Object.entries(settings)) {
+ if (originalKey === "ts" || originalKey === "useJavaScript" || originalKey === "filetype") continue
+ const key = originalKey === "checkJS" ? "checkJs" : originalKey
+ config.compilerOptions[key] = parseLegacyCompilerOption(key, String(value))
+ }
+ if (fileType === "js" || fileType === "jsx") {
+ config.compilerOptions.allowJs = true
+ config.compilerOptions.checkJs ??= true
+ }
+ state.files[configFileName] = `${JSON.stringify(config, undefined, 2)}\n`
+ return state
+}
+
+function navigateToExample(example: PlaygroundExample) {
+ const settings = example.compilerSettings ?? {}
+ const state = createExampleProjectState(example)
+ const serialized = serializeProjectState(state)
+ localStorage.setItem(storageKey, serialized)
+ projectPersistenceEnabled = false
+ window.clearTimeout(projectPersistTimer)
+ const url = new URL(location.pathname, location.origin)
+ const requestedVersion = typeof settings.ts === "string" ? settings.ts : selectedCompiler
+ if (requestedVersion && !isNativeCompilerVersion(requestedVersion)) {
+ url.searchParams.set("ts", requestedVersion)
+ }
+ url.hash = `${projectHashPrefix.slice(1)}${LZString.compressToEncodedURIComponent(serialized)}`
+ history.replaceState({}, "", url)
+ location.reload()
+}
+
+function exampleFileType(example: PlaygroundExample, settings: PlaygroundExample["compilerSettings"]) {
+ if (settings?.useJavaScript === true) return example.name.endsWith("x") ? "jsx" : "js"
+ const match = /(\.d\.[cm]?ts|\.d\.ts|\.tsx|\.ts|\.jsx|\.js)$/i.exec(example.name)
+ return match?.[1].replace(/^\./, "") ?? "ts"
+}
+
+function getExamples() {
+ examplesPromise ??= Promise.resolve(bundledExamples)
+ return examplesPromise
+}
+
+function getHelp() {
+ helpPromise ??= fetch(new URL("./help.json", import.meta.url)).then(async response => {
+ if (!response.ok) throw new Error(`Could not load help: ${response.status} ${response.statusText}`)
+ return response.json() as Promise
+ })
+ return helpPromise
+}
+
+function registerExampleLinks() {
+ const examplesById = new Map(bundledExamples.examples.map(example => [example.id, example]))
+ for (const language of ["javascript", "typescript"]) {
+ monaco.languages.registerLinkProvider(language, {
+ provideLinks(model) {
+ const links: monaco.languages.ILink[] = []
+ for (let lineNumber = 1; lineNumber <= model.getLineCount(); lineNumber++) {
+ const line = model.getLineContent(lineNumber)
+ const match = /\/\/\s*example:\s*([\w-]+)/i.exec(line)
+ if (!match) continue
+ const example = examplesById.get(match[1])
+ if (!example) continue
+ const startColumn = match.index + match[0].lastIndexOf(match[1]) + 1
+ const url = legacyExampleUrl(example)
+ links.push({
+ range: new monaco.Range(lineNumber, startColumn, lineNumber, startColumn + match[1].length),
+ tooltip: `Open example: ${example.title}`,
+ url: monaco.Uri.parse(url.href),
+ })
+ }
+ return { links }
+ },
+ })
+ }
+}
+
+function exampleAtPosition(model: monaco.editor.ITextModel | null, position: monaco.Position) {
+ if (!model) return undefined
+ const line = model.getLineContent(position.lineNumber)
+ const match = /\/\/\s*example:\s*([\w-]+)/i.exec(line)
+ if (!match) return undefined
+ const startColumn = match.index + match[0].lastIndexOf(match[1]) + 1
+ if (position.column < startColumn || position.column > startColumn + match[1].length) return undefined
+ return bundledExamples.examples.find(example => example.id === match[1])
+}
+
+function legacyExampleUrl(example: PlaygroundExample) {
+ const url = new URL(location.pathname, location.origin)
+ for (const [key, value] of Object.entries(example.compilerSettings ?? {})) {
+ url.searchParams.set(key, String(value))
+ }
+ if (!url.searchParams.has("ts") && selectedCompiler && !isNativeCompilerVersion(selectedCompiler)) {
+ url.searchParams.set("ts", selectedCompiler)
+ }
+ url.hash = `example/${example.id}`
+ return url
+}
+
+function projectFileContents() {
+ return Object.fromEntries([...projectModels].map(([fileName, model]) => [fileName, model.getValue()]))
+}
+
+function projectTextMap() {
+ return new Map([...projectModels].map(([fileName, model]) => [fileName, model.getValue()]))
+}
+
+function compilerFileContents() {
+ const files = {
+ ...Object.fromEntries(acquiredTypeFiles),
+ ...projectFileContents(),
+ }
+ files[configFileName] = effectiveCompilerConfigText()
+ return files
+}
+
+function effectiveCompilerConfigText() {
+ const configText = compilerOverrideState.effectiveConfigText
+ const typePackages = implicitTypePackages()
+ return typePackages.length === 0 ? configText : setCompilerOption(configText, "types", typePackages)
+}
+
+function implicitTypePackages() {
+ if (acquiredTypePackages.size === 0 || configOptionNode(compilerOverrideState.effectiveConfigText, "types")) {
+ return []
+ }
+ return [...acquiredTypePackages].sort()
+}
+
+function refreshCompilerOverrides() {
+ compilerOverrideState = computeCompilerOverrides(
+ projectTextMap(),
+ projectModels.get(configFileName)?.getValue() ?? defaultFiles[0].text
+ )
+ languageServer?.updateEffectiveConfig(effectiveCompilerConfigText())
+ renderCompilerOverrides()
+}
+
+function renderCompilerOverrides() {
+ const byFile = new Map()
+ for (const override of compilerOverrideState.overrides) {
+ const entries = byFile.get(override.fileName) ?? []
+ entries.push(override)
+ byFile.set(override.fileName, entries)
+ }
+
+ for (const [fileName, model] of projectModels) {
+ const hints: monaco.languages.InlayHint[] = []
+ for (const override of byFile.get(fileName) ?? []) {
+ if (!override.applied) continue
+ const end = model.getPositionAt(override.end)
+ const baseValue =
+ override.tsconfigValue === undefined
+ ? "not set in tsconfig"
+ : `tsconfig: ${formatOverrideValue(override.tsconfigValue)}`
+ hints.push({
+ kind: monaco.languages.InlayHintKind.Type,
+ label: `· overrides ${baseValue}`,
+ paddingLeft: true,
+ position: end,
+ })
+ }
+ if (fileName === configFileName) {
+ for (const override of compilerOverrideState.overrides) {
+ if (!override.applied) continue
+ const node = configOptionNode(model.getValue(), override.option)
+ if (!node) continue
+ const end = model.getPositionAt(node.offset + node.length)
+ hints.push({
+ kind: monaco.languages.InlayHintKind.Type,
+ label: `· overridden by ${relativeProjectPath(override.fileName)}:${
+ override.lineNumber
+ } → ${formatOverrideValue(override.value)}`,
+ paddingLeft: true,
+ position: end,
+ })
+ }
+ }
+ compilerOverrideHints.set(model.uri.toString(), hints)
+ const markers = compilerOverrideState.diagnostics
+ .filter(diagnostic => diagnostic.fileName === fileName)
+ .map(diagnostic => {
+ const start = model.getPositionAt(diagnostic.start)
+ const end = model.getPositionAt(Math.max(diagnostic.start + 1, diagnostic.end))
+ return {
+ code: "PLAYGROUND_OVERRIDE",
+ endColumn: end.column,
+ endLineNumber: end.lineNumber,
+ message: diagnostic.message,
+ severity: monaco.MarkerSeverity.Error,
+ source: "Playground",
+ startColumn: start.column,
+ startLineNumber: start.lineNumber,
+ }
+ })
+ monaco.editor.setModelMarkers(model, "compiler-overrides", markers)
+ }
+
+ const effectiveConfigText = effectiveCompilerConfigText()
+ if (effectiveConfigModel && effectiveConfigModel.getValue() !== effectiveConfigText) {
+ effectiveConfigModel.setValue(effectiveConfigText)
+ }
+ const activeOverrides = compilerOverrideState.overrides.filter(override => override.applied)
+ applyCompilerOverridesButton.hidden = activeOverrides.length === 0
+ applyCompilerOverridesButton.textContent = `Apply ${activeOverrides.length} override${
+ activeOverrides.length === 1 ? "" : "s"
+ }`
+ inlayEmitter.fire()
+ if (overrideCodeLensProvider) overrideCodeLensEmitter.fire(overrideCodeLensProvider)
+}
+
+function registerCompilerOverrideFeatures() {
+ monaco.editor.registerCommand("playground.showSettings", openSettings)
+ monaco.editor.registerCommand("playground.showEffectiveConfig", () => {
+ const uri = monaco.Uri.parse("playground:///effective-tsconfig.json")
+ effectiveConfigModel ??= monaco.editor.createModel(effectiveCompilerConfigText(), "json", uri)
+ inputEditor.setModel(effectiveConfigModel)
+ inputEditor.focus()
+ })
+ monaco.editor.registerCommand(
+ "playground.goToCompilerOverride",
+ (_accessor, fileName: string, start: number, end: number) => {
+ const model = projectModels.get(fileName)
+ if (!model) return
+ navigateToModel(fileName, spanRange(model, start, end))
+ }
+ )
+ monaco.editor.registerCommand("playground.goToConfigOption", (_accessor, option: string) => {
+ const model = projectModels.get(configFileName)
+ if (!model) return
+ const node = configOptionNode(model.getValue(), option) ?? compilerOptionsNode(model.getValue())
+ const range = node ? spanRange(model, node.offset, node.offset + node.length) : model.getFullModelRange()
+ navigateToModel(configFileName, range)
+ })
+ for (const language of ["javascript", "typescript"]) {
+ monaco.languages.registerHoverProvider(language, {
+ provideHover(model, position) {
+ const override = compilerOverrideAt(model, model.getOffsetAt(position))
+ if (!override) return undefined
+ return {
+ contents: [
+ { value: `**Playground compiler override**` },
+ {
+ value: `\`${override.option}\`: ${formatOverrideValue(override.tsconfigValue)} → **${formatOverrideValue(
+ override.value
+ )}**`,
+ },
+ { value: "This project-wide directive takes precedence over `tsconfig.json`." },
+ ],
+ range: spanRange(model, override.start, override.end),
+ }
+ },
+ })
+ monaco.languages.registerCompletionItemProvider(language, {
+ triggerCharacters: ["@"],
+ async provideCompletionItems(model, position) {
+ const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1)
+ const match = /\/\/\s*@([\w-]*)$/.exec(line)
+ if (!match) return { suggestions: [] }
+ const metadata = await getCompilerOptionMetadata()
+ const range = new monaco.Range(
+ position.lineNumber,
+ position.column - match[1].length,
+ position.lineNumber,
+ position.column
+ )
+ return {
+ suggestions: [...metadata].map(([name, info]) => ({
+ detail: info.description,
+ insertText: `${name}: `,
+ kind: monaco.languages.CompletionItemKind.Property,
+ label: name,
+ range,
+ })),
+ }
+ },
+ })
+ monaco.languages.registerCodeActionProvider(language, {
+ provideCodeActions(model, range) {
+ const override = compilerOverrideAt(model, model.getOffsetAt(range.getStartPosition()))
+ if (!override) return { actions: [], dispose() {} }
+ const removeEdit = removeOverrideEdit(model, override)
+ const actions: monaco.languages.CodeAction[] = [
+ {
+ edit: { edits: [removeEdit] },
+ kind: "quickfix",
+ title: `Remove @${override.option} override`,
+ },
+ {
+ command: {
+ arguments: [override.option],
+ id: "playground.goToConfigOption",
+ title: "Go to tsconfig option",
+ },
+ kind: "quickfix",
+ title: `Go to ${override.option} in tsconfig.json`,
+ },
+ ]
+ if (override.applied) {
+ const configModel = projectModels.get(configFileName)
+ if (configModel) {
+ actions.unshift({
+ edit: {
+ edits: [
+ {
+ resource: configModel.uri,
+ textEdit: {
+ range: configModel.getFullModelRange(),
+ text: setCompilerOption(configModel.getValue(), override.option, override.value),
+ },
+ versionId: configModel.getVersionId(),
+ },
+ removeEdit,
+ ],
+ },
+ isPreferred: true,
+ kind: "quickfix",
+ title: `Move @${override.option} to tsconfig.json`,
+ })
+ }
+ }
+ return { actions, dispose() {} }
+ },
+ })
+ }
+
+ monaco.languages.registerHoverProvider("json", {
+ provideHover(model, position) {
+ if (model.uri.path !== configFileName) return undefined
+ const offset = model.getOffsetAt(position)
+ const override = compilerOverrideState.overrides.find(candidate => {
+ if (!candidate.applied) return false
+ const node = configOptionNode(model.getValue(), candidate.option)
+ return node && offset >= node.offset && offset <= node.offset + node.length
+ })
+ if (!override) return undefined
+ return {
+ contents: [
+ { value: `**Overridden by ${relativeProjectPath(override.fileName)}:${override.lineNumber}**` },
+ {
+ value: `Effective \`${override.option}\`: **${formatOverrideValue(override.value)}**`,
+ },
+ ],
+ }
+ },
+ })
+ overrideCodeLensProvider = {
+ onDidChange: overrideCodeLensEmitter.event,
+ provideCodeLenses(model) {
+ if (model.uri.path !== configFileName) return { lenses: [], dispose() {} }
+ const active = compilerOverrideState.overrides.filter(override => override.applied)
+ const typePackages = implicitTypePackages()
+ const disabledBySettings = !playgroundSettings.automaticTypeAcquisition
+ if (active.length === 0 && typePackages.length === 0 && !disabledBySettings) return { lenses: [], dispose() {} }
+ const node = compilerOptionsNode(model.getValue())
+ const position = node ? model.getPositionAt(node.offset) : new monaco.Position(1, 1)
+ const range = new monaco.Range(position.lineNumber, 1, position.lineNumber, 1)
+ const lenses: monaco.languages.CodeLens[] = []
+ if (disabledBySettings) {
+ lenses.push({
+ command: {
+ id: "playground.showSettings",
+ title: "Automatic package types disabled in Playground settings · Settings",
+ },
+ range,
+ })
+ }
+ if (active.length > 0) {
+ lenses.push({
+ command: {
+ id: "playground.showEffectiveConfig",
+ title: `${active.length} inline compiler override${
+ active.length === 1 ? "" : "s"
+ } active · View effective config`,
+ },
+ range,
+ })
+ }
+ if (typePackages.length > 0) {
+ lenses.push({
+ command: {
+ id: "playground.showEffectiveConfig",
+ title: `Automatic package types add compilerOptions.types: ${typePackages.join(
+ ", "
+ )} · View effective config`,
+ },
+ range,
+ })
+ }
+ return {
+ dispose() {},
+ lenses,
+ }
+ },
+ }
+ monaco.languages.registerCodeLensProvider("json", overrideCodeLensProvider)
+ monaco.languages.registerCodeActionProvider("json", {
+ provideCodeActions(model, range) {
+ if (model.uri.path !== configFileName) return { actions: [], dispose() {} }
+ const offset = model.getOffsetAt(range.getStartPosition())
+ const override = compilerOverrideState.overrides.find(candidate => {
+ if (!candidate.applied) return false
+ const node = configOptionNode(model.getValue(), candidate.option)
+ return node && offset >= node.offset && offset <= node.offset + node.length
+ })
+ if (!override) return { actions: [], dispose() {} }
+ const sourceModel = projectModels.get(override.fileName)
+ return {
+ actions: [
+ {
+ command: {
+ arguments: [override.fileName, override.start, override.end],
+ id: "playground.goToCompilerOverride",
+ title: "Go to overriding directive",
+ },
+ kind: "quickfix",
+ title: `Go to @${override.option} override`,
+ },
+ ...(sourceModel
+ ? [
+ {
+ edit: { edits: [removeOverrideEdit(sourceModel, override)] },
+ kind: "quickfix",
+ title: `Use tsconfig value and remove @${override.option}`,
+ } satisfies monaco.languages.CodeAction,
+ ]
+ : []),
+ ],
+ dispose() {},
+ }
+ },
+ })
+}
+
+let compilerOptionMetadataPromise: Promise> | undefined
+
+function getCompilerOptionMetadata() {
+ compilerOptionMetadataPromise ??= fetch(new URL("./tsconfig.schema.json", import.meta.url)).then(async response => {
+ if (!response.ok) throw new Error(`Could not load TSConfig options: ${response.status}`)
+ const schema = await response.json()
+ const properties = schema.definitions?.compilerOptionsDefinition?.properties?.compilerOptions?.properties ?? {}
+ return new Map(
+ Object.entries(properties).map(([name, value]: [string, any]) => [
+ name,
+ { description: value.markdownDescription ?? value.description ?? "" },
+ ])
+ )
+ })
+ return compilerOptionMetadataPromise
+}
+
+function compilerOverrideAt(model: monaco.editor.ITextModel, offset: number) {
+ return compilerOverrideState.overrides.find(
+ override => override.fileName === model.uri.path && offset >= override.start && offset <= override.end
+ )
+}
+
+function removeOverrideEdit(model: monaco.editor.ITextModel, override: CompilerOverride) {
+ const start = model.getPositionAt(override.start)
+ const end =
+ start.lineNumber < model.getLineCount()
+ ? new monaco.Position(start.lineNumber + 1, 1)
+ : model.getPositionAt(override.end)
+ return {
+ resource: model.uri,
+ textEdit: {
+ range: new monaco.Range(start.lineNumber, 1, end.lineNumber, end.column),
+ text: "",
+ },
+ versionId: model.getVersionId(),
+ }
+}
+
+function applyCompilerOverridesToConfig() {
+ const overrides = compilerOverrideState.overrides.filter(override => override.applied)
+ const configModel = projectModels.get(configFileName)
+ if (overrides.length === 0 || !configModel) return
+
+ let configText = configModel.getValue()
+ for (const override of overrides) {
+ configText = setCompilerOption(configText, override.option, override.value)
+ }
+ configModel.pushEditOperations([], [{ range: configModel.getFullModelRange(), text: configText }], () => null)
+
+ const byFile = new Map()
+ for (const override of overrides) {
+ const entries = byFile.get(override.fileName) ?? []
+ entries.push(override)
+ byFile.set(override.fileName, entries)
+ }
+ for (const [fileName, fileOverrides] of byFile) {
+ const model = projectModels.get(fileName)
+ if (!model) continue
+ const edits = [...fileOverrides]
+ .sort((left, right) => right.start - left.start)
+ .map(override => {
+ const edit = removeOverrideEdit(model, override).textEdit
+ return { range: edit.range, text: edit.text }
+ })
+ model.pushEditOperations([], edits, () => null)
+ }
+}
+
+function spanRange(model: monaco.editor.ITextModel, start: number, end: number) {
+ const startPosition = model.getPositionAt(start)
+ const endPosition = model.getPositionAt(end)
+ return new monaco.Range(startPosition.lineNumber, startPosition.column, endPosition.lineNumber, endPosition.column)
+}
+
+function formatOverrideValue(value: unknown) {
+ if (value === undefined) return "(unset)"
+ return typeof value === "string" ? value : JSON.stringify(value)
+}
+
+function compilerOverrideDiagnostics(): Diagnostic[] {
+ return compilerOverrideState.diagnostics.map((diagnostic, index) => ({
+ category: DiagnosticCategory.Error,
+ code: 98000 + index,
+ end: diagnostic.end,
+ fileName: diagnostic.fileName,
+ pos: diagnostic.start,
+ source: "Playground",
+ text: diagnostic.message,
+ }))
+}
+
+function scheduleTypeAcquisition() {
+ if (!compilerReady || !discoverDependencies) return
+ typeAcquisitionController?.abort()
+ window.clearTimeout(typeAcquisitionTimer)
+ typeAcquisitionProgress = undefined
+ typeAcquisitionFailure = undefined
+ setStatus("Checking project...", "loading")
+ typeAcquisitionTimer = window.setTimeout(() => {
+ void refreshTypeAcquisition()
+ }, 900)
+}
+
+async function refreshTypeAcquisition() {
+ const discover = discoverDependencies
+ if (!discover || !compilerReady) return
+ typeAcquisitionController?.abort()
+ const controller = new AbortController()
+ typeAcquisitionController = controller
+ typeAcquisitionFailure = undefined
+ typeAcquisitionProgress = { downloaded: 0, total: 0, completedPackages: 0, totalPackages: 0, pendingPackages: [] }
+ renderStatus()
+ try {
+ const result = await typeAcquirer.acquire({
+ discover,
+ files: [...projectModels.values()]
+ .filter(model => model.getLanguageId() === "javascript" || model.getLanguageId() === "typescript")
+ .map(model => ({ path: model.uri.path, text: model.getValue() })),
+ options: acquisitionOptions(
+ compilerOverrideState.effectiveConfigText,
+ playgroundSettings.automaticTypeAcquisition
+ ),
+ signal: controller.signal,
+ onProgress(progress) {
+ if (controller.signal.aborted) return
+ typeAcquisitionProgress = progress
+ renderStatus()
+ },
+ })
+ controller.signal.throwIfAborted()
+ const changed = (
+ result.files.size !== acquiredTypeFiles.size ||
+ [...result.files].some(([path, text]) => acquiredTypeFiles.get(path) !== text) ||
+ result.ambientTypes.length !== acquiredTypePackages.size ||
+ result.ambientTypes.some(name => !acquiredTypePackages.has(name))
+ )
+ if (changed) applyAcquiredTypes(result)
+ await mountPendingAcquiredTypeModels()
+ controller.signal.throwIfAborted()
+ typeAcquisitionProgress = undefined
+ typeAcquisitionFailure = result.errors.length
+ ? result.errors.map(error => `${error.packageName}: ${error.message}`).join("; ")
+ : undefined
+ for (const error of result.errors) console.error("Could not acquire package types", error)
+ if (changed) {
+ languageServer?.updateEffectiveConfig(effectiveCompilerConfigText())
+ languageServer?.refreshDiagnostics()
+ renderCompilerOverrides()
+ await compileActiveProject?.()
+ }
+ renderStatus()
+ } catch (error) {
+ if (controller.signal.aborted) return
+ typeAcquisitionProgress = undefined
+ typeAcquisitionFailure = error instanceof Error ? error.message : String(error)
+ console.error("Could not acquire package types", error)
+ renderStatus()
+ }
+}
+
+function applyAcquiredTypes(result: AcquisitionResult) {
+ for (const fileName of acquiredTypeFiles.keys()) {
+ if (result.files.has(fileName)) continue
+ const model = acquiredTypeModels.get(fileName)
+ if (model && inputEditor.getModel() === model) {
+ inputEditor.setModel(projectModels.get(entryFileName) ?? [...projectModels.values()][0])
+ }
+ model?.dispose()
+ acquiredTypeModels.delete(fileName)
+ pendingAcquiredTypeModels.delete(fileName)
+ }
+ acquiredTypeFiles.clear()
+ acquiredTypePackages.clear()
+ for (const name of result.ambientTypes) acquiredTypePackages.add(name)
+ for (const [fileName, text] of result.files) {
+ if (projectModels.has(fileName)) continue
+ acquiredTypeFiles.set(fileName, text)
+ if (lspReady && acquiredTypeModels.get(fileName)?.getValue() === text) continue
+ pendingAcquiredTypeModels.add(fileName)
+ }
+}
+
+function mountPendingAcquiredTypeModels() {
+ if (!lspReady) return Promise.resolve()
+ acquiredTypeMount ??= (async () => {
+ while (lspReady && pendingAcquiredTypeModels.size) {
+ const start = performance.now()
+ do {
+ const fileName = pendingAcquiredTypeModels.values().next().value
+ if (fileName === undefined) break
+ pendingAcquiredTypeModels.delete(fileName)
+ const text = acquiredTypeFiles.get(fileName)
+ if (text !== undefined) mountAcquiredTypeModel(fileName, text)
+ } while (pendingAcquiredTypeModels.size && performance.now() - start < 8)
+ if (pendingAcquiredTypeModels.size) await new Promise(resolve => window.setTimeout(resolve, 0))
+ }
+ })().finally(() => { acquiredTypeMount = undefined })
+ return acquiredTypeMount
+}
+
+function mountAcquiredTypeModel(fileName: string, text: string) {
+ const uri = monaco.Uri.file(fileName)
+ const existing = monaco.editor.getModel(uri)
+ if (existing) {
+ if (existing.getValue() !== text) existing.setValue(text)
+ acquiredTypeModels.set(fileName, existing)
+ return
+ }
+ acquiredTypeModels.set(fileName, monaco.editor.createModel(text, languageForFile(fileName), uri))
+}
+
+function navigateToModel(fileName: string, range?: monaco.IRange) {
+ const model = monaco.editor.getModel(monaco.Uri.file(fileName))
+ if (!model) return
+ if (inputEditor.getModel() === model && !range) {
+ inputEditor.focus()
+ return
+ }
+ const current = getEditorLocation()
+ if (current && projectModels.has(monaco.Uri.parse(current.uri).path)) projectReturnLocation = current
+ inputEditor.setModel(model)
+ if (range) {
+ inputEditor.setSelection(range)
+ inputEditor.revealRangeInCenter(range, monaco.editor.ScrollType.Immediate)
+ }
+ trackedEditorLocation = getEditorLocation()
+ updateNavigationButtons()
+ inputEditor.focus()
+}
+
+function getEditorLocation(): EditorLocation | undefined {
+ const model = inputEditor.getModel()
+ const selection = inputEditor.getSelection()
+ return model && selection ? { selection, uri: model.uri.toString() } : undefined
+}
+
+function updateNavigationButtons() {
+ const model = inputEditor.getModel()
+ navigateBackButton.hidden = !model || projectModels.get(model.uri.path) === model
+}
+
+function navigateBack() {
+ if (projectReturnLocation && projectModels.has(monaco.Uri.parse(projectReturnLocation.uri).path)) {
+ applyEditorLocation(projectReturnLocation)
+ } else {
+ const model = projectModels.get(entryFileName) ?? [...projectModels.values()][0]
+ if (model) navigateToModel(model.uri.path)
+ }
+}
+
+function applyEditorLocation(location: EditorLocation) {
+ const model = monaco.editor.getModel(monaco.Uri.parse(location.uri))
+ if (!model) return
+ inputEditor.setModel(model)
+ inputEditor.setSelection(location.selection)
+ inputEditor.revealRangeInCenter(location.selection, monaco.editor.ScrollType.Immediate)
+ trackedEditorLocation = getEditorLocation()
+ updateNavigationButtons()
+ inputEditor.focus()
+}
+
+async function downloadAsset(name: keyof typeof __LOAD_ASSET_SIZES__, url: URL): Promise> {
+ url.searchParams.set("v", __ASSET_CACHE_VERSION__)
+ const request = new Request(url)
+ const cache = await getAssetCache()
+ let response = await cachedCompilerAsset(cache, url)
+ cachedAssets.set(name, response !== undefined)
+ let cacheWrite: Promise | undefined
+ if (!response) {
+ response = await fetch(request)
+ if (cache) cacheWrite = cache.put(request, response.clone())
+ }
+ if (!response.ok) {
+ throw new Error(`Unable to load ${url.pathname}: ${response.status} ${response.statusText}`)
+ }
+ if (!response.body) {
+ const bytes = new Uint8Array(await response.arrayBuffer())
+ await cacheWrite
+ downloadedAssets.set(name, bytes.length)
+ updateDownloadProgress()
+ return bytes
+ }
+
+ const chunks: Uint8Array[] = []
+ const reader = response.body.getReader()
+ let length = 0
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ chunks.push(value)
+ length += value.length
+ downloadedAssets.set(name, length)
+ updateDownloadProgress()
+ }
+
+ const bytes = new Uint8Array(length)
+ let offset = 0
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset)
+ offset += chunk.length
+ }
+ await cacheWrite
+ return bytes
+}
+
+function getAssetCache() {
+ assetCachePromise ??= openAssetCache()
+ return assetCachePromise
+}
+
+async function cachedCompilerAsset(cache: Cache | undefined, url: URL) {
+ if (!cache) return undefined
+ const exact = await cache.match(url)
+ if (exact) return exact
+ const fileName = url.pathname.slice(url.pathname.lastIndexOf("/") + 1)
+ const previous = (await cache.keys()).find(request => {
+ const cachedUrl = new URL(request.url)
+ return (
+ cachedUrl.origin === url.origin &&
+ cachedUrl.pathname.endsWith(`/${fileName}`) &&
+ cachedUrl.searchParams.get("v") === __ASSET_CACHE_VERSION__
+ )
+ })
+ return previous ? cache.match(previous) : undefined
+}
+
+async function confirmLargeDownloadIfNeeded() {
+ if (downloadWarningSuppressed() || (await compilerAssetsAreCached())) return
+
+ const totalBytes = Object.values(__LOAD_ASSET_SIZES__).reduce((total, value) => total + value, 0)
+ downloadSize.textContent = formatBytes(totalBytes)
+ rememberDownloadConsent.checked = true
+ loadingAnimation.hidden = true
+ loadingCopy.hidden = true
+ downloadConsent.hidden = false
+ loader.hidden = false
+ loader.dataset.state = "consent"
+
+ await new Promise(resolve => {
+ confirmDownloadButton.addEventListener(
+ "click",
+ () => {
+ try {
+ if (rememberDownloadConsent.checked) localStorage.setItem(downloadConsentStorageKey, "true")
+ else localStorage.removeItem(downloadConsentStorageKey)
+ } catch (error) {
+ console.warn("Could not save the download warning preference", error)
+ }
+ resolve()
+ },
+ { once: true }
+ )
+ confirmDownloadButton.focus()
+ })
+
+ downloadConsent.hidden = true
+ loadingAnimation.hidden = false
+ loadingCopy.hidden = false
+ delete loader.dataset.state
+}
+
+function downloadWarningSuppressed() {
+ try {
+ return localStorage.getItem(downloadConsentStorageKey) === "true"
+ } catch {
+ return false
+ }
+}
+
+async function compilerAssetsAreCached() {
+ const cache = await getAssetCache()
+ if (!cache) return false
+ const urls = [
+ new URL("./tsc.wasm", import.meta.url),
+ new URL("./lib-files.json", import.meta.url),
+ new URL("./tsconfig.schema.json", import.meta.url),
+ ]
+ const matches = await Promise.all(
+ urls.map(url => {
+ url.searchParams.set("v", __ASSET_CACHE_VERSION__)
+ return cachedCompilerAsset(cache, url)
+ })
+ )
+ return matches.every(response => response !== undefined)
+}
+
+async function openAssetCache() {
+ if (!("caches" in globalThis)) return undefined
+ const cacheName = `${assetCachePrefix}${__ASSET_CACHE_VERSION__}`
+ try {
+ const cacheNames = await caches.keys()
+ await Promise.all(
+ cacheNames
+ .filter(name => name.startsWith(assetCachePrefix) && name !== cacheName)
+ .map(name => caches.delete(name))
+ )
+ return caches.open(cacheName)
+ } catch (error) {
+ console.warn("Could not open the TypeScript asset cache", error)
+ return undefined
+ }
+}
+
+function updateDownloadProgress() {
+ const totalBytes = Object.values(__LOAD_ASSET_SIZES__).reduce((total, value) => total + value, 0)
+ const downloadedBytes = [...downloadedAssets.values()].reduce((total, value) => total + value, 0)
+ const loadingFromNetwork = [...cachedAssets.values()].some(cached => !cached)
+ setLoadingProgress(
+ Math.min(70, (downloadedBytes / totalBytes) * 70),
+ loadingFromNetwork ? "Downloading TypeScript..." : "Loading cached TypeScript...",
+ `${formatBytes(downloadedBytes)} of ${formatBytes(totalBytes)}`
+ )
+}
+
+function setLoadingProgress(value: number, message: string, detail: string) {
+ loadingProgress.value = Math.max(loadingProgress.value, value)
+ loadingMessage.textContent = message
+ loadingDetail.textContent = detail
+}
+
+function setLoadingIndeterminate(message: string, detail: string) {
+ loadingProgress.removeAttribute("value")
+ loadingMessage.textContent = message
+ loadingDetail.textContent = detail
+}
+
+function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
+}
+
+async function compileNativeProject() {
+ const backend = nativeBackend
+ if (!backend) return
+ const compileVersion = ++nativeCompileVersion
+ setStatus("Checking project...", "loading")
+ runButton.disabled = true
+
+ try {
+ const result = await backend.compile({
+ files: compilerFileContents(),
+ configFileName,
+ sourceFiles: [...projectModels]
+ .filter(([, model]) => model.getLanguageId() === "javascript" || model.getLanguageId() === "typescript")
+ .map(([fileName]) => fileName),
+ })
+ if (compileVersion !== nativeCompileVersion) return
+ const diagnostics = deduplicateDiagnostics(
+ remapCompilerOverrideDiagnostics([...result.diagnostics, ...compilerOverrideDiagnostics()])
+ )
+ diagnosticCount = diagnostics.length
+ setDiagnostics(diagnostics)
+ emittedFiles = new Map(Object.entries(result.outputFiles))
+ runButton.disabled = ![...emittedFiles.keys()].some(fileName => fileName.endsWith(".js"))
+ void renderEmittedFiles()
+ typeQueries.clear()
+ for (const [fileName, queries] of Object.entries(result.typeQueries)) {
+ typeQueries.set(monaco.Uri.file(fileName).toString(), queries)
+ }
+ inlayEmitter.fire()
+ projectFailure = undefined
+ compilerFailure = undefined
+ renderStatus()
+ } catch (error) {
+ if (compileVersion !== nativeCompileVersion) return
+ const message = error instanceof Error ? error.message : String(error)
+ diagnosticCount = 0
+ emittedFiles = new Map()
+ runButton.disabled = true
+ setDiagnostics([])
+ typeQueries.clear()
+ inlayEmitter.fire()
+ renderEmitError(message)
+ projectFailure = message
+ renderStatus()
+ console.error(error)
+ }
+}
+
+let stradaCompileVersion = 0
+
+async function compileStradaProject() {
+ const backend = stradaBackend
+ if (!backend) return
+ const compileVersion = ++stradaCompileVersion
+ setStatus("Checking project...", "loading")
+ runButton.disabled = true
+ try {
+ const result = await backend.compile()
+ if (compileVersion !== stradaCompileVersion) return
+ const diagnostics = remapCompilerOverrideDiagnostics([
+ ...(result.diagnostics as Diagnostic[]),
+ ...compilerOverrideDiagnostics(),
+ ])
+ diagnosticCount = diagnostics.length
+ setDiagnostics(diagnostics)
+ emittedFiles = new Map(Object.entries(result.outputFiles))
+ runButton.disabled = ![...emittedFiles.keys()].some(fileName => fileName.endsWith(".js"))
+ void renderEmittedFiles()
+
+ typeQueries.clear()
+ await Promise.all(
+ [...projectModels.values()]
+ .filter(model => model.getLanguageId() === "javascript" || model.getLanguageId() === "typescript")
+ .map(model => collectStradaTypeQueries(backend, model))
+ )
+ if (compileVersion !== stradaCompileVersion) return
+ inlayEmitter.fire()
+ projectFailure = undefined
+ compilerFailure = undefined
+ renderStatus()
+ } catch (error) {
+ if (compileVersion !== stradaCompileVersion) return
+ diagnosticCount = 0
+ emittedFiles = new Map()
+ runButton.disabled = true
+ setDiagnostics([])
+ typeQueries.clear()
+ inlayEmitter.fire()
+ const message = error instanceof Error ? error.message : String(error)
+ renderEmitError(message)
+ projectFailure = message
+ renderStatus()
+ console.error(error)
+ }
+}
+
+async function collectStradaTypeQueries(backend: StradaBackend, model: monaco.editor.ITextModel) {
+ const source = model.getValue()
+ const queryPattern = /^\s*\/\/\s*\^\?\s*$/gm
+ const queries: TypeQuery[] = []
+ let match: RegExpExecArray | null
+ while ((match = queryPattern.exec(source))) {
+ const queryEnd = match.index + match[0].lastIndexOf("?")
+ const queryPosition = model.getPositionAt(queryEnd)
+ if (queryPosition.lineNumber === 1) continue
+ const inspectedPosition = model.getOffsetAt({
+ lineNumber: queryPosition.lineNumber - 1,
+ column: queryPosition.column,
+ })
+ const info =
+ (await backend.quickInfo(model.uri.path, inspectedPosition)) ??
+ (await backend.quickInfo(model.uri.path, Math.max(0, inspectedPosition - 1)))
+ if (!info?.displayParts) continue
+ const text = info.displayParts
+ .map((part: { text: string }) => part.text)
+ .join("")
+ .replace(/\r?\n\s*/g, " ")
+ queries.push({
+ lineNumber: queryPosition.lineNumber,
+ column: queryPosition.column + 1,
+ label: truncate(`: ${text}`, 120),
+ })
+ }
+ typeQueries.set(model.uri.toString(), queries)
+}
+
+function deduplicateDiagnostics(diagnostics: readonly Diagnostic[]) {
+ const seen = new Set()
+ return diagnostics.filter(diagnostic => {
+ const key = [diagnostic.fileName, diagnostic.pos, diagnostic.end, diagnostic.code, diagnostic.text].join(":")
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+function setDiagnostics(diagnostics: readonly Diagnostic[]) {
+ for (const [fileName, model] of projectModels) {
+ const markers = diagnostics
+ .filter(diagnostic => diagnostic.fileName === fileName && (!useNativeCompiler || diagnostic.source === "Playground"))
+ .map(diagnostic => {
+ const start = model.getPositionAt(Math.max(0, diagnostic.pos))
+ const end = model.getPositionAt(Math.max(diagnostic.pos + 1, diagnostic.end))
+ return {
+ code: String(diagnostic.code),
+ endColumn: end.column,
+ endLineNumber: end.lineNumber,
+ message: diagnostic.text,
+ modelVersionId: model.getVersionId(),
+ severity: diagnosticSeverity(diagnostic.category),
+ source: diagnostic.source || "ts",
+ startColumn: start.column,
+ startLineNumber: start.lineNumber,
+ }
+ })
+ monaco.editor.setModelMarkers(model, "typescript-7", markers)
+ }
+ renderDiagnostics(diagnostics)
+}
+
+function renderDiagnostics(diagnostics: readonly Diagnostic[]) {
+ const sorted = [...diagnostics].sort(
+ (left, right) =>
+ diagnosticSortOrder(left.category) - diagnosticSortOrder(right.category) ||
+ (left.fileName ?? "").localeCompare(right.fileName ?? "") ||
+ left.pos - right.pos ||
+ left.code - right.code
+ )
+ diagnosticsSummary.textContent = String(sorted.length)
+ diagnosticsList.replaceChildren()
+ diagnosticsPanel.dataset.empty = String(sorted.length === 0)
+ if (sorted.length === 0) {
+ diagnosticsList.appendChild(createText("p", "No problems found.", "empty-message"))
+ return
+ }
+ if (!hasShownDiagnostics) {
+ diagnosticsPanel.open = true
+ hasShownDiagnostics = true
+ }
+
+ for (const diagnostic of sorted) {
+ const fileName =
+ diagnostic.fileName && projectModels.has(diagnostic.fileName) ? diagnostic.fileName : configFileName
+ const model = projectModels.get(fileName)
+ const start = model?.getPositionAt(Math.max(0, diagnostic.pos)) ?? new monaco.Position(1, 1)
+ const end =
+ model?.getPositionAt(Math.max(diagnostic.pos + 1, diagnostic.end)) ??
+ new monaco.Position(start.lineNumber, start.column + 1)
+ const button = document.createElement("button")
+ button.type = "button"
+ button.className = `diagnostic diagnostic-${diagnosticCategoryName(diagnostic.category)}`
+ button.appendChild(createText("strong", `TS${diagnostic.code}`))
+ button.appendChild(createText("span", diagnostic.text, "diagnostic-message"))
+ button.appendChild(
+ createText("small", `${relativeProjectPath(fileName)}:${start.lineNumber}:${start.column}`, "diagnostic-location")
+ )
+ button.addEventListener("click", () => {
+ const selection = getSelection()
+ if (selection && !selection.isCollapsed && button.contains(selection.anchorNode)) return
+ navigateToModel(fileName, new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column))
+ })
+ diagnosticsList.appendChild(button)
+ }
+}
+
+function diagnosticSortOrder(category: number) {
+ switch (category) {
+ case DiagnosticCategory.Error:
+ return 0
+ case DiagnosticCategory.Warning:
+ return 1
+ case DiagnosticCategory.Suggestion:
+ return 2
+ default:
+ return 3
+ }
+}
+
+function diagnosticCategoryName(category: number) {
+ switch (category) {
+ case DiagnosticCategory.Error:
+ return "error"
+ case DiagnosticCategory.Warning:
+ return "warning"
+ case DiagnosticCategory.Suggestion:
+ return "suggestion"
+ default:
+ return "message"
+ }
+}
+
+function diagnosticSeverity(category: number) {
+ switch (category) {
+ case DiagnosticCategory.Error:
+ return monaco.MarkerSeverity.Error
+ case DiagnosticCategory.Warning:
+ return monaco.MarkerSeverity.Warning
+ case DiagnosticCategory.Suggestion:
+ return monaco.MarkerSeverity.Hint
+ default:
+ return monaco.MarkerSeverity.Info
+ }
+}
+
+async function renderEmittedFiles() {
+ const renderVersion = ++emitRenderVersion
+ emitOutput.replaceChildren()
+ const files = [...emittedFiles].sort(([left], [right]) => left.localeCompare(right))
+ emitSummary.textContent =
+ files.length === 0 ? "No files emitted" : `${files.length} emitted file${files.length === 1 ? "" : "s"}`
+
+ if (files.length === 0) {
+ emitOutput.appendChild(createText("p", "No files emitted.", "empty-message"))
+ return
+ }
+
+ for (const [fileName, text] of files) {
+ const displayText = text.replace(/(?:\r?\n)+$/, "")
+ const section = document.createElement("section")
+ section.className = "emit-file"
+ section.appendChild(createText("h3", relativeProjectPath(fileName)))
+ const pre = document.createElement("pre")
+ pre.tabIndex = 0
+ const code = document.createElement("code")
+ code.textContent = displayText
+ pre.appendChild(code)
+ section.appendChild(pre)
+ emitOutput.appendChild(section)
+
+ const language = fileName.endsWith(".js") ? "javascript" : fileName.endsWith(".json") ? "json" : "typescript"
+ const highlighted = await monaco.editor.colorize(displayText, language, { tabSize: 2 })
+ if (renderVersion !== emitRenderVersion) return
+ code.innerHTML = highlighted
+ }
+}
+
+function renderEmitError(message: string) {
+ emitRenderVersion++
+ emitSummary.textContent = "Compile failed"
+ emitOutput.replaceChildren(createText("p", message, "empty-message"))
+}
+
+function runProject() {
+ const logs: RuntimeLog[] = []
+ const javascriptFiles = new Map([...emittedFiles].filter(([fileName]) => fileName.endsWith(".js")))
+ const entryFile =
+ [...javascriptFiles.keys()].find(fileName => fileName.endsWith("/index.js")) ?? javascriptFiles.keys().next().value
+
+ if (!entryFile) {
+ renderRunLogs([{ level: "error", text: "No JavaScript entry file was emitted." }])
+ return
+ }
+ if ([...javascriptFiles.values()].some(code => /^\s*(?:export|import)\b/m.test(code))) {
+ renderRunLogs([
+ {
+ level: "error",
+ text: 'Run supports CommonJS output. Set compilerOptions.module to "CommonJS" in tsconfig.json.',
+ },
+ ])
+ return
+ }
+
+ try {
+ executeCommonJs(entryFile, javascriptFiles, createRuntimeConsole(logs))
+ } catch (error) {
+ logs.push({
+ level: "error",
+ text: error instanceof Error ? error.stack ?? error.message : String(error),
+ })
+ }
+ renderRunLogs(logs)
+ runLog.scrollIntoView({ block: "nearest" })
+}
+
+function executeCommonJs(entryFile: string, files: ReadonlyMap, runtimeConsole: Console) {
+ const cache = new Map()
+
+ const load = (fileName: string): any => {
+ const cached = cache.get(fileName)
+ if (cached) return cached.exports
+ const code = files.get(fileName)
+ if (code === undefined) throw new Error(`Cannot find emitted module ${fileName}`)
+
+ const module = { exports: {} as any }
+ cache.set(fileName, module)
+ const require = (specifier: string) => {
+ if (specifier === "reflect-metadata") return {}
+ if (!specifier.startsWith(".")) {
+ throw new Error(`Run cannot load package import "${specifier}".`)
+ }
+ const resolved = new URL(specifier, `file://${fileName}`).pathname
+ const candidates = [resolved, `${resolved}.js`, `${resolved}/index.js`]
+ const target = candidates.find(candidate => files.has(candidate))
+ if (!target) throw new Error(`Cannot resolve "${specifier}" from ${fileName}`)
+ return load(target)
+ }
+ const directory = fileName.slice(0, fileName.lastIndexOf("/")) || "/"
+ const evaluate = new Function("exports", "require", "module", "__filename", "__dirname", "console", code)
+ evaluate(module.exports, require, module, fileName, directory, runtimeConsole)
+ return module.exports
+ }
+
+ load(entryFile)
+}
+
+function createRuntimeConsole(logs: RuntimeLog[]) {
+ const runtimeConsole = Object.create(console) as Console
+ for (const level of ["debug", "error", "info", "log", "warn"] as const) {
+ runtimeConsole[level] = (...values: any[]) => {
+ logs.push({ level, text: values.map(formatRuntimeValue).join(" ") })
+ renderRunLogs(logs)
+ console[level](...values)
+ }
+ }
+ runtimeConsole.clear = () => {
+ logs.splice(0)
+ renderRunLogs(logs)
+ }
+ return runtimeConsole
+}
+
+function formatRuntimeValue(value: unknown): string {
+ if (typeof value === "string") return value
+ if (typeof value === "bigint") return `${value}n`
+ if (typeof value === "symbol") return String(value)
+ if (value instanceof Error) return value.stack ?? value.message
+ try {
+ const json = JSON.stringify(value, undefined, 2)
+ return json ?? String(value)
+ } catch {
+ return String(value)
+ }
+}
+
+function renderRunLogs(logs: readonly RuntimeLog[]) {
+ runLog.replaceChildren()
+ if (logs.length === 0) {
+ runLog.appendChild(createText("p", "Run the project to see console output.", "empty-message"))
+ return
+ }
+ for (const log of logs) {
+ const row = document.createElement("div")
+ row.className = `run-log-entry ${log.level}`
+ row.appendChild(createText("strong", log.level.slice(0, 3).toUpperCase()))
+ row.appendChild(document.createTextNode(log.text))
+ runLog.appendChild(row)
+ }
+}
+
+function renderFileList() {
+ type Tree = {
+ directories: Map
+ files: string[]
+ }
+
+ const root: Tree = { directories: new Map(), files: [] }
+ for (const fileName of [...projectModels.keys()].sort()) {
+ const parts = relativeProjectPath(fileName).split("/")
+ const basename = parts.pop()!
+ let tree = root
+ for (const part of parts) {
+ let child = tree.directories.get(part)
+ if (!child) {
+ child = { directories: new Map(), files: [] }
+ tree.directories.set(part, child)
+ }
+ tree = child
+ }
+ tree.files.push(basename)
+ }
+
+ fileButtons.clear()
+ fileList.replaceChildren(renderTree(root, ""))
+ updateActiveFile()
+
+ function renderTree(tree: Tree, parentPath: string): HTMLUListElement {
+ const list = document.createElement("ul")
+ list.className = "file-tree"
+ for (const [directory, child] of [...tree.directories].sort(([left], [right]) => left.localeCompare(right))) {
+ const item = document.createElement("li")
+ const details = document.createElement("details")
+ details.open = true
+ details.appendChild(createText("summary", directory, "file-tree-folder"))
+ details.appendChild(renderTree(child, `${parentPath}${directory}/`))
+ item.appendChild(details)
+ list.appendChild(item)
+ }
+ for (const basename of tree.files.sort()) {
+ const relativePath = `${parentPath}${basename}`
+ const fileName = `${projectRoot}/${relativePath}`
+ const button = document.createElement("button")
+ button.type = "button"
+ button.className = "file-tree-file-button"
+ button.dataset.kind = fileKind(fileName)
+ button.textContent = basename
+ button.addEventListener("click", () => {
+ navigateToModel(fileName)
+ if (mobileLayout.matches) {
+ layoutState.filesVisible = false
+ applyLayoutState()
+ persistLayoutState()
+ }
+ })
+ fileButtons.set(fileName, button)
+ const deleteButton = document.createElement("button")
+ deleteButton.type = "button"
+ deleteButton.className = "file-tree-delete"
+ deleteButton.textContent = "×"
+ deleteButton.title = `Delete ${relativePath}`
+ deleteButton.setAttribute("aria-label", `Delete ${relativePath}`)
+ deleteButton.addEventListener("click", () => void deleteProjectFile(fileName))
+ const item = document.createElement("li")
+ item.className = "file-tree-file"
+ item.appendChild(button)
+ item.appendChild(deleteButton)
+ list.appendChild(item)
+ }
+ return list
+ }
+}
+
+function updateActiveFile() {
+ const model = inputEditor.getModel()
+ if (!model) return
+ const projectModel = projectModels.has(model.uri.path)
+ currentFile.textContent = projectModel
+ ? relativeProjectPath(model.uri.path)
+ : model.uri.path.startsWith("/typescript/lib/")
+ ? `${model.uri.path.slice(model.uri.path.lastIndexOf("/") + 1)} (bundled)`
+ : model.uri.path
+ inputEditor.updateOptions({ readOnly: !projectModel })
+ editorHint.textContent =
+ model.getLanguageId() === "typescript"
+ ? "Type query: align ^? below an expression"
+ : model.getLanguageId() === "json"
+ ? "Edit compiler options directly"
+ : "Read-only library file"
+ for (const [fileName, button] of fileButtons) {
+ if (fileName === model.uri.path) button.setAttribute("aria-current", "page")
+ else button.removeAttribute("aria-current")
+ }
+ if (projectModel) persistProjectState()
+}
+
+function restoreLegacySelection() {
+ const params = new URLSearchParams(location.search)
+ const values = ["ssl", "ssc", "pln", "pc"].map(key => Number(params.get(key)))
+ if (values.some(value => !Number.isInteger(value) || value <= 0)) return
+ inputEditor.setSelection(new monaco.Selection(values[0], values[1], values[2], values[3]))
+}
+
+function restoreProjectSelection(state: ProjectState) {
+ const selection = state.selection
+ if (!selection) return
+ inputEditor.setSelection(
+ new monaco.Selection(
+ selection.selectionStartLineNumber,
+ selection.selectionStartColumn,
+ selection.positionLineNumber,
+ selection.positionColumn
+ )
+ )
+ inputEditor.revealRangeInCenter(inputEditor.getSelection()!, monaco.editor.ScrollType.Immediate)
+}
+
+function renderStatus() {
+ const failure = compilerFailure ?? lspFailure
+ if (failure) {
+ loader.hidden = false
+ loader.dataset.state = "error"
+ loadingMessage.textContent = failure
+ setStatus(failure, "error")
+ return
+ }
+ if (!compilerReady) {
+ loadingMessage.textContent = "Downloading TypeScript..."
+ setStatus("Loading compiler...", "loading")
+ return
+ }
+ if (!lspReady) {
+ loadingMessage.textContent = `Starting language server: ${lspStatus}`
+ setStatus(`LSP: ${lspStatus}`, "loading")
+ return
+ }
+
+ loader.hidden = true
+ loader.dataset.state = "ready"
+ if (projectFailure) {
+ setStatus(projectFailure, "error")
+ inputEditor.layout()
+ return
+ }
+ if (typeAcquisitionProgress) {
+ const { completedPackages, totalPackages, pendingPackages, downloaded, total } = typeAcquisitionProgress
+ if (totalPackages === 0) {
+ setStatus("Checking package imports...", "loading")
+ return
+ }
+ const packages = `${completedPackages}/${totalPackages} packages`
+ const files = total ? ` · ${downloaded}/${total} files` : ""
+ const pending = pendingPackages.length ? ` · ${pendingPackages.join(", ")}` : ""
+ setStatus(`Package types: ${packages}${files}${pending}`, "loading")
+ return
+ }
+ const compiler = lspServerInfo ?? __TS_VERSION__
+ const diagnostics = `${diagnosticCount} diagnostic${diagnosticCount === 1 ? "" : "s"}`
+ setStatus(
+ `${compiler} ready · ${diagnostics}${typeAcquisitionFailure ? ` · package types: ${typeAcquisitionFailure}` : ""}`,
+ typeAcquisitionFailure ? "error" : "ready"
+ )
+ inputEditor.layout()
+}
+
+function setStatus(message: string, state: "loading" | "ready" | "error") {
+ status.textContent = message
+ status.title = message
+ status.dataset.state = state
+}
+
+function registerProjectModel(model: monaco.editor.ITextModel) {
+ model.onDidChangeContent(() => {
+ nativeCompileVersion++
+ stradaCompileVersion++
+ scheduleProjectPersistence()
+ refreshCompilerOverrides()
+ scheduleTypeAcquisition()
+ window.clearTimeout(updateTimer)
+ updateTimer = window.setTimeout(() => {
+ void compileActiveProject?.()
+ }, 220)
+ })
+}
+
+function createNewFile() {
+ newFilePath.value = "src/new-file.ts"
+ newFileError.hidden = true
+ newFileError.textContent = ""
+ newFileDialog.showModal()
+ newFilePath.focus()
+ newFilePath.select()
+}
+
+function finishCreatingFile() {
+ const relativePath = newFilePath.value.trim().replaceAll("\\", "/").replace(/^\/+/, "")
+ const parts = relativePath.split("/")
+ if (relativePath === "" || parts.some(part => part === "" || part === "." || part === "..")) {
+ showNewFileError("Enter a file path inside /workspace.")
+ return
+ }
+
+ const fileName = `${projectRoot}/${relativePath}`
+ if (projectModels.has(fileName)) {
+ showNewFileError(`${relativePath} already exists.`)
+ return
+ }
+
+ newFileDialog.close()
+ const model = monaco.editor.createModel("", languageForFile(fileName), monaco.Uri.parse(`file://${fileName}`))
+ projectModels.set(fileName, model)
+ registerProjectModel(model)
+ refreshCompilerOverrides()
+ renderFileList()
+ inputEditor.setModel(model)
+ inputEditor.focus()
+ persistProjectState()
+ void compileActiveProject?.()
+}
+
+function showNewFileError(message: string) {
+ newFileError.textContent = message
+ newFileError.hidden = false
+ newFilePath.focus()
+}
+
+async function deleteProjectFile(fileName: string) {
+ const model = projectModels.get(fileName)
+ if (!model) return
+ if (projectModels.size === 1) {
+ await requestConfirmation({
+ cancelLabel: null,
+ confirmLabel: "OK",
+ message: "The project must contain at least one file.",
+ title: "Cannot delete file",
+ })
+ return
+ }
+
+ const relativePath = relativeProjectPath(fileName)
+ const confirmed = await requestConfirmation({
+ confirmLabel: "Delete file",
+ danger: true,
+ message: `Delete ${relativePath}? This cannot be undone.`,
+ title: "Delete file",
+ })
+ if (!confirmed) return
+
+ const deletedUri = model.uri.toString()
+ projectModels.delete(fileName)
+ refreshCompilerOverrides()
+ if (inputEditor.getModel() === model) {
+ const remainingFiles = [...projectModels.keys()]
+ const fallbackFile =
+ remainingFiles.find(candidate => candidate === entryFileName) ??
+ remainingFiles.find(candidate => /\.[cm]?[jt]sx?$/i.test(candidate)) ??
+ remainingFiles[0]
+ inputEditor.setModel(projectModels.get(fallbackFile)!)
+ }
+ model.dispose()
+ if (projectReturnLocation?.uri === deletedUri) projectReturnLocation = undefined
+ trackedEditorLocation = getEditorLocation()
+ renderFileList()
+ persistProjectState()
+ location.reload()
+}
+
+async function resetProject() {
+ const confirmed = await requestConfirmation({
+ confirmLabel: "Reset project",
+ danger: true,
+ message: "Reset the project to the TypeScript Playground v2 defaults? All current project files will be replaced.",
+ title: "Reset project",
+ })
+ if (!confirmed) return
+ projectPersistenceEnabled = false
+ window.clearTimeout(projectPersistTimer)
+ localStorage.removeItem(storageKey)
+ const url = new URL(location.href)
+ url.hash = ""
+ location.replace(url)
+}
+
+function loadProjectState(): ProjectState {
+ try {
+ const pendingReload = sessionStorage.getItem(pendingReloadKey)
+ sessionStorage.removeItem(pendingReloadKey)
+ const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined
+ if (navigation?.type === "reload" && pendingReload === location.href) {
+ const saved = localStorage.getItem(storageKey)
+ if (saved) return normalizeVersionedProjectState(JSON.parse(saved))
+ }
+ } catch (error) {
+ console.warn("Could not restore pending playground edits after reload", error)
+ }
+ if (initialLegacyExample) {
+ return createExampleProjectState(initialLegacyExample)
+ }
+
+ if (location.hash.startsWith(projectHashPrefix)) {
+ const decoded = decodeCompressedHash(location.hash.slice(projectHashPrefix.length))
+ if (decoded) {
+ try {
+ return normalizeVersionedProjectState(JSON.parse(decoded))
+ } catch (error) {
+ console.warn("Could not restore the versioned playground project", error)
+ return { files: {}, useDefaults: true }
+ }
+ }
+ }
+
+ if (location.hash.startsWith("#src=")) {
+ try {
+ return createLegacyProjectState(decodeURIComponent(location.hash.slice("#src=".length)))
+ } catch (error) {
+ console.warn("Could not decode the legacy playground source", error)
+ }
+ }
+
+ if (location.hash.startsWith(legacyCodeHashPrefix)) {
+ const decoded = decodeCompressedHash(location.hash.slice(legacyCodeHashPrefix.length))
+ if (decoded) {
+ try {
+ return normalizeProjectState(JSON.parse(decoded))
+ } catch {
+ return createLegacyProjectState(decoded)
+ }
+ }
+ }
+
+ const stored = localStorage.getItem(storageKey)
+ if (!stored) return { files: {}, useDefaults: true }
+ try {
+ return normalizeProjectState(JSON.parse(stored))
+ } catch (error) {
+ console.warn("Could not restore the TypeScript Playground v2 project", error)
+ return { files: {}, useDefaults: true }
+ }
+}
+
+function legacyExampleFromHash(hash: string) {
+ if (!hash.startsWith("#example/")) return undefined
+ const id = decodeURIComponent(hash.slice("#example/".length))
+ return bundledExamples.examples.find(example => example.id === id)
+}
+
+function decodeCompressedHash(encoded: string) {
+ const decoded = LZString.decompressFromEncodedURIComponent(encoded)
+ if (decoded) return decoded
+ try {
+ return LZString.decompressFromEncodedURIComponent(decodeURIComponent(encoded))
+ } catch {
+ return null
+ }
+}
+
+function serializeProjectState(state: ProjectState) {
+ const versioned: VersionedProjectState = {
+ activeFile: state.activeFile,
+ files: state.files,
+ selection: state.selection,
+ version: projectStateVersion,
+ }
+ return JSON.stringify(versioned)
+}
+
+function normalizeVersionedProjectState(value: unknown) {
+ if (!value || typeof value !== "object" || (value as { version?: unknown }).version !== projectStateVersion) {
+ throw new Error("Unsupported playground project URL version")
+ }
+ return normalizeProjectState(value)
+}
+
+function normalizeProjectState(value: unknown): ProjectState {
+ if (!value || typeof value !== "object") return { files: {}, useDefaults: true }
+ const candidate = value as { activeFile?: unknown; files?: unknown; selection?: unknown; version?: unknown }
+ if (candidate.version !== undefined && candidate.version !== projectStateVersion) {
+ console.warn(`Ignoring unsupported playground project version: ${String(candidate.version)}`)
+ return { files: {}, useDefaults: true }
+ }
+ const filesValue = candidate.files && typeof candidate.files === "object" ? candidate.files : value
+ const files = Object.fromEntries(
+ Object.entries(filesValue).filter(
+ (entry): entry is [string, string] => entry[0].startsWith(`${projectRoot}/`) && typeof entry[1] === "string"
+ )
+ )
+ const migrations = new Map([
+ [`${projectRoot}/index.ts`, entryFileName],
+ [`${projectRoot}/greet.ts`, `${projectRoot}/src/greet.ts`],
+ ])
+ const hasLegacyRootFiles = [...migrations.keys()].some(fileName => files[fileName] !== undefined)
+ for (const [oldPath, newPath] of migrations) {
+ if (files[oldPath] !== undefined && files[newPath] === undefined) {
+ files[newPath] = files[oldPath]
+ }
+ delete files[oldPath]
+ }
+ if (hasLegacyRootFiles && files[configFileName]) {
+ try {
+ const config = JSON.parse(files[configFileName])
+ if (Array.isArray(config.include) && config.include.length === 1 && config.include[0] === "./*.ts") {
+ config.include = ["./src/**/*"]
+ }
+ config.compilerOptions ??= {}
+ config.compilerOptions.declaration ??= true
+ files[configFileName] = `${JSON.stringify(config, undefined, 2)}\n`
+ } catch {
+ files[configFileName] = files[configFileName].replace(
+ /"include"\s*:\s*\[\s*"\.\/\*\.ts"\s*\]/,
+ '"include": ["./src/**/*"]'
+ )
+ }
+ }
+ const requestedActiveFile =
+ typeof candidate.activeFile === "string" ? migrations.get(candidate.activeFile) ?? candidate.activeFile : undefined
+ const selection = normalizeSelection(candidate.selection)
+ return {
+ activeFile: requestedActiveFile?.startsWith(`${projectRoot}/`) ? requestedActiveFile : undefined,
+ files,
+ selection,
+ useDefaults: false,
+ }
+}
+
+function normalizeSelection(value: unknown): ProjectState["selection"] {
+ if (!value || typeof value !== "object") return undefined
+ const candidate = value as Record
+ const keys = ["positionColumn", "positionLineNumber", "selectionStartColumn", "selectionStartLineNumber"] as const
+ if (keys.some(key => !Number.isInteger(candidate[key]) || Number(candidate[key]) < 1)) return undefined
+ return {
+ positionColumn: Number(candidate.positionColumn),
+ positionLineNumber: Number(candidate.positionLineNumber),
+ selectionStartColumn: Number(candidate.selectionStartColumn),
+ selectionStartLineNumber: Number(candidate.selectionStartLineNumber),
+ }
+}
+
+function createLegacyProjectState(code: string, fileType = getLegacyFileType()): ProjectState {
+ const fileNamePattern = /^\s*\/\/\s*@filename:\s*(.+)$/i
+ if (!code.split(/\r\n?|\n/g).some(line => fileNamePattern.test(line))) {
+ const fileName = `${projectRoot}/src/index.${fileType}`
+ return {
+ activeFile: fileName,
+ files: {
+ [configFileName]: defaultFiles[0].text,
+ [fileName]: code,
+ },
+ useDefaults: false,
+ }
+ }
+
+ const fourSlashStyle = /^\s*\/\/\s*@Filename:/m.test(code)
+ const files: Record = {
+ [configFileName]: defaultFiles[0].text,
+ }
+ let currentFile = `src/index.${fileType}`
+ let currentLines: string[] = []
+ let hasFileDirective = false
+ const flush = (allowEmpty = false) => {
+ if (currentLines.length === 0 && !allowEmpty) return
+ const relativePath = sanitizeLegacyPath(currentFile) || `src/index.${fileType}`
+ const fileName = `${projectRoot}/${relativePath}`
+ files[fileName] = currentLines.join("\n")
+ }
+ for (const line of code.split(/\r\n?|\n/g)) {
+ const match = fileNamePattern.exec(line)
+ if (match) {
+ flush(hasFileDirective)
+ currentFile = match[1].trim()
+ currentLines = []
+ hasFileDirective = true
+ } else {
+ currentLines.push(fourSlashStyle ? line.replace(/^(\s*)\/\/\/\//, "$1") : line)
+ }
+ }
+ flush(hasFileDirective)
+ const sourceFiles = Object.keys(files).filter(fileName => fileName !== configFileName)
+ return {
+ activeFile: sourceFiles[0] ?? entryFileName,
+ files,
+ useDefaults: false,
+ }
+}
+
+function sanitizeLegacyPath(fileName: string) {
+ const parts = fileName.replaceAll("\\", "/").replace(/^\/+/, "").split("/")
+ return parts.filter(part => part !== "" && part !== "." && part !== "..").join("/")
+}
+
+function getLegacyFileType() {
+ const params = new URLSearchParams(location.search)
+ if (params.has("useJavaScript")) return "js"
+ const fileType = params.get("filetype")
+ return fileType && (fileType === "d.ts" || /^[cm]?[jt]sx?$/.test(fileType)) ? fileType : "ts"
+}
+
+function applyLegacyCompilerOptions(files: Record) {
+ const params = new URLSearchParams(location.search)
+ const reserved = new Set(["example", "filetype", "pc", "pln", "ssc", "ssl", "ts", "useJavaScript"])
+ let config: any
+ try {
+ config = JSON.parse(files[configFileName] ?? defaultFiles[0].text)
+ } catch {
+ config = JSON.parse(defaultFiles[0].text)
+ }
+ config.compilerOptions ??= {}
+ for (const [key, rawValue] of params) {
+ if (reserved.has(key)) continue
+ const value = parseLegacyCompilerOption(key, rawValue)
+ if (value !== undefined) config.compilerOptions[key] = value
+ }
+ if (params.has("useJavaScript") || params.get("filetype")?.startsWith("js")) {
+ config.compilerOptions.allowJs = true
+ config.compilerOptions.checkJs = true
+ }
+ files[configFileName] = `${JSON.stringify(config, undefined, 2)}\n`
+}
+
+function parseLegacyCompilerOption(key: string, rawValue: string) {
+ if (rawValue === "true") return true
+ if (rawValue === "false") return false
+ const number = Number(rawValue)
+ if (!Number.isFinite(number)) return rawValue
+ const enumMaps: Record> = {
+ jsx: {
+ 0: "preserve",
+ 1: "react",
+ 2: "react-native",
+ 3: "react-jsx",
+ 4: "react-jsxdev",
+ },
+ module: {
+ 0: "none",
+ 1: "commonjs",
+ 2: "amd",
+ 3: "umd",
+ 4: "system",
+ 5: "es2015",
+ 6: "es2020",
+ 7: "es2022",
+ 99: "esnext",
+ 100: "node16",
+ 199: "nodenext",
+ 200: "preserve",
+ },
+ moduleResolution: {
+ 1: "classic",
+ 2: "node",
+ 3: "node16",
+ 99: "nodenext",
+ 100: "bundler",
+ },
+ newLine: {
+ 0: "crlf",
+ 1: "lf",
+ },
+ target: {
+ 0: "es3",
+ 1: "es5",
+ 2: "es2015",
+ 3: "es2016",
+ 4: "es2017",
+ 5: "es2018",
+ 6: "es2019",
+ 7: "es2020",
+ 8: "es2021",
+ 9: "es2022",
+ 10: "es2023",
+ 11: "es2024",
+ 12: "es2025",
+ 99: "esnext",
+ },
+ }
+ return enumMaps[key]?.[number] ?? number
+}
+
+function scheduleProjectPersistence() {
+ window.clearTimeout(projectPersistTimer)
+ window.addEventListener("beforeunload", flushProjectBeforeUnload)
+ projectPersistTimer = window.setTimeout(persistProjectState, 250)
+}
+
+function flushProjectBeforeUnload() {
+ const previousUrl = location.href
+ persistProjectState()
+ if (!projectPersistenceEnabled) return
+ try {
+ // Reload can capture the old hash before the pending save updates it.
+ sessionStorage.setItem(pendingReloadKey, previousUrl)
+ } catch (error) {
+ console.warn("Could not preserve pending playground edits for reload", error)
+ }
+}
+
+function persistProjectState() {
+ window.clearTimeout(projectPersistTimer)
+ window.removeEventListener("beforeunload", flushProjectBeforeUnload)
+ if (!projectPersistenceEnabled) return
+ const activeModel = inputEditor.getModel()
+ const selection = activeModel && projectModels.has(activeModel.uri.path) ? inputEditor.getSelection() : undefined
+ const state: ProjectState = {
+ activeFile: activeModel && projectModels.has(activeModel.uri.path) ? activeModel.uri.path : entryFileName,
+ files: Object.fromEntries([...projectModels].map(([fileName, model]) => [fileName, model.getValue()])),
+ selection: selection
+ ? {
+ positionColumn: selection.positionColumn,
+ positionLineNumber: selection.positionLineNumber,
+ selectionStartColumn: selection.selectionStartColumn,
+ selectionStartLineNumber: selection.selectionStartLineNumber,
+ }
+ : undefined,
+ }
+ try {
+ const serialized = serializeProjectState(state)
+ if (serialized !== lastStoredProject) {
+ localStorage.setItem(storageKey, serialized)
+ lastStoredProject = serialized
+ }
+ if (!playgroundSettings.saveToUrl) return
+ if (serialized === lastUrlProject) return
+ const url = new URL(location.href)
+ url.hash = `${projectHashPrefix.slice(1)}${LZString.compressToEncodedURIComponent(serialized)}`
+ history.replaceState({}, "", url)
+ lastUrlProject = serialized
+ } catch (error) {
+ console.warn("Could not save the TypeScript Playground v2 project", error)
+ }
+}
+
+function languageForFile(fileName: string): ProjectFile["language"] {
+ if (fileName.endsWith(".json")) return "json"
+ if (/\.[cm]?jsx?$/i.test(fileName)) return "javascript"
+ return "typescript"
+}
+
+function fileKind(fileName: string) {
+ const language = languageForFile(fileName)
+ return language === "json" ? "{}" : language === "javascript" ? "JS" : "TS"
+}
+
+function relativeProjectPath(fileName: string) {
+ return fileName.startsWith(`${projectRoot}/`) ? fileName.slice(projectRoot.length + 1) : fileName
+}
+
+function truncate(value: string, maxLength: number) {
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`
+}
+
+function createText(tagName: K, text: string, className?: string) {
+ const element = document.createElement(tagName)
+ element.textContent = text
+ if (className) element.className = className
+ return element
+}
+
+function getElement(id: string) {
+ const element = document.getElementById(id)
+ if (!element) throw new Error(`Missing #${id}`)
+ return element as T
+}
diff --git a/packages/playground-v2/src/native-compiler.test.ts b/packages/playground-v2/src/native-compiler.test.ts
new file mode 100644
index 000000000000..e8f7a6addaaf
--- /dev/null
+++ b/packages/playground-v2/src/native-compiler.test.ts
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict"
+import { readFile, readdir } from "node:fs/promises"
+import { test } from "node:test"
+import { API } from "@typescript/typescript/unstable/sync"
+import { instantiateWasm, WasmTransport } from "@typescript/typescript-wasip1-wasm"
+import { NativeCompiler, type NativeCompileInput } from "./native-compiler"
+
+test("native compiler reuses snapshots across edits, types, and configuration changes", async () => {
+ const directory = process.env.PLAYGROUND_DIR!
+ const module = await WebAssembly.compile(await readFile(`${directory}/vendor/typescript-wasip1-wasm/lib/tsc.wasm`))
+ const transport = new WasmTransport({ instance: await instantiateWasm(module), cwd: "/workspace" })
+ for (const name of await readdir(`${directory}/vendor/lib`)) {
+ if (/^lib(?:\..+)?\.d\.ts$/.test(name)) transport.setFile(`/${name}`, await readFile(`${directory}/vendor/lib/${name}`, "utf8"))
+ }
+ const api = new API({ transport })
+ const createProgram = api.createProgram
+ const readConfigFile = api.readConfigFile
+ const parseConfig = api.parseJsonConfigFileContent
+ let syntheticPrograms = 0
+ let configReads = 0
+ let configParses = 0
+ Object.defineProperties(api, {
+ createProgram: {
+ value: (...args: Parameters) => {
+ syntheticPrograms++
+ return createProgram(...args)
+ },
+ },
+ readConfigFile: {
+ value: (...args: Parameters) => {
+ configReads++
+ return readConfigFile(...args)
+ },
+ },
+ parseJsonConfigFileContent: {
+ value: (...args: Parameters) => {
+ configParses++
+ return parseConfig(...args)
+ },
+ },
+ })
+ const compiler = new NativeCompiler(api, transport)
+ const configFileName = "/workspace/tsconfig.json"
+ const source = "/workspace/src/index.ts"
+ const config = { compilerOptions: { strict: true, target: "es2022", module: "commonjs", declaration: true }, include: ["src/**/*"] }
+ const input: NativeCompileInput = {
+ configFileName,
+ sourceFiles: [source],
+ files: {
+ [configFileName]: JSON.stringify(config),
+ [source]: "const answer = 42;\n// ^?\n",
+ },
+ }
+ try {
+ const first = compiler.compile(input)
+ assert.equal(first.diagnostics.length, 0)
+ assert.match(first.outputFiles["/workspace/src/index.js"], /42/)
+ assert(first.typeQueries[source].some(query => query.label.includes("42")))
+
+ input.files[source] = 'const answer: number = "wrong";\n// ^?\n'
+ const changed = compiler.compile(input)
+ assert(changed.diagnostics.some(diagnostic => diagnostic.code === 2322))
+ assert.match(changed.outputFiles["/workspace/src/index.js"], /wrong/)
+ assert(changed.typeQueries[source].some(query => query.label === ": number"))
+
+ input.files[source] = 'import { value } from "pkg";\nconst answer: string = value;'
+ assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 2307))
+ input.files["/workspace/node_modules/pkg/package.json"] = '{"name":"pkg","types":"index.d.ts"}'
+ input.files["/workspace/node_modules/pkg/index.d.ts"] = "export const value: string;"
+ assert.equal(compiler.compile(input).diagnostics.length, 0)
+ delete input.files["/workspace/node_modules/pkg/package.json"]
+ delete input.files["/workspace/node_modules/pkg/index.d.ts"]
+ assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 2307))
+
+ input.files[source] = "const answer = 1;"
+ input.files[configFileName] = JSON.stringify({ ...config, compilerOptions: { ...config.compilerOptions, noEmit: true } })
+ assert.deepEqual(compiler.compile(input).outputFiles, {})
+ input.files[configFileName] = JSON.stringify(config)
+ assert.match(compiler.compile(input).outputFiles["/workspace/src/index.js"], /1/)
+
+ const unopened = "/workspace/src/unopened.ts"
+ input.files[unopened] = 'export const unopenedValue: number = "wrong";'
+ const wholeProject = compiler.compile(input)
+ assert(wholeProject.diagnostics.some(diagnostic => diagnostic.code === 2322 && diagnostic.fileName === unopened))
+ delete input.files[unopened]
+ assert.equal(compiler.compile(input).diagnostics.length, 0)
+
+ input.files[configFileName] = JSON.stringify({
+ ...config,
+ compilerOptions: { ...config.compilerOptions, unknownCompilerOption: true },
+ })
+ assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 5023))
+ input.files[configFileName] = '{"compilerOptions": { "strict": true ';
+ assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.fileName === configFileName))
+ input.files[configFileName] = JSON.stringify(config)
+ assert.equal(compiler.compile(input).diagnostics.length, 0)
+
+ assert.equal(configReads, 0)
+ assert.equal(configParses, 0)
+ const orphan = "/workspace/orphan.ts"
+ input.sourceFiles = [source, orphan]
+ input.files[orphan] = "const orphanValue = true;"
+ assert.equal(compiler.compile(input).typeQueries[orphan], undefined)
+ assert.equal(syntheticPrograms, 0)
+
+ const orphanHelper = "/workspace/orphan-helper.ts"
+ input.files[orphanHelper] = "declare const orphanSource: true;"
+ input.sourceFiles = [source, orphan, orphanHelper]
+ input.files[orphan] = "const orphanValue = orphanSource;\n// ^?\n"
+ assert(compiler.compile(input).typeQueries[orphan].some(query => query.label.includes("true")))
+ assert.equal(syntheticPrograms, 1)
+ } finally {
+ compiler.close()
+ }
+})
diff --git a/packages/playground-v2/src/native-compiler.ts b/packages/playground-v2/src/native-compiler.ts
new file mode 100644
index 000000000000..591ed182e640
--- /dev/null
+++ b/packages/playground-v2/src/native-compiler.ts
@@ -0,0 +1,171 @@
+import { API, type Diagnostic, type FileNotifications, type Snapshot } from "@typescript/typescript/unstable/sync"
+import type { Node } from "@typescript/typescript/unstable/ast"
+import type { WasmTransport } from "@typescript/typescript-wasip1-wasm"
+import { TextDocument } from "vscode-languageserver-textdocument"
+import { corsaDiscovery, type Dependency, type SourceFile } from "./ata"
+
+export type NativeCompileInput = {
+ files: Record
+ configFileName: string
+ sourceFiles: readonly string[]
+}
+
+export type NativeTypeQuery = {
+ lineNumber: number
+ column: number
+ label: string
+}
+
+export type NativeCompileResult = {
+ diagnostics: readonly Diagnostic[]
+ outputFiles: Record
+ typeQueries: Record
+}
+
+export class NativeCompiler {
+ private snapshot: Snapshot | undefined
+ private readonly files = new Map()
+ private readonly discoverDependencies: ReturnType
+
+ constructor(private readonly api: API, private readonly transport: WasmTransport) {
+ this.discoverDependencies = corsaDiscovery(api)
+ }
+
+ discover(file: SourceFile): readonly Dependency[] {
+ return this.discoverDependencies(file)
+ }
+
+ compile(input: NativeCompileInput): NativeCompileResult {
+ const changed: string[] = []
+ const created: string[] = []
+ const deleted: string[] = []
+ for (const [fileName, text] of Object.entries(input.files)) {
+ if (this.files.get(fileName) === text) continue
+ this.transport.setFile(fileName, text)
+ if (this.files.has(fileName)) changed.push(fileName)
+ else created.push(fileName)
+ }
+ for (const fileName of this.files.keys()) {
+ if (fileName in input.files) continue
+ this.transport.removeFile(fileName)
+ deleted.push(fileName)
+ }
+ const notifications: FileNotifications = { changed, created, deleted }
+ const next = this.snapshot
+ ? this.snapshot.update({
+ ensurePrograms: true,
+ fileNotifications: notifications,
+ fileSystem: {
+ kind: "layer",
+ files: Object.fromEntries([...changed, ...created].map(fileName => [fileName, input.files[fileName]])),
+ removedPaths: deleted,
+ },
+ })
+ : this.api.createSnapshot({
+ openProjects: [input.configFileName],
+ fileSystem: { kind: "layer", files: input.files },
+ })
+ const previous = this.snapshot
+ this.snapshot = next
+ if (previous && previous !== next) previous.dispose()
+ this.files.clear()
+ for (const [fileName, text] of Object.entries(input.files)) this.files.set(fileName, text)
+
+ const project = next.getConfiguredProject(input.configFileName)
+ const config = project ? undefined : this.api.readConfigFile(input.configFileName)
+ const parsed =
+ project?.parsedCommandLine ??
+ this.api.parseJsonConfigFileContent(config?.config, { configFileName: input.configFileName })
+ const configuredProgram = project && next.getProgram(project.id)
+ const program = configuredProgram ?? this.api.createProgram(parsed.fileNames, parsed.options, {
+ projectReferences: parsed.projectReferences,
+ configFileParsingDiagnostics: parsed.errors,
+ })
+ try {
+ const emit = program.emitToString()
+ const diagnostics = [
+ ...(config?.error ? [config.error] : []),
+ ...parsed.errors,
+ ...program.getSyntacticDiagnostics(),
+ ...program.getSemanticDiagnostics(),
+ ...program.getConfigFileParsingDiagnostics(),
+ ...emit.diagnostics,
+ ]
+ const typeQueries: Record = {}
+ this.collectTypeQueries(program, parsed.fileNames, input.files, typeQueries)
+ const configuredFiles = new Set(parsed.fileNames)
+ const orphanFiles = input.sourceFiles.filter(fileName => !configuredFiles.has(fileName))
+ if (orphanFiles.some(fileName => hasTypeQuery(input.files[fileName]))) {
+ const inferred = this.api.createProgram(orphanFiles, { ...parsed.options, allowJs: true })
+ try {
+ this.collectTypeQueries(inferred, orphanFiles, input.files, typeQueries)
+ } finally {
+ inferred.dispose()
+ }
+ }
+ return {
+ diagnostics,
+ outputFiles: Object.fromEntries([...emit.outputFiles].map(([fileName, output]) => [fileName, output.text])),
+ typeQueries,
+ }
+ } finally {
+ if (!configuredProgram) program.dispose()
+ }
+ }
+
+ close() {
+ this.snapshot?.dispose()
+ this.snapshot = undefined
+ this.api.close()
+ }
+
+ private collectTypeQueries(
+ program: ReturnType,
+ fileNames: readonly string[],
+ files: Record,
+ result: Record
+ ) {
+ for (const fileName of fileNames) {
+ const source = files[fileName]
+ if (!hasTypeQuery(source)) continue
+ const sourceFile = program.getSourceFile(fileName)
+ if (!sourceFile) continue
+ const document = TextDocument.create(fileName, "typescript", 0, source)
+ const queries: NativeTypeQuery[] = []
+ const pattern = /^\s*\/\/\s*\^\?\s*$/gm
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(source))) {
+ const position = document.positionAt(match.index + match[0].lastIndexOf("?"))
+ if (position.line === 0) continue
+ const offset = document.offsetAt({ line: position.line - 1, character: position.character })
+ const node = findNodeAtPosition(sourceFile, offset) ?? findNodeAtPosition(sourceFile, Math.max(0, offset - 1))
+ if (!node) continue
+ const checker = program.getProject().checker
+ const typeText = checker.typeToString(checker.getTypeAtLocation(node), node).replace(/\r?\n\s*/g, " ")
+ const label = `: ${typeText}`
+ queries.push({
+ lineNumber: position.line + 1,
+ column: position.character + 2,
+ label: label.length > 120 ? `${label.slice(0, 119)}…` : label,
+ })
+ }
+ result[fileName] = queries
+ }
+ }
+}
+
+function hasTypeQuery(source: string | undefined): source is string {
+ return source !== undefined && /^\s*\/\/\s*\^\?\s*$/m.test(source)
+}
+
+function findNodeAtPosition(node: Node, position: number): Node | undefined {
+ if (position < node.getFullStart() || position > node.getEnd()) return undefined
+ let match: Node | undefined
+ node.forEachChild(child => {
+ const descendant = findNodeAtPosition(child, position)
+ if (!descendant) return undefined
+ match = descendant
+ return true
+ })
+ return match ?? node
+}
diff --git a/packages/playground-v2/src/native.ts b/packages/playground-v2/src/native.ts
new file mode 100644
index 000000000000..6c3ed36b25e6
--- /dev/null
+++ b/packages/playground-v2/src/native.ts
@@ -0,0 +1,101 @@
+import type { Dependency, SourceFile } from "./ata"
+import type { NativeCompileInput, NativeCompileResult } from "./native-compiler"
+
+type PendingRequest = {
+ resolve(value: unknown): void
+ reject(error: Error): void
+}
+
+type QueuedCompile = {
+ input: NativeCompileInput
+ waiters: Array<{ resolve(value: NativeCompileResult): void; reject(error: Error): void }>
+}
+
+export class NativeBackend {
+ private readonly worker = new Worker(new URL("./native.worker.js", import.meta.url), { type: "module" })
+ private readonly pending = new Map()
+ private nextId = 0
+ private compiling = false
+ private queuedCompile: QueuedCompile | undefined
+ private failure: Error | undefined
+
+ private constructor() {
+ this.worker.addEventListener("message", (event: MessageEvent<{ id: number; result?: unknown; error?: string }>) => {
+ const pending = this.pending.get(event.data.id)
+ if (!pending) return
+ this.pending.delete(event.data.id)
+ if (event.data.error !== undefined) pending.reject(new Error(event.data.error))
+ else pending.resolve(event.data.result)
+ })
+ this.worker.addEventListener("error", event => {
+ this.failure = new Error(event.message || "Native compiler worker failed")
+ for (const request of this.pending.values()) request.reject(this.failure)
+ this.pending.clear()
+ })
+ this.worker.addEventListener("messageerror", () => {
+ this.failure = new Error("Could not decode the native compiler worker response")
+ for (const request of this.pending.values()) request.reject(this.failure)
+ this.pending.clear()
+ })
+ }
+
+ static async create(module: WebAssembly.Module, libraries: Record) {
+ const backend = new NativeBackend()
+ try {
+ await backend.request("init", { module, libraries })
+ return backend
+ } catch (error) {
+ backend.worker.terminate()
+ throw error
+ }
+ }
+
+ discover(file: SourceFile): Promise {
+ return this.request("discover", file)
+ }
+
+ compile(input: NativeCompileInput): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.queuedCompile) {
+ this.queuedCompile.input = input
+ this.queuedCompile.waiters.push({ resolve, reject })
+ } else {
+ this.queuedCompile = { input, waiters: [{ resolve, reject }] }
+ }
+ if (!this.compiling) void this.runCompiles()
+ })
+ }
+
+ private async runCompiles() {
+ this.compiling = true
+ try {
+ while (this.queuedCompile) {
+ const compile = this.queuedCompile
+ this.queuedCompile = undefined
+ try {
+ const result = await this.request("compile", compile.input)
+ for (const waiter of compile.waiters) waiter.resolve(result)
+ } catch (error) {
+ const failure = error instanceof Error ? error : new Error(String(error))
+ for (const waiter of compile.waiters) waiter.reject(failure)
+ }
+ }
+ } finally {
+ this.compiling = false
+ }
+ }
+
+ private request(method: string, args: unknown): Promise {
+ if (this.failure) return Promise.reject(this.failure)
+ const id = ++this.nextId
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve: value => resolve(value as T), reject })
+ try {
+ this.worker.postMessage({ id, method, args })
+ } catch (error) {
+ this.pending.delete(id)
+ reject(error)
+ }
+ })
+ }
+}
diff --git a/packages/playground-v2/src/native.worker.ts b/packages/playground-v2/src/native.worker.ts
new file mode 100644
index 000000000000..61ff377af823
--- /dev/null
+++ b/packages/playground-v2/src/native.worker.ts
@@ -0,0 +1,32 @@
+import { API } from "@typescript/typescript/unstable/sync"
+import { instantiateWasm, WasmTransport } from "@typescript/typescript-wasip1-wasm"
+import type { SourceFile } from "./ata"
+import { NativeCompiler, type NativeCompileInput } from "./native-compiler"
+
+type Message =
+ | { id: number; method: "init"; args: { module: WebAssembly.Module; libraries: Record } }
+ | { id: number; method: "compile"; args: NativeCompileInput }
+ | { id: number; method: "discover"; args: SourceFile }
+
+let compiler: NativeCompiler | undefined
+
+self.addEventListener("message", (event: MessageEvent) => {
+ void handle(event.data)
+})
+
+async function handle(message: Message) {
+ try {
+ let result: unknown
+ if (message.method === "init") {
+ const transport = new WasmTransport({ instance: await instantiateWasm(message.args.module), cwd: "/workspace" })
+ for (const [fileName, text] of Object.entries(message.args.libraries)) transport.setFile(fileName, text)
+ compiler = new NativeCompiler(new API({ transport }), transport)
+ } else {
+ if (!compiler) throw new Error("Native compiler worker is not initialized")
+ result = message.method === "compile" ? compiler.compile(message.args) : compiler.discover(message.args)
+ }
+ self.postMessage({ id: message.id, result })
+ } catch (error) {
+ self.postMessage({ id: message.id, error: error instanceof Error ? error.stack ?? error.message : String(error) })
+ }
+}
diff --git a/packages/playground-v2/src/strada.ts b/packages/playground-v2/src/strada.ts
new file mode 100644
index 000000000000..41eca2c8344c
--- /dev/null
+++ b/packages/playground-v2/src/strada.ts
@@ -0,0 +1,634 @@
+import { monaco } from "./tsgo-lsp"
+
+type CompilerDiagnostic = {
+ category: number
+ code: number
+ end: number
+ fileName?: string
+ pos: number
+ text: string
+}
+
+type CompileResult = {
+ diagnostics: CompilerDiagnostic[]
+ emitSkipped: boolean
+ outputFiles: Record
+ rootNames: string[]
+}
+
+type StradaBackendOptions = {
+ compilerSource: string
+ editor: monaco.editor.IStandaloneCodeEditor
+ files(): Record
+ models: Map
+ onNavigate(fileName: string, range: monaco.Range): void
+ version: string
+ baseUrl: string
+}
+
+export class StradaBackend {
+ readonly version: string
+ readonly #worker = new Worker(new URL("./strada.worker.js", import.meta.url), {
+ type: "module",
+ })
+ readonly #pending = new Map<
+ number,
+ {
+ resolve(value: any): void
+ reject(error: Error): void
+ }
+ >()
+ readonly #disposables: monaco.IDisposable[] = []
+ readonly #options: StradaBackendOptions
+ #nextId = 1
+
+ private constructor(options: StradaBackendOptions) {
+ this.#options = options
+ this.version = options.version
+ this.#worker.addEventListener("message", event => {
+ const pending = this.#pending.get(event.data.id)
+ if (!pending) return
+ this.#pending.delete(event.data.id)
+ if (event.data.error) pending.reject(new Error(event.data.error))
+ else pending.resolve(event.data.result)
+ })
+ }
+
+ static async create(options: StradaBackendOptions) {
+ const backend = new StradaBackend(options)
+ await backend.#request("init", {
+ baseUrl: options.baseUrl,
+ compilerSource: options.compilerSource,
+ files: options.files(),
+ version: options.version,
+ })
+ backend.#registerLanguageFeatures()
+ return backend
+ }
+
+ async compile() {
+ await this.updateFiles()
+ return this.#request("compile", {})
+ }
+
+ async updateFiles() {
+ await this.#request("updateFiles", {
+ files: this.#options.files(),
+ })
+ }
+
+ async quickInfo(fileName: string, position: number) {
+ return this.#request("quickInfo", { fileName, position })
+ }
+
+ async readFile(fileName: string) {
+ return this.#request("readFile", { fileName })
+ }
+
+ async goToDefinition(model: monaco.editor.ITextModel, position: monaco.Position) {
+ await this.updateFiles()
+ const result = await this.#request("definitions", {
+ fileName: model.uri.path,
+ position: model.getOffsetAt(position),
+ })
+ const definition = result?.definitions?.[0]
+ if (!definition) return
+ const target = await this.#ensureModel(definition.fileName)
+ if (!target) return
+ this.#options.onNavigate(target.uri.path, spanToRange(target, definition.textSpan))
+ }
+
+ async #formatOptions(model: monaco.editor.ITextModel) {
+ const options = model.getOptions()
+ return {
+ ConvertTabsToSpaces: options.insertSpaces,
+ IndentSize: options.indentSize,
+ IndentStyle: 2,
+ InsertSpaceAfterCommaDelimiter: true,
+ InsertSpaceAfterFunctionKeywordForAnonymousFunctions: true,
+ InsertSpaceAfterKeywordsInControlFlowStatements: true,
+ InsertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true,
+ InsertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false,
+ InsertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false,
+ InsertSpaceAfterSemicolonInForStatements: true,
+ InsertSpaceBeforeAndAfterBinaryOperators: true,
+ NewLineCharacter: model.getEOL(),
+ PlaceOpenBraceOnNewLineForControlBlocks: false,
+ PlaceOpenBraceOnNewLineForFunctions: false,
+ TabSize: options.tabSize,
+ baseIndentSize: 0,
+ convertTabsToSpaces: options.insertSpaces,
+ indentSize: options.indentSize,
+ indentStyle: 2,
+ insertSpaceAfterCommaDelimiter: true,
+ insertSpaceAfterConstructor: false,
+ insertSpaceAfterFunctionKeywordForAnonymousFunctions: true,
+ insertSpaceAfterKeywordsInControlFlowStatements: true,
+ insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: false,
+ insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: false,
+ insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true,
+ insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false,
+ insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false,
+ insertSpaceAfterSemicolonInForStatements: true,
+ insertSpaceAfterTypeAssertion: false,
+ insertSpaceBeforeAndAfterBinaryOperators: true,
+ insertSpaceBeforeFunctionParenthesis: false,
+ newLineCharacter: model.getEOL(),
+ placeOpenBraceOnNewLineForControlBlocks: false,
+ placeOpenBraceOnNewLineForFunctions: false,
+ semicolons: "ignore",
+ tabSize: options.tabSize,
+ trimTrailingWhitespace: true,
+ }
+ }
+
+ dispose() {
+ this.#disposables.forEach(disposable => disposable.dispose())
+ this.#worker.terminate()
+ }
+
+ #registerLanguageFeatures() {
+ this.#disposables.push(
+ this.#options.editor.addAction({
+ id: "strada.goToDefinition",
+ label: "Go to Definition",
+ keybindings: [monaco.KeyCode.F12],
+ run: () => {
+ const model = this.#options.editor.getModel()
+ const position = this.#options.editor.getPosition()
+ if (model && position) void this.goToDefinition(model, position)
+ },
+ })
+ )
+ for (const language of ["javascript", "typescript"]) {
+ this.#disposables.push(
+ monaco.languages.registerCompletionItemProvider(language, {
+ triggerCharacters: [".", '"', "'", "/", "@", "<", "#"],
+ provideCompletionItems: async (model, position, context) => {
+ await this.updateFiles()
+ const result = await this.#request("completions", {
+ fileName: model.uri.path,
+ options: { triggerCharacter: context.triggerCharacter },
+ position: model.getOffsetAt(position),
+ })
+ const word = model.getWordUntilPosition(position)
+ const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn)
+ const suggestions = (result?.entries ?? []).map((entry: any) => {
+ const suggestion: monaco.languages.CompletionItem & {
+ strada?: {
+ data: any
+ fileName: string
+ name: string
+ position: number
+ source: string | undefined
+ }
+ } = {
+ detail: entry.labelDetails?.description,
+ filterText: entry.filterText,
+ insertText: entry.insertText ?? entry.name,
+ kind: completionKind(entry.kind),
+ label: entry.name,
+ range,
+ sortText: entry.sortText,
+ }
+ suggestion.strada = {
+ data: entry.data,
+ fileName: model.uri.path,
+ name: entry.name,
+ position: model.getOffsetAt(position),
+ source: entry.source,
+ }
+ return suggestion
+ })
+ return {
+ incomplete: result?.isIncomplete,
+ suggestions,
+ }
+ },
+ resolveCompletionItem: async item => {
+ const metadata = (
+ item as typeof item & {
+ strada?: {
+ data: any
+ fileName: string
+ name: string
+ position: number
+ source: string | undefined
+ }
+ }
+ ).strada
+ if (!metadata) return item
+ const details = await this.#request("completionDetails", metadata)
+ if (!details) return item
+ item.detail = displayParts(details.displayParts)
+ item.documentation = {
+ value: displayParts(details.documentation),
+ }
+ const changes = (details.codeActions ?? []).flatMap((action: any) => action.changes ?? [])
+ item.additionalTextEdits = changes
+ .filter((change: any) => normalizePath(change.fileName) === normalizePath(metadata.fileName))
+ .flatMap((change: any) =>
+ change.textChanges.map((textChange: any) => ({
+ range: spanToRange(this.#options.models.get(metadata.fileName)!, textChange.span),
+ text: textChange.newText,
+ }))
+ )
+ return item
+ },
+ }),
+ monaco.languages.registerHoverProvider(language, {
+ provideHover: async (model, position) => {
+ await this.updateFiles()
+ const info = await this.quickInfo(model.uri.path, model.getOffsetAt(position))
+ if (!info) return undefined
+ return {
+ contents: [
+ {
+ value: `\`\`\`typescript\n${displayParts(info.displayParts)}\n\`\`\``,
+ },
+ {
+ value: displayParts(info.documentation),
+ },
+ ],
+ range: spanToRange(model, info.textSpan),
+ }
+ },
+ }),
+ monaco.languages.registerDefinitionProvider(language, {
+ provideDefinition: async (model, position) => {
+ await this.updateFiles()
+ const result = await this.#request("definitions", {
+ fileName: model.uri.path,
+ position: model.getOffsetAt(position),
+ })
+ if (!result?.definitions) return undefined
+ const locations = []
+ for (const definition of result.definitions) {
+ const target = await this.#ensureModel(definition.fileName)
+ if (!target) continue
+ locations.push({
+ range: spanToRange(target, definition.textSpan),
+ uri: target.uri,
+ })
+ }
+ return locations
+ },
+ }),
+ monaco.languages.registerSignatureHelpProvider(language, {
+ signatureHelpTriggerCharacters: ["(", ",", "<"],
+ provideSignatureHelp: async (model, position) => {
+ await this.updateFiles()
+ const help = await this.#request("signatureHelp", {
+ fileName: model.uri.path,
+ options: {
+ triggerReason: {
+ kind: "invoked",
+ },
+ },
+ position: model.getOffsetAt(position),
+ })
+ if (!help) return undefined
+ return {
+ dispose() {},
+ value: {
+ activeParameter: help.argumentIndex,
+ activeSignature: help.selectedItemIndex,
+ signatures: help.items.map((item: any) => ({
+ documentation: displayParts(item.documentation),
+ label: [
+ displayParts(item.prefixDisplayParts),
+ item.parameters
+ .map((parameter: any) => displayParts(parameter.displayParts))
+ .join(displayParts(item.separatorDisplayParts)),
+ displayParts(item.suffixDisplayParts),
+ ].join(""),
+ parameters: item.parameters.map((parameter: any) => ({
+ documentation: displayParts(parameter.documentation),
+ label: displayParts(parameter.displayParts),
+ })),
+ })),
+ },
+ }
+ },
+ }),
+ monaco.languages.registerReferenceProvider(language, {
+ provideReferences: async (model, position) => {
+ await this.updateFiles()
+ const references = await this.#request("references", {
+ fileName: model.uri.path,
+ position: model.getOffsetAt(position),
+ })
+ if (!references) return []
+ const locations = []
+ for (const reference of references) {
+ const target = await this.#ensureModel(reference.fileName)
+ if (!target) continue
+ locations.push({
+ range: spanToRange(target, reference.textSpan),
+ uri: target.uri,
+ })
+ }
+ return locations
+ },
+ }),
+ monaco.languages.registerDocumentHighlightProvider(language, {
+ provideDocumentHighlights: async (model, position) => {
+ await this.updateFiles()
+ const results = await this.#request("occurrences", {
+ fileName: model.uri.path,
+ position: model.getOffsetAt(position),
+ })
+ return (results ?? []).map((occurrence: any) => ({
+ kind: occurrence.isWriteAccess
+ ? monaco.languages.DocumentHighlightKind.Write
+ : monaco.languages.DocumentHighlightKind.Text,
+ range: spanToRange(model, occurrence.textSpan),
+ }))
+ },
+ }),
+ monaco.languages.registerDocumentSymbolProvider(language, {
+ provideDocumentSymbols: async model => {
+ await this.updateFiles()
+ const items = await this.#request("navigationBarItems", {
+ fileName: model.uri.path,
+ })
+ return items?.flatMap((item: any) => navigationSymbols(model, item)) ?? []
+ },
+ }),
+ monaco.languages.registerRenameProvider(language, {
+ resolveRenameLocation: async (model, position) => {
+ await this.updateFiles()
+ const info = await this.#request("renameInfo", {
+ fileName: model.uri.path,
+ position: model.getOffsetAt(position),
+ })
+ if (!info?.canRename) {
+ return {
+ range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
+ rejectReason: info?.localizedErrorMessage ?? "This symbol cannot be renamed.",
+ text: "",
+ }
+ }
+ return {
+ range: spanToRange(model, info.triggerSpan),
+ text: model.getValueInRange(spanToRange(model, info.triggerSpan)),
+ }
+ },
+ provideRenameEdits: async (model, position, newName) => {
+ await this.updateFiles()
+ const locations = await this.#request("renameLocations", {
+ fileName: model.uri.path,
+ position: model.getOffsetAt(position),
+ })
+ if (!locations) return { edits: [], rejectReason: "This symbol cannot be renamed." }
+ const edits = []
+ for (const location of locations) {
+ const target = await this.#ensureModel(location.fileName)
+ if (!target) continue
+ edits.push({
+ resource: target.uri,
+ textEdit: {
+ range: spanToRange(target, location.textSpan),
+ text: `${location.prefixText ?? ""}${newName}${location.suffixText ?? ""}`,
+ },
+ versionId: target.getVersionId(),
+ })
+ }
+ return { edits }
+ },
+ }),
+ monaco.languages.registerDocumentFormattingEditProvider(language, {
+ provideDocumentFormattingEdits: async model => {
+ await this.updateFiles()
+ const edits = await this.#request("formatDocument", {
+ fileName: model.uri.path,
+ options: await this.#formatOptions(model),
+ })
+ return edits.map(edit => ({
+ range: spanToRange(model, edit.span),
+ text: edit.newText,
+ }))
+ },
+ }),
+ monaco.languages.registerDocumentRangeFormattingEditProvider(language, {
+ provideDocumentRangeFormattingEdits: async (model, range) => {
+ await this.updateFiles()
+ const edits = await this.#request("formatRange", {
+ end: model.getOffsetAt(range.getEndPosition()),
+ fileName: model.uri.path,
+ options: await this.#formatOptions(model),
+ start: model.getOffsetAt(range.getStartPosition()),
+ })
+ return edits.map(edit => ({
+ range: spanToRange(model, edit.span),
+ text: edit.newText,
+ }))
+ },
+ }),
+ monaco.languages.registerOnTypeFormattingEditProvider(language, {
+ autoFormatTriggerCharacters: [";", "}", "\n"],
+ provideOnTypeFormattingEdits: async (model, position, ch) => {
+ await this.updateFiles()
+ const edits = await this.#request("formatOnType", {
+ fileName: model.uri.path,
+ key: ch,
+ options: await this.#formatOptions(model),
+ position: model.getOffsetAt(position),
+ })
+ return edits.map(edit => ({
+ range: spanToRange(model, edit.span),
+ text: edit.newText,
+ }))
+ },
+ }),
+ monaco.languages.registerCodeActionProvider(language, {
+ provideCodeActions: async (model, _range, context, token) => {
+ const empty = { actions: [], dispose() {} }
+ const version = model.getVersionId()
+ const markers = context.markers.filter(marker => marker.modelVersionId === version)
+ if (markers.length === 0) return empty
+ await this.updateFiles()
+ const formatOptions = await this.#formatOptions(model)
+ const actions = []
+ for (const marker of markers) {
+ if (token.isCancellationRequested || model.isDisposed() || model.getVersionId() !== version) return empty
+ const code = typeof marker.code === "object" ? marker.code.value : marker.code
+ const errorCode = Number(String(code ?? "").replace(/^TS/, ""))
+ if (!Number.isFinite(errorCode)) continue
+ const fixes = await this.#request("codeFixes", {
+ end: model.getOffsetAt(new monaco.Position(marker.endLineNumber, marker.endColumn)),
+ errorCodes: [errorCode],
+ fileName: model.uri.path,
+ formatOptions,
+ preferences: {},
+ start: model.getOffsetAt(new monaco.Position(marker.startLineNumber, marker.startColumn)),
+ })
+ for (const fix of fixes) {
+ const edits = []
+ for (const change of fix.changes ?? []) {
+ const target = await this.#ensureModel(change.fileName)
+ if (!target) continue
+ for (const textChange of change.textChanges) {
+ edits.push({
+ resource: target.uri,
+ textEdit: {
+ range: spanToRange(target, textChange.span),
+ text: textChange.newText,
+ },
+ versionId: target.getVersionId(),
+ })
+ }
+ }
+ actions.push({
+ diagnostics: [marker],
+ edit: { edits },
+ isPreferred: fix.fixId !== undefined,
+ kind: "quickfix",
+ title: fix.description,
+ })
+ }
+ }
+ if (token.isCancellationRequested || model.isDisposed() || model.getVersionId() !== version) return empty
+ return { actions, dispose() {} }
+ },
+ }),
+ monaco.languages.registerInlayHintsProvider(language, {
+ provideInlayHints: async (model, range) => {
+ await this.updateFiles()
+ const start = model.getOffsetAt(range.getStartPosition())
+ const end = model.getOffsetAt(range.getEndPosition())
+ const hints = await this.#request("inlayHints", {
+ fileName: model.uri.path,
+ preferences: {
+ includeInlayEnumMemberValueHints: true,
+ includeInlayFunctionLikeReturnTypeHints: true,
+ includeInlayFunctionParameterTypeHints: true,
+ includeInlayParameterNameHints: "literals",
+ includeInlayParameterNameHintsWhenArgumentMatchesName: false,
+ includeInlayPropertyDeclarationTypeHints: true,
+ includeInlayVariableTypeHints: true,
+ includeInlayVariableTypeHintsWhenTypeMatchesName: false,
+ },
+ span: { length: end - start, start },
+ })
+ return {
+ dispose() {},
+ hints: hints.map(hint => ({
+ kind:
+ hint.kind === "Type" ? monaco.languages.InlayHintKind.Type : monaco.languages.InlayHintKind.Parameter,
+ label: hint.text,
+ paddingLeft: hint.whitespaceBefore,
+ paddingRight: hint.whitespaceAfter,
+ position: model.getPositionAt(hint.position),
+ })),
+ }
+ },
+ })
+ )
+ }
+ }
+
+ async #ensureModel(fileName: string) {
+ const normalized = normalizePath(fileName)
+ const existing = monaco.editor.getModel(monaco.Uri.file(normalized))
+ if (existing) return existing
+ const text = await this.readFile(normalized)
+ if (text === undefined) return undefined
+ return monaco.editor.createModel(text, languageForFile(normalized), monaco.Uri.file(normalized))
+ }
+
+ #request(method: string, args: any): Promise {
+ const id = this.#nextId++
+ return new Promise((resolve, reject) => {
+ this.#pending.set(id, { resolve, reject })
+ this.#worker.postMessage({ id, method, args })
+ })
+ }
+}
+
+function displayParts(parts: readonly { text: string }[] | undefined) {
+ return parts?.map(part => part.text).join("") ?? ""
+}
+
+function spanToRange(model: monaco.editor.ITextModel, span: { start: number; length: number }) {
+ const start = model.getPositionAt(span.start)
+ const end = model.getPositionAt(span.start + span.length)
+ return new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column)
+}
+
+function navigationSymbols(model: monaco.editor.ITextModel, item: any): monaco.languages.DocumentSymbol[] {
+ const range = spanToRange(model, item.spans?.[0] ?? { start: 0, length: 0 })
+ return [
+ {
+ children: item.childItems?.flatMap((child: any) => navigationSymbols(model, child)) ?? [],
+ detail: "",
+ kind: symbolKind(item.kind),
+ name: item.text,
+ range,
+ selectionRange: range,
+ tags: [],
+ },
+ ]
+}
+
+function symbolKind(kind: string | undefined) {
+ switch (kind) {
+ case "class":
+ return monaco.languages.SymbolKind.Class
+ case "const":
+ case "let":
+ case "var":
+ return monaco.languages.SymbolKind.Variable
+ case "enum":
+ return monaco.languages.SymbolKind.Enum
+ case "enum member":
+ return monaco.languages.SymbolKind.EnumMember
+ case "function":
+ return monaco.languages.SymbolKind.Function
+ case "interface":
+ return monaco.languages.SymbolKind.Interface
+ case "method":
+ return monaco.languages.SymbolKind.Method
+ case "module":
+ return monaco.languages.SymbolKind.Module
+ case "property":
+ return monaco.languages.SymbolKind.Property
+ case "type":
+ return monaco.languages.SymbolKind.TypeParameter
+ default:
+ return monaco.languages.SymbolKind.Object
+ }
+}
+
+function completionKind(kind: string | undefined) {
+ switch (kind) {
+ case "class":
+ return monaco.languages.CompletionItemKind.Class
+ case "const":
+ case "let":
+ case "var":
+ return monaco.languages.CompletionItemKind.Variable
+ case "function":
+ return monaco.languages.CompletionItemKind.Function
+ case "interface":
+ return monaco.languages.CompletionItemKind.Interface
+ case "method":
+ return monaco.languages.CompletionItemKind.Method
+ case "property":
+ return monaco.languages.CompletionItemKind.Property
+ case "keyword":
+ return monaco.languages.CompletionItemKind.Keyword
+ default:
+ return monaco.languages.CompletionItemKind.Text
+ }
+}
+
+function languageForFile(fileName: string) {
+ if (/\.json$/i.test(fileName)) return "json"
+ if (/\.[cm]?jsx?$/i.test(fileName)) return "javascript"
+ return "typescript"
+}
+
+function normalizePath(fileName: string) {
+ return fileName.replaceAll("\\", "/").replace(/^file:\/\//, "")
+}
diff --git a/packages/playground-v2/src/strada.worker.ts b/packages/playground-v2/src/strada.worker.ts
new file mode 100644
index 000000000000..c1c14ce8a080
--- /dev/null
+++ b/packages/playground-v2/src/strada.worker.ts
@@ -0,0 +1,369 @@
+type TS = typeof import("typescript")
+
+type InitMessage = {
+ id: number
+ method: "init"
+ args: {
+ compilerSource: string
+ files: Record
+ version: string
+ baseUrl: string
+ }
+}
+
+type RequestMessage = {
+ id: number
+ method: string
+ args: any
+}
+
+type FileEntry = {
+ text: string
+ version: number
+}
+
+let ts: TS
+let version = ""
+let baseUrl = ""
+let compilerOptions: import("typescript").CompilerOptions = {}
+let projectVersion = 0
+const files = new Map()
+const libraries = new Map()
+let languageService: import("typescript").LanguageService
+
+self.addEventListener("message", (event: MessageEvent) => {
+ void handleMessage(event.data)
+})
+
+async function handleMessage(message: InitMessage | RequestMessage) {
+ try {
+ const result =
+ message.method === "init" ? await initialize(message.args) : await dispatch(message.method, message.args)
+ self.postMessage({ id: message.id, result })
+ } catch (error) {
+ self.postMessage({
+ id: message.id,
+ error: error instanceof Error ? error.stack ?? error.message : String(error),
+ })
+ }
+}
+
+async function initialize(args: InitMessage["args"]) {
+ ts = new Function(`${args.compilerSource}\nreturn ts;`)() as TS
+ version = args.version
+ baseUrl = args.baseUrl
+ replaceFiles(args.files)
+ refreshConfig()
+ await loadLibraries(compilerOptions)
+ languageService = ts.createLanguageService(createLanguageServiceHost())
+ return { version: ts.version }
+}
+
+async function dispatch(method: string, args: any) {
+ switch (method) {
+ case "updateFiles":
+ replaceFiles(args.files)
+ refreshConfig()
+ await loadLibraries(compilerOptions)
+ return
+ case "compile":
+ return compile()
+ case "quickInfo":
+ return languageService.getQuickInfoAtPosition(args.fileName, args.position)
+ case "completions":
+ return languageService.getCompletionsAtPosition(args.fileName, args.position, args.options)
+ case "completionDetails":
+ return languageService.getCompletionEntryDetails(
+ args.fileName,
+ args.position,
+ args.name,
+ {},
+ args.source,
+ {},
+ args.data
+ )
+ case "definitions":
+ return languageService.getDefinitionAndBoundSpan
+ ? languageService.getDefinitionAndBoundSpan(args.fileName, args.position)
+ : {
+ definitions: languageService.getDefinitionAtPosition(args.fileName, args.position),
+ }
+ case "references":
+ return languageService.getReferencesAtPosition(args.fileName, args.position)
+ case "occurrences": {
+ const service = languageService as import("typescript").LanguageService & {
+ getOccurrencesAtPosition?(fileName: string, position: number): readonly any[] | undefined
+ }
+ if (service.getOccurrencesAtPosition) {
+ return service.getOccurrencesAtPosition(args.fileName, args.position)
+ }
+ const highlights = service.getDocumentHighlights(args.fileName, args.position, [...files.keys()])
+ return (
+ highlights
+ ?.find(result => normalizePath(result.fileName) === normalizePath(args.fileName))
+ ?.highlightSpans.map(highlight => ({
+ isWriteAccess: highlight.kind === "writtenReference",
+ textSpan: highlight.textSpan,
+ })) ?? []
+ )
+ }
+ case "signatureHelp":
+ return languageService.getSignatureHelpItems(args.fileName, args.position, args.options)
+ case "renameInfo":
+ return languageService.getRenameInfo(args.fileName, args.position, {
+ allowRenameOfImportPath: true,
+ })
+ case "renameLocations":
+ return languageService.findRenameLocations(args.fileName, args.position, false, false, true)
+ case "navigationBarItems":
+ return languageService.getNavigationBarItems(args.fileName)
+ case "formatDocument":
+ return languageService.getFormattingEditsForDocument(args.fileName, args.options)
+ case "formatRange":
+ return languageService.getFormattingEditsForRange(args.fileName, args.start, args.end, args.options)
+ case "formatOnType":
+ return languageService.getFormattingEditsAfterKeystroke(args.fileName, args.position, args.key, args.options)
+ case "codeFixes":
+ return languageService.getCodeFixesAtPosition(
+ args.fileName,
+ args.start,
+ args.end,
+ args.errorCodes,
+ args.formatOptions,
+ args.preferences
+ )
+ case "inlayHints": {
+ const service = languageService as import("typescript").LanguageService & {
+ provideInlayHints?(
+ fileName: string,
+ span: import("typescript").TextSpan,
+ preferences: import("typescript").UserPreferences
+ ): readonly any[]
+ }
+ return service.provideInlayHints?.(args.fileName, args.span, args.preferences) ?? []
+ }
+ case "readFile":
+ return readFile(args.fileName)
+ default:
+ throw new Error(`Unknown Strada request: ${method}`)
+ }
+}
+
+function replaceFiles(nextFiles: Record) {
+ const nextNames = new Set(Object.keys(nextFiles))
+ for (const fileName of files.keys()) {
+ if (!nextNames.has(fileName)) files.delete(fileName)
+ }
+ for (const [fileName, text] of Object.entries(nextFiles)) {
+ const current = files.get(fileName)
+ if (!current || current.text !== text) {
+ files.set(fileName, {
+ text,
+ version: (current?.version ?? 0) + 1,
+ })
+ }
+ }
+ projectVersion++
+}
+
+function refreshConfig() {
+ const parsed = parseConfig()
+ compilerOptions = parsed.options
+ projectVersion++
+}
+
+function parseConfig() {
+ const configPath =
+ [...files.keys()].find(fileName => /\/tsconfig\.json$/i.test(fileName)) ??
+ [...files.keys()].find(fileName => /\/jsconfig\.json$/i.test(fileName))
+ const configText = configPath ? files.get(configPath)?.text ?? "{}" : "{}"
+ const configResult = ts.parseConfigFileTextToJson(configPath ?? "/workspace/tsconfig.json", configText)
+ const config = configResult.config ?? {}
+ const converted = ts.convertCompilerOptionsFromJson(config.compilerOptions ?? {}, "/workspace", configPath)
+ const options: import("typescript").CompilerOptions = {
+ ...converted.options,
+ allowJs: converted.options.allowJs ?? configPath?.endsWith("/jsconfig.json") ?? false,
+ checkJs: converted.options.checkJs ?? configPath?.endsWith("/jsconfig.json") ?? false,
+ }
+ const rootNames = getRootNames(config)
+ return {
+ errors: [
+ ...(configResult.error ? [serializeDiagnostic(configResult.error)] : []),
+ ...converted.errors.map(serializeDiagnostic),
+ ],
+ options,
+ rootNames,
+ }
+}
+
+function getRootNames(config: any) {
+ const sourceFiles = [...files.keys()].filter(
+ fileName => !fileName.includes("/node_modules/") && /\.[cm]?[jt]sx?$/i.test(fileName)
+ )
+ if (Array.isArray(config.files)) {
+ return config.files
+ .filter((fileName: unknown): fileName is string => typeof fileName === "string")
+ .map((fileName: string) => normalizePath(`/workspace/${fileName}`))
+ .filter((fileName: string) => files.has(fileName))
+ }
+ const includes = Array.isArray(config.include) ? config.include : ["**/*"]
+ const excludes = Array.isArray(config.exclude)
+ ? config.exclude
+ : ["node_modules", "bower_components", "jspm_packages"]
+ return sourceFiles.filter(fileName => {
+ const relative = fileName.replace(/^\/workspace\//, "")
+ return (
+ includes.some((pattern: string) => matchesGlob(relative, pattern)) &&
+ !excludes.some((pattern: string) => matchesGlob(relative, pattern))
+ )
+ })
+}
+
+function matchesGlob(fileName: string, pattern: string) {
+ const normalized = pattern.replace(/^\.\//, "")
+ const expression = normalized
+ .replaceAll("**/", "\u0000")
+ .replaceAll("**", "\u0001")
+ .replaceAll("*", "\u0002")
+ .replaceAll("?", "\u0003")
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+ .replaceAll("\u0000", "(?:.*/)?")
+ .replaceAll("\u0001", ".*")
+ .replaceAll("\u0002", "[^/]*")
+ .replaceAll("\u0003", ".")
+ return new RegExp(`^${expression}$`, "i").test(fileName)
+}
+
+async function loadLibraries(options: import("typescript").CompilerOptions) {
+ if (options.noLib) return
+ const roots = options.lib?.length
+ ? options.lib.map(lib => (lib.toLowerCase().startsWith("lib.") ? lib : `lib.${lib}.d.ts`))
+ : [ts.getDefaultLibFileName(options)]
+ for (const root of roots) await loadLibrary(root)
+}
+
+async function loadLibrary(fileName: string) {
+ const normalized = fileName.startsWith("lib.") ? fileName : `lib.${fileName}.d.ts`
+ const path = `/typescript/lib/${normalized}`
+ if (libraries.has(path)) return
+ const response = await fetch(`${baseUrl}${normalized}`)
+ if (!response.ok) {
+ if (response.status === 404) return
+ throw new Error(`Could not load ${normalized}: ${response.status}`)
+ }
+ const text = await response.text()
+ libraries.set(path, text)
+ const references = [...text.matchAll(/ {
+ const referenced = match[1]
+ return referenced.endsWith(".d.ts") ? referenced.split("/").at(-1)! : `lib.${referenced}.d.ts`
+ })
+ for (const reference of references) await loadLibrary(reference)
+}
+
+function compile() {
+ const parsed = parseConfig()
+ compilerOptions = parsed.options
+ const outputFiles: Record = {}
+ const host = createCompilerHost(outputFiles)
+ const program = ts.createProgram(parsed.rootNames, parsed.options, host)
+ const emit = program.emit()
+ const diagnostics = [
+ ...parsed.errors,
+ ...ts.getPreEmitDiagnostics(program).map(serializeDiagnostic),
+ ...emit.diagnostics.map(serializeDiagnostic),
+ ]
+ return {
+ diagnostics,
+ emitSkipped: emit.emitSkipped,
+ outputFiles,
+ rootNames: parsed.rootNames,
+ }
+}
+
+function createCompilerHost(outputFiles: Record) {
+ const host: import("typescript").CompilerHost = {
+ directoryExists: directoryName =>
+ [...files.keys(), ...libraries.keys()].some(fileName =>
+ fileName.startsWith(`${normalizePath(directoryName).replace(/\/$/, "")}/`)
+ ),
+ fileExists: fileName => readFile(fileName) !== undefined,
+ getCanonicalFileName: fileName => fileName,
+ getCurrentDirectory: () => "/workspace",
+ getDefaultLibFileName: options => `/typescript/lib/${ts.getDefaultLibFileName(options)}`,
+ getDirectories: directoryName => {
+ const prefix = `${normalizePath(directoryName).replace(/\/$/, "")}/`
+ return [
+ ...new Set(
+ [...files.keys(), ...libraries.keys()]
+ .filter(fileName => fileName.startsWith(prefix))
+ .map(fileName => fileName.slice(prefix.length).split("/")[0])
+ .filter(name => name && !name.includes("."))
+ ),
+ ]
+ },
+ getNewLine: () => "\n",
+ getSourceFile: (fileName, languageVersion) => {
+ const text = readFile(fileName)
+ return text === undefined
+ ? undefined
+ : ts.createSourceFile(fileName, text, languageVersion, true, scriptKind(fileName))
+ },
+ readFile,
+ useCaseSensitiveFileNames: () => true,
+ writeFile: (fileName, text) => {
+ outputFiles[normalizePath(fileName)] = text
+ },
+ }
+ return host
+}
+
+function createLanguageServiceHost(): import("typescript").LanguageServiceHost {
+ return {
+ fileExists: fileName => readFile(fileName) !== undefined,
+ getCompilationSettings: () => ({
+ ...compilerOptions,
+ allowJs: true,
+ }),
+ getCurrentDirectory: () => "/workspace",
+ getDefaultLibFileName: options => `/typescript/lib/${ts.getDefaultLibFileName(options)}`,
+ getProjectVersion: () => String(projectVersion),
+ getScriptFileNames: () => [...files.keys(), ...libraries.keys()],
+ getScriptKind: scriptKind,
+ getScriptSnapshot: fileName => {
+ const text = readFile(fileName)
+ return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text)
+ },
+ getScriptVersion: fileName => files.get(normalizePath(fileName))?.version.toString() ?? "1",
+ readFile,
+ readDirectory: () => [...files.keys()],
+ useCaseSensitiveFileNames: () => true,
+ }
+}
+
+function readFile(fileName: string) {
+ const normalized = normalizePath(fileName)
+ return files.get(normalized)?.text ?? libraries.get(normalized)
+}
+
+function scriptKind(fileName: string) {
+ if (/\.tsx$/i.test(fileName)) return ts.ScriptKind.TSX
+ if (/\.jsx$/i.test(fileName)) return ts.ScriptKind.JSX
+ if (/\.[cm]?js$/i.test(fileName)) return ts.ScriptKind.JS
+ if (/\.json$/i.test(fileName)) return ts.ScriptKind.JSON
+ return ts.ScriptKind.TS
+}
+
+function normalizePath(fileName: string) {
+ return fileName.replaceAll("\\", "/").replace(/^file:\/\//, "")
+}
+
+function serializeDiagnostic(diagnostic: import("typescript").Diagnostic) {
+ return {
+ category: diagnostic.category,
+ code: diagnostic.code,
+ end: (diagnostic.start ?? 0) + (diagnostic.length ?? 1),
+ fileName: diagnostic.file?.fileName,
+ pos: diagnostic.start ?? 0,
+ text: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
+ }
+}
diff --git a/packages/playground-v2/src/styles.css b/packages/playground-v2/src/styles.css
new file mode 100644
index 000000000000..8e1362cce29b
--- /dev/null
+++ b/packages/playground-v2/src/styles.css
@@ -0,0 +1,1850 @@
+@import "@fontsource/nunito-sans/400.css";
+@import "@fontsource/nunito-sans/600.css";
+@import "hack-font/build/web/hack.css";
+
+:root {
+ color: #1f1f1f;
+ background: #fafafa;
+ font-family: "Nunito Sans", sans-serif;
+ font-synthesis: none;
+ --accent: #1d5f9e;
+ --border: #d6d6d6;
+ --hover: #eeeeee;
+ --menu-text: #1f1f1f;
+ --muted: #5f6368;
+ --output: #f5f5f5;
+ --panel: #ffffff;
+ --selected: #e4e6f1;
+ --sidebar: #f3f3f3;
+ --toolbar: #3178c6;
+ --tree-guide: #b7b7b7;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ overflow: hidden;
+}
+
+.playground {
+ width: 100%;
+ height: auto;
+ min-height: 0;
+ margin: 0;
+ position: relative;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.toolbar {
+ min-height: 3.5rem;
+ padding: 0.5rem 1rem;
+ color: white;
+ background: var(--toolbar);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.toolbar h1,
+.toolbar p,
+.panel-heading h2 {
+ margin: 0;
+}
+
+.toolbar h1 {
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.toolbar h1 a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.toolbar-brand {
+ min-width: 0;
+}
+
+.site-footer {
+ min-height: 2.4rem;
+ padding: 0.45rem 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ background: var(--sidebar);
+ font-size: 0.72rem;
+}
+
+.site-footer > a {
+ color: inherit;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.site-footer nav {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.site-footer nav a {
+ color: inherit;
+}
+
+.toolbar p {
+ margin-top: 0.125rem;
+ font-size: 0.875rem;
+ opacity: 0.85;
+}
+
+.toolbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.version-control {
+ position: relative;
+ min-width: 7rem;
+ max-width: 12rem;
+}
+
+.version-control summary {
+ min-height: 2.15rem;
+ padding: 0.4rem 0.65rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.65rem;
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 45%);
+ border-radius: 0.25rem;
+ color: white;
+ background: rgb(0 0 0 / 16%);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ list-style: none;
+ white-space: nowrap;
+}
+
+.version-control summary::-webkit-details-marker {
+ display: none;
+}
+
+.version-control summary:hover,
+.version-control[open] summary {
+ background: rgb(0 0 0 / 24%);
+}
+
+.version-control summary[aria-disabled="true"] {
+ pointer-events: none;
+ opacity: 0.55;
+}
+
+.version-caret {
+ width: 0.8rem;
+ height: 0.8rem;
+ flex: 0 0 auto;
+ display: grid;
+ place-items: center;
+ transition: transform 120ms ease;
+}
+
+.version-caret::before {
+ content: "▾";
+ line-height: 1;
+}
+
+.version-control[open] .version-caret {
+ transform: rotate(180deg);
+}
+
+.version-menu {
+ position: absolute;
+ top: calc(100% + 0.3rem);
+ left: 0;
+ z-index: 60;
+ width: max(100%, 10rem);
+ max-height: min(30rem, calc(100dvh - 6rem));
+ padding: 0.3rem 0;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 0.3rem;
+ color: var(--menu-text);
+ background: var(--panel);
+ box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 28%);
+}
+
+.version-menu-item {
+ width: 100%;
+ min-height: 2.4rem;
+ padding: 0.45rem 1.9rem 0.45rem 0.7rem !important;
+ position: relative;
+ display: block;
+ overflow: hidden;
+ border: 0 !important;
+ border-radius: 0 !important;
+ color: inherit !important;
+ background: transparent !important;
+ font-size: 0.78rem !important;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.version-menu-item:hover,
+.version-menu-item:focus {
+ background: var(--hover) !important;
+ outline: 0;
+}
+
+.version-menu-item[aria-checked="true"] {
+ color: var(--accent) !important;
+ background: var(--selected) !important;
+ font-weight: 700;
+}
+
+.version-menu-item[aria-checked="true"]::after {
+ position: absolute;
+ top: 50%;
+ right: 0.65rem;
+ content: "✓";
+ transform: translateY(-50%);
+}
+
+.version-menu-separator {
+ height: 1px;
+ margin: 0.3rem 0;
+ background: var(--border);
+}
+
+.toolbar button,
+.run-output button {
+ border: 1px solid rgb(255 255 255 / 45%);
+ border-radius: 0.25rem;
+ color: inherit;
+ background: rgb(0 0 0 / 12%);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+}
+
+.toolbar button {
+ padding: 0.4rem 0.8rem;
+}
+
+.toolbar button:hover:not(:disabled) {
+ background: rgb(0 0 0 / 22%);
+}
+
+.toolbar button:disabled {
+ cursor: wait;
+ opacity: 0.5;
+}
+
+.status {
+ padding: 0.35rem 0.65rem;
+ border: 1px solid rgb(255 255 255 / 35%);
+ border-radius: 0.25rem;
+ font-family: Hack, monospace;
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+.status[data-state="ready"] {
+ background: rgb(0 0 0 / 12%);
+}
+
+.status[data-state="error"] {
+ background: #a1260d;
+}
+
+.resources-dialog {
+ width: min(48rem, calc(100vw - 2rem));
+ height: min(42rem, calc(100dvh - 2rem));
+ padding: 0;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 0.4rem;
+ color: inherit;
+ background: var(--panel);
+ box-shadow: 0 1rem 3rem rgb(0 0 0 / 35%);
+}
+
+.form-dialog {
+ width: min(28rem, calc(100vw - 2rem));
+ padding: 0;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 0.4rem;
+ color: inherit;
+ background: var(--panel);
+ box-shadow: 0 1rem 3rem rgb(0 0 0 / 35%);
+}
+
+.form-dialog::backdrop {
+ background: rgb(0 0 0 / 45%);
+}
+
+.form-dialog form {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.form-dialog header {
+ padding: 0.75rem 0.9rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--sidebar);
+}
+
+.form-dialog h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.form-dialog label {
+ padding: 0 0.9rem;
+ display: grid;
+ gap: 0.35rem;
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+
+.form-dialog input {
+ width: 100%;
+ min-height: 2.5rem;
+ padding: 0 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ color: inherit;
+ background: var(--panel);
+ font-family: Hack, monospace;
+}
+
+.form-dialog footer {
+ padding: 0.7rem 0.9rem;
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ border-top: 1px solid var(--border);
+}
+
+.form-dialog button {
+ min-height: 2.25rem;
+ padding: 0 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ color: inherit;
+ background: var(--panel);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+}
+
+.form-dialog button[type="submit"] {
+ color: white;
+ background: var(--toolbar);
+}
+
+.form-dialog button.danger {
+ color: white;
+ background: #a1260d;
+}
+
+.form-error {
+ margin: 0;
+ padding: 0 0.9rem;
+ color: #c63131;
+ font-size: 0.78rem;
+}
+
+.settings-dialog {
+ width: min(34rem, calc(100vw - 2rem));
+ max-height: calc(100dvh - 2rem);
+ overflow: auto;
+}
+
+.settings-dialog fieldset {
+ margin: 0 0.9rem;
+ padding: 0.75rem;
+ display: grid;
+ gap: 0.7rem;
+ border: 1px solid var(--border);
+ border-radius: 0.3rem;
+}
+
+.settings-dialog legend {
+ padding: 0 0.35rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.settings-dialog .checkbox-setting {
+ padding: 0;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 0.55rem;
+ color: inherit;
+ font-weight: 400;
+}
+
+.settings-dialog .checkbox-setting input {
+ width: 1rem;
+ height: 1rem;
+ min-height: 0;
+ margin-top: 0.15rem;
+}
+
+.settings-dialog .checkbox-setting span {
+ display: grid;
+ gap: 0.1rem;
+}
+
+.settings-dialog .checkbox-setting small {
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.settings-dialog .select-setting {
+ padding: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.75rem;
+ color: inherit;
+ font-weight: 400;
+}
+
+.settings-dialog select {
+ min-height: 2.25rem;
+ padding: 0 0.45rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ color: inherit;
+ background: var(--panel);
+ font: inherit;
+}
+
+.settings-dialog footer span {
+ flex: 1;
+}
+
+.settings-dialog .form-secondary-action {
+ margin-right: auto;
+}
+
+.confirmation-dialog p {
+ margin: 0;
+ padding: 0.2rem 0.9rem 0.4rem;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.resources-dialog::backdrop {
+ background: rgb(0 0 0 / 45%);
+}
+
+.resources-dialog > header {
+ min-height: 3.25rem;
+ padding: 0.65rem 0.8rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--sidebar);
+}
+
+.resources-dialog h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.resources-dialog button,
+.resources-dialog a {
+ color: inherit;
+ font: inherit;
+}
+
+.resources-dialog > header button {
+ min-height: 2rem;
+ padding: 0 0.55rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ background: var(--panel);
+ cursor: pointer;
+}
+
+.resources-view {
+ height: calc(100% - 3.25rem);
+ min-height: 0;
+}
+
+.resource-search {
+ padding: 0.7rem 0.8rem;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 0.65rem;
+ border-bottom: 1px solid var(--border);
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+
+.resource-search input {
+ min-width: 0;
+ height: 2.25rem;
+ padding: 0 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ color: inherit;
+ background: var(--panel);
+ font: inherit;
+}
+
+.resource-list {
+ height: 100%;
+ padding: 0.4rem;
+ overflow: auto;
+}
+
+.resource-search + .resource-list {
+ height: calc(100% - 3.7rem);
+}
+
+.resource-list > button,
+.resource-list > a {
+ width: 100%;
+ padding: 0.65rem 0.7rem;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+ border: 0;
+ border-bottom: 1px solid var(--border);
+ color: inherit;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ text-decoration: none;
+}
+
+.resource-list > button:hover,
+.resource-list > a:hover {
+ background: var(--hover);
+}
+
+.resource-list small {
+ color: var(--muted);
+ font-size: 0.68rem;
+ text-align: right;
+}
+
+.resource-list > .empty-message,
+.resource-error {
+ padding: 0.75rem;
+}
+
+.resource-error {
+ color: #c63131;
+}
+
+.help-topic {
+ border-bottom: 1px solid var(--border);
+}
+
+.help-topic summary {
+ padding: 0.75rem;
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.help-topic summary:hover {
+ background: var(--hover);
+}
+
+.help-topic article {
+ padding: 0.25rem 0.75rem 0.75rem;
+ font-size: 0.9rem;
+ line-height: 1.55;
+}
+
+.help-topic article p {
+ max-width: 42rem;
+ margin: 0 0 1rem;
+}
+
+.help-topic article code,
+.help-topic article kbd {
+ padding: 0.1rem 0.25rem;
+ border-radius: 0.2rem;
+ background: var(--sidebar);
+ font-family: Hack, monospace;
+ font-size: 0.82em;
+}
+
+.workspace {
+ --files-handle-width: 0.3rem;
+ --files-width: 13rem;
+ --output-handle-width: 0.3rem;
+ --output-width: 27.5rem;
+ min-height: 0;
+ display: grid;
+ grid-template-columns:
+ var(--files-width)
+ var(--files-handle-width)
+ minmax(24rem, 1fr)
+ var(--output-handle-width)
+ var(--output-width);
+}
+
+.workspace[data-files-collapsed="true"] {
+ --files-handle-width: 0rem;
+ --files-width: 0rem;
+}
+
+.workspace[data-output-collapsed="true"] {
+ --output-handle-width: 0rem;
+ --output-width: 0rem;
+}
+
+.workspace[data-files-collapsed="true"] .file-explorer,
+.workspace[data-files-collapsed="true"] .file-resizer,
+.workspace[data-output-collapsed="true"] .output-panel,
+.workspace[data-output-collapsed="true"] .output-resizer {
+ display: none;
+}
+
+.file-explorer {
+ grid-column: 1;
+ min-width: 0;
+ overflow: auto;
+ border-right: 1px solid var(--border);
+ background: var(--sidebar);
+}
+
+.file-explorer-heading,
+.panel-heading {
+ min-height: 2.75rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.file-explorer-heading {
+ padding: 0.5rem 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.file-explorer-heading h2,
+.file-explorer-heading span {
+ margin: 0;
+}
+
+.file-explorer-heading h2 {
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.file-explorer-heading span {
+ display: block;
+ margin-top: 0.1rem;
+ color: var(--muted);
+ font-family: Hack, monospace;
+ font-size: 0.65rem;
+}
+
+.file-explorer-actions {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.file-explorer-actions button {
+ height: 1.7rem;
+ padding: 0 0.4rem;
+ border: 1px solid var(--border);
+ border-radius: 0.2rem;
+ color: inherit;
+ background: var(--panel);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.68rem;
+ line-height: 1;
+}
+
+.file-explorer-actions button:hover {
+ background: var(--hover);
+}
+
+.file-list {
+ padding: 0.4rem 0;
+}
+
+.file-tree,
+.file-tree ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.file-tree .file-tree {
+ margin-left: 1rem;
+ border-left: 1px solid var(--tree-guide);
+}
+
+.file-tree .file-tree > li {
+ position: relative;
+}
+
+.file-tree .file-tree > li::before {
+ position: absolute;
+ top: 1rem;
+ left: 0;
+ width: 0.65rem;
+ border-top: 1px solid var(--tree-guide);
+ content: "";
+}
+
+.file-tree-folder {
+ padding: 0.35rem 0.65rem 0.35rem 0.85rem;
+ color: inherit;
+ cursor: pointer;
+ font-size: 0.76rem;
+ font-weight: 600;
+ list-style: none;
+ user-select: none;
+}
+
+.file-tree-folder::before {
+ display: inline-block;
+ width: 0.8rem;
+ margin-right: 0.35rem;
+ color: var(--muted);
+ content: "▾";
+}
+
+.file-tree details:not([open]) > .file-tree-folder::before {
+ content: "▸";
+}
+
+.file-tree-file {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+}
+
+.file-tree-file-button {
+ width: 100%;
+ padding: 0.42rem 0.65rem 0.42rem 0.85rem;
+ overflow: hidden;
+ border: 0;
+ color: inherit;
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.8rem;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.file-tree-file-button::before {
+ display: inline-block;
+ width: 1.65rem;
+ color: var(--muted);
+ font-family: Hack, monospace;
+ font-size: 0.65rem;
+ content: attr(data-kind);
+}
+
+.file-tree-file-button:hover {
+ background: var(--hover);
+}
+
+.file-tree-file-button[aria-current="page"] {
+ background: var(--selected);
+ font-weight: 600;
+}
+
+.file-tree-delete {
+ width: 1.7rem;
+ height: 1.7rem;
+ margin-right: 0.3rem;
+ padding: 0;
+ border: 0;
+ border-radius: 0.2rem;
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+ font-size: 1rem;
+ line-height: 1;
+ opacity: 0;
+}
+
+.file-tree-file:hover .file-tree-delete,
+.file-tree-file:focus-within .file-tree-delete {
+ opacity: 1;
+}
+
+.file-tree-delete:hover {
+ color: #c63131;
+ background: var(--hover);
+}
+
+.editor-panel {
+ grid-column: 3;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ background: var(--panel);
+}
+
+.output-panel {
+ grid-column: 5;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ border-left: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.workspace-resizer {
+ position: relative;
+ z-index: 4;
+ min-width: 0;
+ background: var(--border);
+ cursor: col-resize;
+ touch-action: none;
+}
+
+.workspace-resizer:hover,
+.workspace-resizer:focus,
+body[data-resizing] .workspace-resizer {
+ background: var(--toolbar);
+ outline: 0;
+}
+
+.file-resizer {
+ grid-column: 2;
+}
+
+.output-resizer {
+ grid-column: 4;
+}
+
+body[data-resizing] {
+ cursor: col-resize;
+ user-select: none;
+}
+
+.diagnostics-panel {
+ order: 1;
+ flex: 0 0 auto;
+ min-height: 0;
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.diagnostics-panel summary {
+ min-height: 2.75rem;
+ padding: 0.45rem 0.65rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.35rem;
+ background: var(--sidebar);
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 600;
+ list-style: none;
+}
+
+.diagnostics-panel summary::-webkit-details-marker {
+ display: none;
+}
+
+.diagnostics-panel summary::marker {
+ content: "";
+}
+
+.diagnostics-panel summary::before {
+ flex: 0 0 1.4rem;
+ color: var(--muted);
+ content: "▸";
+ font-size: 0.85rem;
+ line-height: 1;
+ text-align: center;
+}
+
+.diagnostics-panel[open] summary::before {
+ content: "▾";
+}
+
+.diagnostics-panel summary > :first-child {
+ margin-right: auto;
+}
+
+.diagnostics-panel summary > :last-child {
+ min-width: 1.5rem;
+ padding: 0.1rem 0.35rem;
+ border-radius: 1rem;
+ background: var(--selected);
+ font-family: Hack, monospace;
+ font-size: 0.68rem;
+ text-align: center;
+}
+
+.diagnostics-list {
+ max-height: 13rem;
+ overflow: auto;
+}
+
+.diagnostics-list > .empty-message {
+ padding: 0.65rem;
+}
+
+.diagnostic {
+ width: 100%;
+ padding: 0.55rem 0.65rem;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.15rem 0.55rem;
+ border: 0;
+ border-bottom: 1px solid var(--border);
+ color: inherit;
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ user-select: text;
+}
+
+.diagnostic:last-child {
+ border-bottom: 0;
+}
+
+.diagnostic:hover {
+ background: var(--hover);
+}
+
+.diagnostic strong {
+ color: var(--muted);
+ font-family: Hack, monospace;
+ font-size: 0.68rem;
+}
+
+.diagnostic-error strong {
+ color: #c63131;
+}
+
+.diagnostic-warning strong {
+ color: #b26a00;
+}
+
+.diagnostic-message {
+ min-width: 0;
+ font-size: 0.76rem;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.diagnostic-location {
+ grid-column: 2;
+ color: var(--muted);
+ font-family: Hack, monospace;
+ font-size: 0.65rem;
+}
+
+.panel-heading {
+ padding: 0.55rem 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.output-panel > .panel-heading {
+ order: 0;
+ flex: 0 0 auto;
+ padding: 0.45rem 0.65rem;
+}
+
+.output-panel > .panel-heading h2 {
+ font-size: 0.8rem;
+}
+
+.panel-heading h2 {
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.editor-heading-title,
+.editor-heading-actions {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+}
+
+.editor-heading-title {
+ gap: 0.55rem;
+}
+
+.editor-heading-title h2 {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.editor-heading-actions {
+ justify-content: flex-end;
+ gap: 0.55rem;
+}
+
+.editor-heading-title button,
+.editor-heading-actions button,
+.output-heading-actions button {
+ min-height: 1.8rem;
+ padding: 0 0.45rem;
+ border: 1px solid var(--border);
+ border-radius: 0.2rem;
+ color: inherit;
+ background: var(--panel);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.72rem;
+ line-height: 1;
+}
+
+.file-explorer-actions .pane-toggle,
+.editor-heading-title .pane-toggle,
+.editor-heading-actions .pane-toggle,
+.output-heading-actions .pane-toggle {
+ flex: 0 0 auto;
+ width: 1.8rem;
+ min-height: 1.8rem;
+ padding: 0;
+ font-size: 1rem;
+}
+
+.editor-heading-title button:hover:not(:disabled),
+.editor-heading-actions button:hover:not(:disabled),
+.output-heading-actions button:hover:not(:disabled) {
+ background: var(--hover);
+}
+
+.editor-heading-title button:disabled,
+.editor-heading-actions button:disabled,
+.output-heading-actions button:disabled {
+ cursor: default;
+ opacity: 0.45;
+}
+
+.panel-heading span {
+ color: var(--muted);
+ font-size: 0.75rem;
+ text-align: right;
+}
+
+.output-heading-actions,
+.output-section-title,
+.run-output header > div {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.4rem;
+}
+
+.output-section-title {
+ justify-content: flex-start;
+ gap: 0.35rem;
+}
+
+.output-section-toggle {
+ flex: 0 0 1.4rem;
+ width: 1.4rem;
+ min-height: 1.4rem;
+ padding: 0;
+ border: 0;
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.85rem;
+ line-height: 1;
+}
+
+.output-section-toggle:hover {
+ color: inherit;
+ background: var(--hover);
+}
+
+code {
+ font-family: Hack, monospace;
+}
+
+.editor {
+ min-width: 0;
+ min-height: 0;
+}
+
+.emit-output {
+ order: 2;
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 0.75rem;
+ overflow: auto;
+ background: var(--output);
+}
+
+.emit-file {
+ margin-bottom: 0.75rem;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ background: var(--panel);
+}
+
+.emit-file:last-child {
+ margin-bottom: 0;
+}
+
+.emit-file h3 {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ margin: 0;
+ padding: 0.45rem 0.65rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--sidebar);
+ font-family: Hack, monospace;
+ font-size: 0.72rem;
+ font-weight: 600;
+}
+
+.emit-file pre {
+ margin: 0;
+ padding: 0.75rem;
+ overflow: auto;
+ font-family: Hack, monospace;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ tab-size: 2;
+}
+
+.empty-message {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.8rem;
+}
+
+.run-output {
+ order: 3;
+ flex: 0 0 auto;
+ max-height: 12rem;
+ overflow: auto;
+ border-top: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.run-output[data-collapsed="true"] .run-log {
+ display: none;
+}
+
+.output-panel:has(.emit-output[hidden]) .run-output:not([data-collapsed="true"]) {
+ max-height: none;
+ flex: 1 1 auto;
+}
+
+.run-output header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 2.75rem;
+ padding: 0.45rem 0.65rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--sidebar);
+}
+
+.run-output h2 {
+ margin: 0;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.run-output button {
+ padding: 0.2rem 0.45rem;
+ border-color: var(--border);
+ color: inherit;
+ background: var(--panel);
+ font-size: 0.7rem;
+}
+
+.run-output .output-section-toggle {
+ width: 1.4rem;
+ min-height: 1.4rem;
+ padding: 0;
+ border: 0;
+ color: var(--muted);
+ background: transparent;
+ font-size: 0.85rem;
+}
+
+.run-log {
+ padding: 0.65rem;
+ font-family: Hack, monospace;
+ font-size: 0.72rem;
+ line-height: 1.45;
+ white-space: pre-wrap;
+}
+
+.run-log-entry + .run-log-entry {
+ margin-top: 0.35rem;
+ padding-top: 0.35rem;
+ border-top: 1px dashed var(--border);
+}
+
+.run-log-entry strong {
+ display: inline-block;
+ width: 2.5rem;
+ color: var(--muted);
+}
+
+.run-log-entry.warn strong {
+ color: #b26a00;
+}
+
+.run-log-entry.error strong {
+ color: #c63131;
+}
+
+.loading-overlay {
+ position: absolute;
+ top: 5.25rem;
+ right: 1rem;
+ z-index: 20;
+ pointer-events: none;
+}
+
+.loading-overlay[data-state="consent"] {
+ position: fixed;
+ inset: 0;
+ padding: 1rem;
+ display: grid;
+ place-items: center;
+ pointer-events: none;
+ transform: none;
+}
+
+.loading-overlay[data-state="consent"] .loading-card {
+ width: min(30rem, calc(100vw - 2rem));
+ pointer-events: auto;
+}
+
+.loading-card {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ width: min(25rem, calc(100vw - 2rem));
+ padding: 0.8rem 0.9rem;
+ border: 1px solid var(--border);
+ border-radius: 0.4rem;
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
+ box-shadow: 0 0.45rem 1.5rem rgb(0 0 0 / 22%);
+ backdrop-filter: blur(8px);
+}
+
+.loading-overlay[hidden] {
+ display: none;
+}
+
+.loading-overlay p {
+ margin: 0 0 0.45rem;
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
+.loading-copy {
+ min-width: 0;
+ flex: 1;
+}
+
+.download-consent {
+ flex: 1;
+}
+
+.download-consent h2 {
+ margin: 0 0 0.45rem;
+ font-size: 1rem;
+}
+
+.download-consent p {
+ margin: 0 0 0.75rem;
+ color: var(--muted);
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+
+.download-consent label {
+ margin-bottom: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.75rem;
+}
+
+.download-consent input {
+ width: 1rem;
+ height: 1rem;
+}
+
+.download-consent button {
+ min-height: 2.25rem;
+ padding: 0 0.75rem;
+ border: 0;
+ border-radius: 0.25rem;
+ color: white;
+ background: var(--toolbar);
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+}
+
+.loading-overlay progress {
+ display: block;
+ width: 100%;
+ height: 0.65rem;
+ accent-color: #3178c6;
+}
+
+.loading-overlay[data-state="error"] progress {
+ accent-color: #c63131;
+}
+
+.loading-overlay small {
+ display: block;
+ min-height: 1rem;
+ margin-top: 0.35rem;
+ overflow: hidden;
+ color: var(--muted);
+ font-family: Hack, monospace;
+ font-size: 0.68rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lds-grid {
+ position: relative;
+ width: 48px;
+ height: 48px;
+ flex: 0 0 48px;
+}
+
+.lds-grid div {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #3178c6;
+ animation: lds-grid 1.2s linear infinite;
+}
+
+.loading-overlay[data-state="error"] .lds-grid div {
+ background: #c63131;
+ animation: none;
+}
+
+.lds-grid div:nth-child(1) {
+ top: 3px;
+ left: 3px;
+ animation-delay: 0s;
+}
+.lds-grid div:nth-child(2) {
+ top: 3px;
+ left: 19px;
+ animation-delay: -0.4s;
+}
+.lds-grid div:nth-child(3) {
+ top: 3px;
+ left: 35px;
+ animation-delay: -0.8s;
+}
+.lds-grid div:nth-child(4) {
+ top: 19px;
+ left: 3px;
+ animation-delay: -0.4s;
+}
+.lds-grid div:nth-child(5) {
+ top: 19px;
+ left: 19px;
+ animation-delay: -0.8s;
+}
+.lds-grid div:nth-child(6) {
+ top: 19px;
+ left: 35px;
+ animation-delay: -1.2s;
+}
+.lds-grid div:nth-child(7) {
+ top: 35px;
+ left: 3px;
+ animation-delay: -0.8s;
+}
+.lds-grid div:nth-child(8) {
+ top: 35px;
+ left: 19px;
+ animation-delay: -1.2s;
+}
+.lds-grid div:nth-child(9) {
+ top: 35px;
+ left: 35px;
+ animation-delay: -1.6s;
+}
+
+@keyframes lds-grid {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@media (max-width: 1000px) {
+ .toolbar {
+ min-height: 0;
+ flex-wrap: wrap;
+ }
+
+ .toolbar-actions {
+ min-width: 0;
+ flex: 1 1 34rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+
+ .workspace {
+ grid-template-columns: 11rem minmax(20rem, 1fr);
+ grid-template-rows: minmax(20rem, 1fr) minmax(16rem, 0.8fr);
+ }
+
+ .file-explorer {
+ grid-column: 1;
+ grid-row: 1;
+ }
+
+ .workspace-resizer {
+ display: none;
+ }
+
+ .editor-panel {
+ grid-column: 2;
+ grid-row: 1;
+ }
+
+ .output-panel {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ border-top: 1px solid var(--border);
+ border-left: 0;
+ }
+
+ .workspace[data-files-collapsed="true"] {
+ grid-template-columns: minmax(20rem, 1fr);
+ }
+
+ .workspace[data-files-collapsed="true"] .editor-panel {
+ grid-column: 1;
+ }
+
+ .workspace[data-output-collapsed="true"] {
+ grid-template-rows: minmax(20rem, 1fr);
+ }
+}
+
+@media (max-width: 700px), (max-width: 900px) and (max-height: 600px) {
+ html,
+ body {
+ height: auto;
+ min-height: 100%;
+ }
+
+ body {
+ overflow: auto;
+ }
+
+ .playground {
+ height: auto;
+ min-height: calc(100dvh - 2.4rem);
+ grid-template-rows: auto auto;
+ }
+
+ .toolbar {
+ min-height: 0;
+ padding: calc(0.65rem + env(safe-area-inset-top)) calc(0.75rem + env(safe-area-inset-right)) 0.65rem
+ calc(0.75rem + env(safe-area-inset-left));
+ align-items: stretch;
+ flex-direction: column;
+ gap: 0.55rem;
+ }
+
+ .toolbar h1 {
+ font-size: 1.05rem;
+ }
+
+ .site-footer {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 0.35rem;
+ }
+
+ .site-footer nav {
+ justify-content: flex-start;
+ }
+
+ .toolbar-actions {
+ width: 100%;
+ flex: none;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.5rem;
+ }
+
+ .version-control {
+ min-width: 0;
+ grid-column: 1 / -1;
+ width: 100%;
+ max-width: none;
+ min-height: 0;
+ padding: 0;
+ }
+
+ .version-control summary {
+ min-height: 2.75rem;
+ }
+
+ .version-menu {
+ width: 100%;
+ }
+
+ .toolbar button {
+ min-width: 0;
+ min-height: 2.75rem;
+ }
+
+ .status {
+ min-width: 0;
+ grid-column: 1 / -1;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+ white-space: normal;
+ }
+
+ .resources-dialog {
+ width: 100vw;
+ max-width: none;
+ height: 100dvh;
+ max-height: none;
+ border: 0;
+ border-radius: 0;
+ }
+
+ .form-dialog {
+ width: calc(100vw - 1.5rem);
+ }
+
+ .settings-dialog {
+ max-height: calc(100dvh - 1.5rem);
+ }
+
+ .form-dialog input,
+ .form-dialog button {
+ min-height: 2.75rem;
+ }
+
+ .download-consent button {
+ min-height: 2.75rem;
+ }
+
+ .resources-dialog > header button,
+ .help-topic summary {
+ min-height: 2.75rem;
+ }
+
+ .resource-search {
+ grid-template-columns: 1fr;
+ gap: 0.35rem;
+ }
+
+ .resource-search input {
+ min-height: 2.75rem;
+ }
+
+ .resource-list > button,
+ .resource-list > a {
+ min-height: 3.25rem;
+ }
+
+ .workspace {
+ min-height: 0;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(28rem, 70dvh) auto;
+ }
+
+ .file-explorer {
+ max-height: min(16rem, 40dvh);
+ grid-column: 1;
+ grid-row: 1;
+ overflow: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .file-explorer-heading {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ min-height: 2.75rem;
+ background: var(--sidebar);
+ }
+
+ .file-explorer-actions button {
+ min-width: 2.75rem;
+ height: 2.75rem;
+ padding: 0 0.55rem;
+ }
+
+ .file-tree-folder {
+ min-height: 2.75rem;
+ display: flex;
+ align-items: center;
+ }
+
+ .file-tree-file-button {
+ min-height: 2.75rem;
+ padding-top: 0.65rem;
+ padding-bottom: 0.65rem;
+ }
+
+ .file-tree-delete {
+ width: 2.75rem;
+ height: 2.75rem;
+ opacity: 1;
+ }
+
+ .editor-panel {
+ min-height: 28rem;
+ grid-column: 1;
+ grid-row: 2;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .output-panel {
+ min-height: max(32rem, 70dvh);
+ grid-column: 1;
+ grid-row: 3;
+ border-top: 0;
+ border-left: 0;
+ }
+
+ .panel-heading {
+ align-items: center;
+ }
+
+ .panel-heading span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .editor-heading-title {
+ flex: 1 1 auto;
+ }
+
+ .editor-heading-actions {
+ flex: 0 1 auto;
+ }
+
+ .editor-heading-title button,
+ .editor-heading-actions button,
+ .output-heading-actions button,
+ .output-section-toggle,
+ .run-output .output-section-toggle {
+ min-width: 2.75rem;
+ min-height: 2.75rem;
+ }
+
+ .diagnostics-panel summary {
+ min-height: 3.65rem;
+ }
+
+ .diagnostics-panel summary::before {
+ flex-basis: 2.75rem;
+ }
+
+ .emit-output {
+ min-height: 18rem;
+ padding: 0.5rem;
+ }
+
+ .emit-file pre {
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ }
+
+ .monaco-editor .scrollbar.horizontal {
+ display: none !important;
+ }
+
+ .run-output {
+ max-height: 16rem;
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+
+ .run-log {
+ overflow-wrap: anywhere;
+ }
+
+ .run-output button {
+ min-width: 2.75rem;
+ min-height: 2.75rem;
+ }
+
+ .loading-overlay {
+ position: fixed;
+ top: 50%;
+ right: 0.5rem;
+ left: 0.5rem;
+ transform: translateY(-50%);
+ }
+
+ .loading-card {
+ width: 100%;
+ }
+}
+
+@media (max-width: 420px) {
+ .file-explorer-heading {
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+ }
+
+ .file-explorer-actions {
+ gap: 0.15rem;
+ }
+
+ .file-explorer-actions button {
+ min-width: 0;
+ padding-right: 0.4rem;
+ padding-left: 0.4rem;
+ }
+
+ .editor-panel .panel-heading span {
+ display: none;
+ }
+
+ .editor-heading-title {
+ gap: 0.35rem;
+ }
+
+ .editor-heading-actions {
+ flex: 0 0 auto;
+ }
+
+ .loading-card {
+ gap: 0.65rem;
+ }
+
+ .lds-grid {
+ width: 40px;
+ height: 40px;
+ flex-basis: 40px;
+ transform: scale(0.8);
+ transform-origin: center;
+ }
+}
+
+@media (pointer: coarse) {
+ button,
+ select,
+ summary {
+ touch-action: manipulation;
+ }
+
+ .file-tree-delete {
+ width: 2.75rem;
+ height: 2.75rem;
+ opacity: 1;
+ }
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+}
+
+:root[data-theme="dark"] {
+ color: #f3f3f3;
+ color-scheme: dark;
+ background: #1e1e1e;
+ --accent: #75b7f0;
+ --border: #414141;
+ --hover: #292929;
+ --menu-text: #f3f3f3;
+ --muted: #b7b7b7;
+ --output: #171717;
+ --panel: #1e1e1e;
+ --selected: #37373d;
+ --sidebar: #252526;
+ --toolbar: #235a97;
+ --tree-guide: #5c5c5c;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ color: #f3f3f3;
+ background: #1e1e1e;
+ --accent: #75b7f0;
+ --border: #414141;
+ --hover: #292929;
+ --menu-text: #f3f3f3;
+ --muted: #b7b7b7;
+ --output: #171717;
+ --panel: #1e1e1e;
+ --selected: #37373d;
+ --sidebar: #252526;
+ --toolbar: #235a97;
+ --tree-guide: #5c5c5c;
+ }
+}
diff --git a/packages/playground-v2/src/tsgo-lsp.ts b/packages/playground-v2/src/tsgo-lsp.ts
new file mode 100644
index 000000000000..9c4548204b15
--- /dev/null
+++ b/packages/playground-v2/src/tsgo-lsp.ts
@@ -0,0 +1,429 @@
+import { createTransportToWorker, MonacoLspClient } from "@vscode/monaco-lsp-client/src/index"
+import * as monaco from "monaco-editor-core"
+import {
+ conf as javascriptConfiguration,
+ language as javascriptLanguage,
+} from "monaco-editor/languages/definitions/javascript/javascript.js"
+import {
+ conf as typescriptConfiguration,
+ language as typescriptLanguage,
+} from "monaco-editor/languages/definitions/typescript/typescript.js"
+
+const headerWords = 4
+const readPosition = 0
+const writePosition = 1
+const closed = 2
+const signal = 3
+const bufferSize = 4 * 1024 * 1024
+
+export type TsgoStatus = "mounting files" | "starting tsc.wasm" | "initializing LSP" | "ready"
+
+type WorkerMessage = {
+ type: "lsp" | "drain" | "status" | "stderr" | "error"
+ message?: any
+ status?: TsgoStatus
+}
+
+type LspRange = {
+ start: { line: number; character: number }
+ end: { line: number; character: number }
+}
+
+type StartTsgoLspOptions = {
+ configFileName: string
+ editor: monaco.editor.IStandaloneCodeEditor
+ effectiveConfigText: string
+ libraries: Record
+ models: readonly monaco.editor.ITextModel[]
+ module: WebAssembly.Module
+ onError(message: string): void
+ onNavigate(fileName: string, range: monaco.Range): void
+ shouldHandleDiagnostics(model: monaco.editor.ITextModel): boolean
+ onStatus(status: TsgoStatus, serverInfo?: string): void
+}
+
+export type TsgoLspController = {
+ updateEffectiveConfig(text: string): void
+ refreshDiagnostics(): void
+}
+
+class RingBufferWorker {
+ readonly #worker = new Worker(new URL("./tsgo-lsp.worker.js", import.meta.url), {
+ type: "module",
+ })
+ readonly #state: Int32Array
+ readonly #data: Uint8Array
+ readonly #queue: Uint8Array[] = []
+ readonly #listeners = new Map()
+ readonly #pendingRequests = new Map()
+ readonly #configFileName: string
+ #configVersion = 1
+ #effectiveConfigText: string
+ #queueOffset = 0
+
+ onStatus?: (status: TsgoStatus) => void
+ onError?: (message: string) => void
+ onServerInfo?: (serverInfo: string) => void
+
+ constructor(stdin: SharedArrayBuffer, configFileName: string, effectiveConfigText: string) {
+ this.#state = new Int32Array(stdin, 0, headerWords)
+ this.#data = new Uint8Array(stdin, headerWords * Int32Array.BYTES_PER_ELEMENT)
+ this.#configFileName = configFileName
+ this.#effectiveConfigText = effectiveConfigText
+ this.#worker.addEventListener("message", (event: MessageEvent) => {
+ if (event.data.type === "drain") {
+ this.#flush()
+ } else if (event.data.type === "status" && event.data.status) {
+ this.onStatus?.(event.data.status)
+ } else if (event.data.type === "error") {
+ this.onError?.(event.data.message)
+ } else if (event.data.type === "stderr") {
+ console.warn("[tsgo]", event.data.message)
+ } else if (event.data.type === "lsp") {
+ void this.#handleLspMessage(event.data.message)
+ }
+ })
+ }
+
+ start(
+ stdin: SharedArrayBuffer,
+ module: WebAssembly.Module,
+ libraries: Record,
+ files: Record
+ ) {
+ this.#worker.postMessage({
+ type: "init",
+ stdin,
+ libraries,
+ module,
+ files,
+ })
+ }
+
+ postMessage(message: unknown) {
+ message = this.#rewriteConfigMessage(message)
+ const lspMessage = message as { id?: string | number; method?: string }
+ if (lspMessage.id !== undefined && lspMessage.method) {
+ this.#pendingRequests.set(lspMessage.id, lspMessage.method)
+ }
+
+ const body = new TextEncoder().encode(JSON.stringify(message))
+ const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`)
+ const framed = new Uint8Array(header.length + body.length)
+ framed.set(header)
+ framed.set(body, header.length)
+ if (framed.length > this.#data.length) {
+ throw new Error(`LSP message exceeds the ${this.#data.length}-byte stdin buffer`)
+ }
+ this.#queue.push(framed)
+ this.#flush()
+ }
+
+ updateEffectiveConfig(text: string) {
+ if (text === this.#effectiveConfigText) return false
+ this.#effectiveConfigText = text
+ this.postMessage({
+ jsonrpc: "2.0",
+ method: "textDocument/didChange",
+ params: {
+ contentChanges: [{ text }],
+ textDocument: {
+ uri: monaco.Uri.file(this.#configFileName).toString(),
+ version: this.#configVersion + 1,
+ },
+ },
+ })
+ return true
+ }
+
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
+ if (type !== "message") return
+ const callback: EventListener = typeof listener === "function" ? listener : event => listener.handleEvent(event)
+ this.#listeners.set(listener, callback)
+ }
+
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
+ if (type === "message") this.#listeners.delete(listener)
+ }
+
+ #rewriteConfigMessage(message: unknown) {
+ if (!message || typeof message !== "object") return message
+ const candidate = message as {
+ method?: string
+ params?: {
+ contentChanges?: Array<{ text?: string }>
+ textDocument?: { text?: string; uri?: string; version?: number }
+ }
+ }
+ const document = candidate.params?.textDocument
+ if (!document?.uri || monaco.Uri.parse(document.uri).path !== this.#configFileName) return message
+ if (candidate.method === "textDocument/didOpen") {
+ this.#configVersion = Math.max(this.#configVersion, document.version ?? this.#configVersion)
+ return {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ textDocument: {
+ ...document,
+ text: this.#effectiveConfigText,
+ },
+ },
+ }
+ }
+ if (candidate.method === "textDocument/didChange") {
+ this.#configVersion = Math.max(this.#configVersion + 1, document.version ?? 0)
+ return {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ contentChanges: [{ text: this.#effectiveConfigText }],
+ textDocument: {
+ ...document,
+ version: this.#configVersion,
+ },
+ },
+ }
+ }
+ return message
+ }
+
+ #flush() {
+ while (this.#queue.length > 0) {
+ const readPos = Atomics.load(this.#state, readPosition)
+ const writePos = Atomics.load(this.#state, writePosition)
+ const free = this.#data.length - (writePos - readPos)
+ if (free <= 0) return
+
+ const current = this.#queue[0]
+ const length = Math.min(free, current.length - this.#queueOffset)
+ const start = writePos % this.#data.length
+ const first = Math.min(length, this.#data.length - start)
+ this.#data.set(current.subarray(this.#queueOffset, this.#queueOffset + first), start)
+ if (first < length) {
+ this.#data.set(current.subarray(this.#queueOffset + first, this.#queueOffset + length), 0)
+ }
+ this.#queueOffset += length
+ Atomics.store(this.#state, writePosition, writePos + length)
+ Atomics.add(this.#state, signal, 1)
+ Atomics.notify(this.#state, signal)
+
+ if (this.#queueOffset === current.length) {
+ this.#queue.shift()
+ this.#queueOffset = 0
+ }
+ }
+ }
+
+ async #handleLspMessage(message: any) {
+ const method = message?.method
+ const label = method ?? this.#pendingRequests.get(message?.id)
+ if (method === "textDocument/publishDiagnostics") return
+ if (label === "textDocument/definition") {
+ message.result = normalizeLibraryLocations(message?.result)
+ await ensureDefinitionModels(message.result)
+ navigateToDefinition(message.result)
+ }
+ if (message?.id !== undefined && !method) {
+ this.#pendingRequests.delete(message.id)
+ }
+ if (message?.result?.capabilities) {
+ const info = message.result.serverInfo
+ if (info?.name) {
+ const name = info.name === "typescript-go" ? "TypeScript" : info.name
+ this.onServerInfo?.(`${name}${info.version ? ` ${info.version}` : ""}`)
+ }
+ this.onStatus?.("ready")
+ }
+
+ const forwarded = new MessageEvent("message", { data: message })
+ for (const listener of this.#listeners.values()) listener(forwarded)
+ }
+}
+
+let languageRegistered = false
+let activeEditor: monaco.editor.IStandaloneCodeEditor | undefined
+let navigateToLocation: StartTsgoLspOptions["onNavigate"] | undefined
+let definitionFilesPromise: Promise> | undefined
+
+export function registerPlaygroundLanguages() {
+ if (languageRegistered) return
+ languageRegistered = true
+ monaco.languages.register({ id: "typescript", extensions: [".ts", ".tsx", ".mts", ".cts"] })
+ monaco.languages.setLanguageConfiguration("typescript", typescriptConfiguration)
+ monaco.languages.setMonarchTokensProvider("typescript", typescriptLanguage)
+ monaco.languages.register({ id: "javascript", extensions: [".js", ".jsx", ".mjs", ".cjs"] })
+ monaco.languages.setLanguageConfiguration("javascript", javascriptConfiguration)
+ monaco.languages.setMonarchTokensProvider("javascript", javascriptLanguage)
+ monaco.languages.register({ id: "json", extensions: [".json"] })
+ monaco.languages.setLanguageConfiguration("json", {
+ brackets: [
+ ["{", "}"],
+ ["[", "]"],
+ ],
+ comments: { lineComment: "//", blockComment: ["/*", "*/"] },
+ })
+ monaco.languages.setMonarchTokensProvider("json", {
+ tokenizer: {
+ root: [
+ [/"(?:\\.|[^"\\])*"(?=\s*:)/, "string.key.json"],
+ [/"(?:\\.|[^"\\])*"/, "string.value.json"],
+ [/\b(?:true|false|null)\b/, "keyword.json"],
+ [/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/, "number"],
+ [/[{}\[\],:]/, "delimiter"],
+ [/\/\/.*$/, "comment"],
+ ],
+ },
+ })
+}
+
+export function startTsgoLsp(options: StartTsgoLspOptions) {
+ if (!crossOriginIsolated) {
+ throw new Error("The TypeScript language server requires cross-origin isolation; reload once to activate it.")
+ }
+
+ activeEditor = options.editor
+ navigateToLocation = options.onNavigate
+ definitionFilesPromise = Promise.resolve(
+ Object.fromEntries(
+ Object.entries(options.libraries).map(([fileName, text]) => [
+ `/typescript/lib/${fileName.slice(fileName.lastIndexOf("/") + 1)}`,
+ text,
+ ])
+ )
+ )
+ const stdin = new SharedArrayBuffer(headerWords * Int32Array.BYTES_PER_ELEMENT + bufferSize)
+ const worker = new RingBufferWorker(stdin, options.configFileName, options.effectiveConfigText)
+ let serverInfo: string | undefined
+ worker.onStatus = status => options.onStatus(status, serverInfo)
+ worker.onServerInfo = info => {
+ serverInfo = info
+ }
+ worker.onError = options.onError
+ // Acquired files are synchronized as open models so closing them can remove them.
+ worker.start(stdin, options.module, options.libraries, {
+ ...Object.fromEntries(options.models.map(model => [model.uri.path, model.getValue()])),
+ [options.configFileName]: options.effectiveConfigText,
+ })
+
+ const transport = createTransportToWorker(worker as unknown as Worker)
+ const client = new MonacoLspClient(transport, {
+ shouldHandleDiagnostics: options.shouldHandleDiagnostics,
+ shouldSynchronizeModel: model => !isServerOwnedLibraryModel(model),
+ })
+ return {
+ updateEffectiveConfig(text: string) {
+ if (worker.updateEffectiveConfig(text)) client.refreshDiagnostics()
+ },
+ refreshDiagnostics() {
+ client.refreshDiagnostics()
+ },
+ } satisfies TsgoLspController
+}
+
+function normalizeLibraryLocations(result: unknown): unknown {
+ if (Array.isArray(result)) return result.map(normalizeLibraryLocations)
+ if (!result || typeof result !== "object") return result
+
+ const location = result as {
+ uri?: string
+ targetUri?: string
+ }
+ const uri = location.targetUri ?? location.uri
+ if (!uri) return result
+ const normalized = normalizeLibraryUri(uri)
+ if (normalized === uri) return result
+ return "targetUri" in location ? { ...location, targetUri: normalized } : { ...location, uri: normalized }
+}
+
+function normalizeLibraryUri(uri: string) {
+ const parsed = monaco.Uri.parse(uri)
+ if (parsed.scheme !== "bundled" || !/^\/libs\/lib(?:\..*)?\.d\.ts$/i.test(parsed.path)) {
+ return uri
+ }
+ return monaco.Uri.file(`/typescript/lib/${parsed.path.slice("/libs/".length)}`).toString()
+}
+
+function isServerOwnedLibraryModel(model: monaco.editor.ITextModel) {
+ return model.uri.scheme === "file" && /^\/typescript\/lib\/lib(?:\..*)?\.d\.ts$/i.test(model.uri.path)
+}
+
+async function ensureDefinitionModels(result: unknown) {
+ const locations = Array.isArray(result) ? result : [result]
+ const uris = new Set()
+ for (const location of locations) {
+ if (!location || typeof location !== "object") continue
+ const candidate = location as { uri?: string; targetUri?: string }
+ const uri = candidate.targetUri ?? candidate.uri
+ if (uri && monaco.Uri.parse(uri).scheme === "file") uris.add(uri)
+ }
+
+ const definitionFiles = uris.size > 0 ? await getDefinitionFiles() : {}
+ for (const uri of uris) {
+ const monacoUri = monaco.Uri.parse(uri)
+ if (monaco.editor.getModel(monacoUri)) continue
+ const contents = definitionFiles[monacoUri.path]
+ if (contents === undefined) continue
+ monaco.editor.createModel(contents, languageForFile(monacoUri.path), monacoUri)
+ }
+}
+
+function getDefinitionFiles() {
+ definitionFilesPromise ??= fetch(new URL("./lib-files.json", import.meta.url)).then(response => {
+ if (!response.ok) {
+ throw new Error(`Could not load TypeScript libraries: ${response.status} ${response.statusText}`)
+ }
+ return response
+ .json()
+ .then((files: Record) =>
+ Object.fromEntries(
+ Object.entries(files).map(([fileName, text]) => [
+ `/typescript/lib/${fileName.slice(fileName.lastIndexOf("/") + 1)}`,
+ text,
+ ])
+ )
+ )
+ })
+ return definitionFilesPromise
+}
+
+function navigateToDefinition(result: unknown) {
+ const location = Array.isArray(result) ? result[0] : result
+ if (!activeEditor || !location || typeof location !== "object") return
+
+ const target = location as {
+ uri?: string
+ targetUri?: string
+ range?: LspRange
+ targetRange?: LspRange
+ targetSelectionRange?: LspRange
+ }
+
+ const uri = target.targetUri ?? target.uri
+ const range = target.targetSelectionRange ?? target.targetRange ?? target.range
+ if (!uri || !range) return
+ const model = monaco.editor.getModel(monaco.Uri.parse(uri))
+ if (!model) return
+
+ const monacoRange = new monaco.Range(
+ range.start.line + 1,
+ range.start.character + 1,
+ range.end.line + 1,
+ range.end.character + 1
+ )
+ if (navigateToLocation) {
+ navigateToLocation(model.uri.path, monacoRange)
+ return
+ }
+ activeEditor.setModel(model)
+ activeEditor.setSelection(monacoRange)
+ activeEditor.revealRangeInCenter(monacoRange, monaco.editor.ScrollType.Immediate)
+ activeEditor.focus()
+}
+
+function languageForFile(fileName: string) {
+ if (/\.json$/i.test(fileName)) return "json"
+ if (/\.[cm]?jsx?$/i.test(fileName)) return "javascript"
+ return "typescript"
+}
+
+export { monaco }
diff --git a/packages/playground-v2/src/tsgo-lsp.worker.ts b/packages/playground-v2/src/tsgo-lsp.worker.ts
new file mode 100644
index 000000000000..572e56eb19fb
--- /dev/null
+++ b/packages/playground-v2/src/tsgo-lsp.worker.ts
@@ -0,0 +1,306 @@
+import { Directory, Fd, File, Inode, PreopenDirectory, WASI, wasi, WASIProcExit } from "@bjorn3/browser_wasi_shim"
+
+const headerWords = 4
+const readPosition = 0
+const writePosition = 1
+const closed = 2
+const signal = 3
+const subscriptionSize = 48
+const eventSize = 32
+
+type InitMessage = {
+ type: "init"
+ stdin: SharedArrayBuffer
+ libraries: Record
+ module: WebAssembly.Module
+ files: Record
+}
+
+class BlockingStdin extends Fd {
+ readonly #state: Int32Array
+ readonly #data: Uint8Array
+
+ constructor(buffer: SharedArrayBuffer) {
+ super()
+ this.#state = new Int32Array(buffer, 0, headerWords)
+ this.#data = new Uint8Array(buffer, headerWords * Int32Array.BYTES_PER_ELEMENT)
+ }
+
+ override fd_fdstat_get() {
+ const fdstat = new wasi.Fdstat(wasi.FILETYPE_CHARACTER_DEVICE, wasi.FDFLAGS_NONBLOCK)
+ fdstat.fs_rights_base = BigInt(wasi.RIGHTS_FD_READ | wasi.RIGHTS_POLL_FD_READWRITE)
+ return { ret: wasi.ERRNO_SUCCESS, fdstat }
+ }
+
+ override fd_filestat_get() {
+ return {
+ ret: wasi.ERRNO_SUCCESS,
+ filestat: new wasi.Filestat(Inode.issue_ino(), wasi.FILETYPE_CHARACTER_DEVICE, 0n),
+ }
+ }
+
+ override fd_fdstat_set_flags() {
+ return wasi.ERRNO_SUCCESS
+ }
+
+ override fd_read(size: number) {
+ let readPos = Atomics.load(this.#state, readPosition)
+ let writePos = Atomics.load(this.#state, writePosition)
+ if (readPos === writePos && !Atomics.load(this.#state, closed)) {
+ const currentSignal = Atomics.load(this.#state, signal)
+ Atomics.wait(this.#state, signal, currentSignal, 50)
+ readPos = Atomics.load(this.#state, readPosition)
+ writePos = Atomics.load(this.#state, writePosition)
+ }
+ if (readPos !== writePos) {
+ const available = writePos - readPos
+ const length = Math.min(size, available)
+ const result = new Uint8Array(length)
+ const start = readPos % this.#data.length
+ const first = Math.min(length, this.#data.length - start)
+ result.set(this.#data.subarray(start, start + first))
+ if (first < length) {
+ result.set(this.#data.subarray(0, length - first), first)
+ }
+ Atomics.store(this.#state, readPosition, readPos + length)
+ self.postMessage({ type: "drain" })
+ return { ret: wasi.ERRNO_SUCCESS, data: result }
+ }
+ if (Atomics.load(this.#state, closed)) {
+ return { ret: wasi.ERRNO_SUCCESS, data: new Uint8Array() }
+ }
+ return { ret: wasi.ERRNO_AGAIN, data: new Uint8Array() }
+ }
+}
+
+class LspStdout extends Fd {
+ #buffer = new Uint8Array()
+
+ override fd_fdstat_get() {
+ const fdstat = new wasi.Fdstat(wasi.FILETYPE_CHARACTER_DEVICE, 0)
+ fdstat.fs_rights_base = BigInt(wasi.RIGHTS_FD_WRITE)
+ return { ret: wasi.ERRNO_SUCCESS, fdstat }
+ }
+
+ override fd_filestat_get() {
+ return {
+ ret: wasi.ERRNO_SUCCESS,
+ filestat: new wasi.Filestat(Inode.issue_ino(), wasi.FILETYPE_CHARACTER_DEVICE, 0n),
+ }
+ }
+
+ override fd_write(data: Uint8Array) {
+ const combined = new Uint8Array(this.#buffer.length + data.length)
+ combined.set(this.#buffer)
+ combined.set(data, this.#buffer.length)
+ this.#buffer = combined
+ this.#flushMessages()
+ return { ret: wasi.ERRNO_SUCCESS, nwritten: data.length }
+ }
+
+ #flushMessages() {
+ for (;;) {
+ const headerEnd = findHeaderEnd(this.#buffer)
+ if (headerEnd < 0) return
+ const header = new TextDecoder().decode(this.#buffer.subarray(0, headerEnd))
+ const match = /(?:^|\r\n)Content-Length:\s*(\d+)/i.exec(header)
+ if (!match) {
+ self.postMessage({ type: "error", message: `Invalid LSP header: ${header}` })
+ this.#buffer = new Uint8Array()
+ return
+ }
+ const contentLength = Number(match[1])
+ const bodyStart = headerEnd + 4
+ const bodyEnd = bodyStart + contentLength
+ if (this.#buffer.length < bodyEnd) return
+ const body = new TextDecoder().decode(this.#buffer.subarray(bodyStart, bodyEnd))
+ try {
+ self.postMessage({ type: "lsp", message: JSON.parse(body) })
+ } catch (error) {
+ self.postMessage({
+ type: "error",
+ message: `Invalid LSP JSON: ${String(error)}`,
+ })
+ }
+ this.#buffer = this.#buffer.slice(bodyEnd)
+ }
+ }
+}
+
+class Stderr extends Fd {
+ readonly #decoder = new TextDecoder()
+
+ override fd_write(data: Uint8Array) {
+ const message = this.#decoder.decode(data, { stream: true }).trim()
+ if (message) self.postMessage({ type: "stderr", message })
+ return { ret: wasi.ERRNO_SUCCESS, nwritten: data.length }
+ }
+}
+
+function findHeaderEnd(data: Uint8Array) {
+ for (let i = 0; i <= data.length - 4; i++) {
+ if (data[i] === 13 && data[i + 1] === 10 && data[i + 2] === 13 && data[i + 3] === 10) {
+ return i
+ }
+ }
+ return -1
+}
+
+function installPollOneoff(wasiRuntime: WASI, state: Int32Array) {
+ wasiRuntime.wasiImport.poll_oneoff = (
+ inputPointer: number,
+ outputPointer: number,
+ subscriptionCount: number,
+ eventCountPointer: number
+ ) => {
+ const memory = new DataView(wasiRuntime.inst.exports.memory.buffer)
+ const subscriptions = Array.from({ length: subscriptionCount }, (_, index) =>
+ wasi.Subscription.read_bytes(memory, inputPointer + index * subscriptionSize)
+ )
+ const clockDeadlines = new Map()
+ for (const subscription of subscriptions) {
+ if (subscription.eventtype !== wasi.EVENTTYPE_CLOCK) continue
+ const clockNow =
+ subscription.clockid === wasi.CLOCKID_REALTIME
+ ? BigInt(Date.now()) * 1_000_000n
+ : BigInt(Math.round(performance.now() * 1e6))
+ clockDeadlines.set(
+ subscription,
+ (subscription.flags & wasi.SUBCLOCKFLAGS_SUBSCRIPTION_CLOCK_ABSTIME) !== 0
+ ? subscription.timeout
+ : clockNow + subscription.timeout
+ )
+ }
+
+ for (;;) {
+ const now = {
+ [wasi.CLOCKID_MONOTONIC]: BigInt(Math.round(performance.now() * 1e6)),
+ [wasi.CLOCKID_REALTIME]: BigInt(Date.now()) * 1_000_000n,
+ }
+ const ready = subscriptions.filter(subscription => {
+ if (subscription.eventtype === wasi.EVENTTYPE_FD_READ) {
+ return (
+ Atomics.load(state, readPosition) !== Atomics.load(state, writePosition) ||
+ Atomics.load(state, closed) !== 0
+ )
+ }
+ if (subscription.eventtype === wasi.EVENTTYPE_FD_WRITE) return true
+ if (subscription.eventtype !== wasi.EVENTTYPE_CLOCK) return false
+ const clockNow = now[subscription.clockid as keyof typeof now] ?? 0n
+ return (clockDeadlines.get(subscription) ?? 0n) <= clockNow
+ })
+
+ if (ready.length > 0) {
+ ready.forEach((subscription, index) => {
+ const eventPointer = outputPointer + index * eventSize
+ new wasi.Event(subscription.userdata, wasi.ERRNO_SUCCESS, subscription.eventtype).write_bytes(
+ memory,
+ eventPointer
+ )
+ memory.setBigUint64(eventPointer + 16, 0n, true)
+ memory.setUint16(eventPointer + 24, 0, true)
+ })
+ memory.setUint32(eventCountPointer, ready.length, true)
+ return wasi.ERRNO_SUCCESS
+ }
+
+ const currentSignal = Atomics.load(state, signal)
+ const nextDeadline = Math.min(
+ 50,
+ ...subscriptions
+ .filter(subscription => subscription.eventtype === wasi.EVENTTYPE_CLOCK)
+ .map(subscription => {
+ const clockNow = now[subscription.clockid as keyof typeof now] ?? 0n
+ const remaining = (clockDeadlines.get(subscription) ?? clockNow) - clockNow
+ return Math.max(1, Math.ceil(Number(remaining) / 1e6))
+ })
+ )
+ Atomics.wait(state, signal, currentSignal, nextDeadline)
+ }
+ }
+}
+
+type Tree = Map
+
+function createFileSystem(files: Record) {
+ const root: Tree = new Map([
+ ["workspace", new Map()],
+ ["tmp", new Map()],
+ ["typescript", new Map([["lib", new Map()]])],
+ ])
+ for (const [filename, contents] of Object.entries(files)) {
+ const parts = filename.replace(/^\/+/, "").split("/")
+ const basename = parts.pop()!
+ let current = root
+ for (const part of parts) {
+ let child = current.get(part)
+ if (!(child instanceof Map)) {
+ child = new Map()
+ current.set(part, child)
+ }
+ current = child
+ }
+ current.set(basename, contents)
+ }
+
+ function build(tree: Tree): Directory {
+ const contents = new Map()
+ for (const [name, value] of tree) {
+ contents.set(name, typeof value === "string" ? new File(new TextEncoder().encode(value)) : build(value))
+ }
+ return new Directory(contents)
+ }
+
+ return new PreopenDirectory("/", build(root).contents)
+}
+
+async function start(message: InitMessage) {
+ self.postMessage({ type: "status", status: "mounting files" })
+ const files = { ...message.files }
+ for (const [name, contents] of Object.entries(message.libraries)) {
+ const filename = name.slice(name.lastIndexOf("/") + 1)
+ files[`/typescript/lib/${filename}`] = contents
+ }
+
+ const fds = [new BlockingStdin(message.stdin), new LspStdout(), new Stderr(), createFileSystem(files)]
+ const wasiRuntime = new WASI(["tsc", "--lsp", "--stdio"], ["HOME=/workspace", "TMPDIR=/tmp"], fds, { debug: false })
+ installPollOneoff(wasiRuntime, new Int32Array(message.stdin, 0, headerWords))
+ const instance = await WebAssembly.instantiate(message.module, {
+ wasi_snapshot_preview1: wasiRuntime.wasiImport,
+ })
+ self.postMessage({ type: "status", status: "starting tsc.wasm" })
+ self.postMessage({ type: "status", status: "initializing LSP" })
+ try {
+ wasiRuntime.start(
+ instance as unknown as {
+ exports: {
+ memory: WebAssembly.Memory
+ _start(): unknown
+ }
+ }
+ )
+ } catch (error) {
+ if (error instanceof WASIProcExit) {
+ self.postMessage({
+ type: "error",
+ message: `tsc exited with status ${error.code}`,
+ })
+ } else {
+ throw error
+ }
+ }
+}
+
+self.addEventListener(
+ "message",
+ (event: MessageEvent) => {
+ if (event.data.type !== "init") return
+ start(event.data).catch(error => {
+ self.postMessage({
+ type: "error",
+ message: error instanceof Error ? error.stack ?? error.message : String(error),
+ })
+ })
+ },
+ { once: true }
+)
diff --git a/packages/playground-v2/tsconfig.json b/packages/playground-v2/tsconfig.json
new file mode 100644
index 000000000000..0c00ec6d4998
--- /dev/null
+++ b/packages/playground-v2/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "ES2022"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts"]
+}
diff --git a/packages/playground-v2/tsconfig.test.json b/packages/playground-v2/tsconfig.test.json
new file mode 100644
index 000000000000..dcdf9b0af030
--- /dev/null
+++ b/packages/playground-v2/tsconfig.test.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["src/ata/**/*.ts", "src/native-compiler.test.ts"],
+ "exclude": []
+}
diff --git a/packages/playground-v2/vendor/examples.json b/packages/playground-v2/vendor/examples.json
new file mode 100644
index 000000000000..a9f9cf07d5b7
--- /dev/null
+++ b/packages/playground-v2/vendor/examples.json
@@ -0,0 +1 @@
+{"examples":[{"path":["3-7","Fixits"],"title":"Big number literals","name":"Big number literals.ts","lang":"en","id":"big-number-literals","sortIndex":1,"hash":"657d5a5ef695d2f2a2977aa7aff4afa0dd6f9c30a29fe38a91cfc16f03ea8ce40c5dc7f2d293f32c488b9988ad65fb0dff9bfc3367c2057bd7c92b959e44e2c2","compilerSettings":{"target":99},"code":"// Did you know there is a limit to how big of a number you\n// can represent in JavaScript when writing ?\n\nconst maxHighValue = 9007199254740991;\nconst maxLowValue = -9007199254740991;\n\n// If you go one over/below these numbers\n// then you start to get into dangerous territory.\n\nconst oneOverMax = 9007199254740992;\nconst oneBelowMin = -9007199254740992;\n\n// The solution for handling numbers of this size\n// is to convert these numbers to BigInts instead\n// of a number:\n//\n// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/BigInt\n\n// TypeScript will now offer a fixit for number\n// literals which are above 2^52 (positive / negative)\n// which adds the suffix \"n\" which informs JavaScript\n// that the type should be BigInt.\n\n// Number literals\n9007199254740993;\n-9007199254740993;\n9007199254740994;\n-9007199254740994;\n\n// Hex numbers\n0x19999999999999;\n-0x19999999999999;\n0x20000000000000;\n-0x20000000000000;\n0x20000000000001;\n-0x20000000000001;"},{"path":["3-7","Fixits"],"title":"Const to let","name":"Const to let.ts","lang":"en","id":"const-to-let","sortIndex":1,"hash":"6ad1f3516a970584b4eb4b549d4f071e22e120148df44b57195fe09323240bbde32d196f3f183b62899bcb44c23d48371b3bf33f01630f5033fc51ecaed2e876","compilerSettings":{},"code":"// New to 3.7 is the ability to quickly convert\n// a const variable to a let when the value\n// has been re-assigned.\n\n// You can try this by highlighting the below error\n// and choosing to run the quick-fix.\n\nconst displayName = \"Andrew\";\n\ndisplayName = \"Andrea\";"},{"path":["3-7","Fixits"],"title":"Infer From Usage Changes","name":"Infer From Usage Changes.ts","lang":"en","id":"infer-from-usage-changes","sortIndex":2,"hash":"f7a4ed733a3746ae16ffbe6533aab704b9533bd3329c6db27b059e313475a272992c16e2131c2cc3718336eaf885554ad1ddb8b35bf95c79e7d447c9bee03693","compilerSettings":{"noImplicitAny":false},"code":"// With 3.7 TypeScript's existing 'infer from usage'\n// code fix became smarter. It will now use a list of\n// known important types (string, number, array, Promise)\n// and infer whether the usage of a type matches the API\n// of these objects.\n\n// For the next few examples, select the parameters of\n// the functions, click the light bulb and choose\n// \"Infer Parameter types...\"\n\n// Infer a number array:\n\nfunction pushNumber(arr) {\n arr.push(12);\n}\n\n// Infer a promise:\n\nfunction awaitPromise(promise) {\n promise.then((value) => console.log(value));\n}\n\n// Infer the function, and its return type:\n\nfunction inferAny(app) {\n const result = app.use(\"hi\");\n return result;\n}\n\n// Infer a string array because a string\n// was added to it:\n\nfunction insertString(names) {\n names[1] = \"hello\";\n}"},{"path":["3-7","Syntax and Messaging"],"title":"Flattened Error Reporting","name":"Flattened Error Reporting.ts","lang":"en","id":"flattened-error-reporting","sortIndex":3,"hash":"f62f4ff309c8a67013ae831386ae42694de363abd841e06f928af91e51a6be28480688b03f3062a94fe6b8575ab76a0b3f4e73f9d122d54b1ac70c44790ef2ca","compilerSettings":{},"code":"// TypeScript's error messages can sometimes be a tad verbose...\n// With 3.7, we've taken a few cases which could be particularly\n// egregious.\n\n// Nested Properties\n\nlet a = { b: { c: { d: { e: \"string\" } } } };\nlet b = { b: { c: { d: { e: 12 } } } };\n\na = b;\n\n// Before, it was 2 lines of code per nested property, which\n// quickly meant people learned to read error messages by\n// reading the first and then last line of an error message.\n\n// Now they're inline. :tada:\n\n// Previously in 3.6:\n//\n// Type '{ b: { c: { d: { e: number; }; }; }; }' is not assignable to type '{ b: { c: { d: { e: string; }; }; }; }'.\n// Types of property 'b' are incompatible.\n// Type '{ c: { d: { e: number; }; }; }' is not assignable to type '{ c: { d: { e: string; }; }; }'.\n// Types of property 'c' are incompatible.\n// Type '{ d: { e: number; }; }' is not assignable to type '{ d: { e: string; }; }'.\n// Types of property 'd' are incompatible.\n// Type '{ e: number; }' is not assignable to type '{ e: string; }'.\n// Types of property 'e' are incompatible.\n// Type 'number' is not assignable to type 'string'\n\n// This can handle working through different types of objects,\n// to still give a useful and concise error message.\n\nclass ExampleClass {\n state = \"ok\";\n}\n\nclass OtherClass {\n state = 12;\n}\n\nlet x = { a: { b: { c: { d: { e: { f: ExampleClass } } } } } };\nlet y = { a: { b: { c: { d: { e: { f: OtherClass } } } } } };\nx = y;\n\n// Previously in 3.6:\n//\n// Type '{ a: { b: { c: { d: { e: { f: typeof OtherClass; }; }; }; }; }; }' is not assignable to type '{ a: { b: { c: { d: { e: { f: typeof ExampleClass; }; }; }; }; }; }'.\n// Types of property 'a' are incompatible.\n// Type '{ b: { c: { d: { e: { f: typeof OtherClass; }; }; }; }; }' is not assignable to type '{ b: { c: { d: { e: { f: typeof ExampleClass; }; }; }; }; }'.\n// Types of property 'b' are incompatible.\n// Type '{ c: { d: { e: { f: typeof OtherClass; }; }; }; }' is not assignable to type '{ c: { d: { e: { f: typeof ExampleClass; }; }; }; }'.\n// Types of property 'c' are incompatible.\n// Type '{ d: { e: { f: typeof OtherClass; }; }; }' is not assignable to type '{ d: { e: { f: typeof ExampleClass; }; }; }'.\n// Types of property 'd' are incompatible.\n// Type '{ e: { f: typeof OtherClass; }; }' is not assignable to type '{ e: { f: typeof ExampleClass; }; }'.\n// Types of property 'e' are incompatible.\n// Type '{ f: typeof OtherClass; }' is not assignable to type '{ f: typeof ExampleClass; }'.\n// Types of property 'f' are incompatible.\n// Type 'typeof OtherClass' is not assignable to type 'typeof ExampleClass'.\n// Type 'OtherClass' is not assignable to type 'ExampleClass'.\n// Types of property 'state' are incompatible.\n// Type 'number' is not assignable to type 'string'"},{"path":["3-7","Syntax and Messaging"],"title":"Nullish Coalescing","name":"Nullish Coalescing.ts","lang":"en","id":"nullish-coalescing","sortIndex":2,"hash":"31115c13c13f4664b1193f32d487e93f6ecd30324f7f008173e1bd08fb6164f9ba48d0cf915a5dff5f66144cf067fe4883f5f1b72da9866aca93f99af77e88a5","compilerSettings":{},"code":"// The nullish coalescing operator is an alternative to ||\n// which returns the right-side expression if the left-side\n// is null or undefined.\n\n// In contrast, || uses falsy checks, meaning an empty\n// string or the number 0 would be considered false.\n\n// A good example for this feature is dealing with partial\n// objects which have defaults when a key isn't passed in.\n\ninterface AppConfiguration {\n // Default: \"(no name)\"; empty string IS valid\n name: string;\n\n // Default: -1; 0 is valid\n items: number;\n\n // Default: true\n active: boolean;\n}\n\nfunction updateApp(config: Partial) {\n // With null-coalescing operator\n config.name = config.name ?? \"(no name)\";\n config.items = config.items ?? -1;\n config.active = config.active ?? true;\n\n // Current solution\n config.name = typeof config.name === \"string\" ? config.name : \"(no name)\";\n config.items = typeof config.items === \"number\" ? config.items : -1;\n config.active = typeof config.active === \"boolean\" ? config.active : true;\n\n // Using || operator which could give bad data\n config.name = config.name || \"(no name)\"; // does not allow for \"\" input\n config.items = config.items || -1; // does not allow for 0 input\n config.active = config.active || true; // really bad, always true\n}\n\n// You can read more about nullish coalescing in the 3.7 blog post:\n//\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/"},{"path":["3-7","Syntax and Messaging"],"title":"Optional Chaining","name":"Optional Chaining.ts","lang":"en","id":"optional-chaining","sortIndex":1,"hash":"f220872faae83af0283838d6f91fb7335d29557f0a4461261fb105b3db773d6323d7e28d8dfe253ebc68718954ab6f37794c0095715182c4cdbfa9c13f1c559b","compilerSettings":{},"code":"// Optional chaining reached TC39 Stage 3 consensus during\n// 3.7's development. Optional Chaining allows you to write\n// code which can immediately stop running expressions when\n// it hits a null or undefined.\n\n// Property Access\n\n// Let's imagine we have an album where the artist, and the\n// artists bio might not be present in the data. For example\n// a compilation may not have a single artist.\n\ntype AlbumAPIResponse = {\n title: string;\n artist?: {\n name: string;\n bio?: string;\n previousAlbums?: string[];\n };\n};\n\ndeclare const album: AlbumAPIResponse;\n\n// With optional chaining, you can write\n// code like this:\n\nconst artistBio = album?.artist?.bio;\n\n// Instead of:\n\nconst maybeArtistBio = album.artist && album.artist.bio;\n\n// In this case ?. acts differently than the &&s since &&\n// will act differently on \"falsy\" values (e.g. an empty string,\n// 0, NaN, and, well, false).\n\n// Optional chaining will only take null or undefined as\n// a signal to stop and return an undefined.\n\n// Optional Element Access\n\n// Property access is via the . operator, the optional chaining\n// also works with the [] operators when accessing elements.\n\nconst maybeArtistBioElement = album?.[\"artist\"]?.[\"bio\"];\n\nconst maybeFirstPreviousAlbum = album?.artist?.previousAlbums?.[0];\n\n// Optional Calls\n\n// When dealing with functions which may or may not exist at\n// runtime, optional chaining supports only calling a function\n// if it exists. This can replace code where you would traditionally\n// write something like: if (func) func()\n\n// For example here's an optional call to the callback from\n// an API request:\n\nconst callUpdateMetadata = (metadata: any) => Promise.resolve(metadata); // Fake API call\n\nconst updateAlbumMetadata = async (metadata: any, callback?: () => void) => {\n await callUpdateMetadata(metadata);\n\n callback?.();\n};\n\n// You can read more about optional chaining in the 3.7 blog post:\n//\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/"},{"path":["3-7","Types and Code Flow"],"title":"Assertion Functions","name":"Assertion Functions.ts","lang":"en","id":"assertion-functions","sortIndex":1,"hash":"f76c42c29fedc10c1863d536ff551354b9ba563e4707f569beb122d59324349c17650461e5c9b462a765ca3690df9a238c51d17b400bc659b390c429b16168c9","compilerSettings":{},"code":"// Given JavaScript's flexibility, it can be a good idea to add\n// runtime checks to your code to validate your assumptions.\n\n// These are typically called assertions (or invariants) and\n// they are small functions which raise errors early when\n// your variables don't match up to what you expect.\n\n// Node comes with a function for doing this out of the box,\n// it's called assert and it's available without an import.\n\n// We're going to define our own though. This declares a\n// function which asserts that the expression called\n// value is true:\ndeclare function assert(value: unknown): asserts value;\n\n// Now we're use it to validate the type of an enum\ndeclare const maybeStringOrNumber: string | number;\nassert(typeof maybeStringOrNumber === \"string\");\n\n// With TypeScript 3.7, the code flow analysis can use these\n// types of functions to figure out what the code is. So,\n// when you hover over the variable below - you can see that\n// it has been narrowed from a string or number to\n// just a string.\n\nmaybeStringOrNumber;\n\n// You can use assertion functions to make guarantees of\n// your types throughout your inferred code, for example\n// TypeScript knows that this function will return a\n// number without the need to add types to the parameter\n// via the above assert declaration.\n\nfunction multiply(x: any, y: any) {\n assert(typeof x === \"number\");\n assert(typeof y === \"number\");\n\n return x * y;\n}\n\n// Assertion functions are siblings to Type Guards\n// example:type-guards except they affect the control flow\n// when it continues through the function.\n\n// For example, we can use assertion functions to narrow\n// an enum down over time:\n\ndeclare const oneOfFirstFiveNumbers: 1 | 2 | 3 | 4 | 5;\n\ndeclare function isOdd(param: unknown): asserts param is 1 | 3 | 5;\ndeclare function isBelowFour(param: unknown): asserts param is 1 | 2 | 3 | 4;\n\n// This should cut down the enum to: 1 | 3 | 5\n\nisOdd(oneOfFirstFiveNumbers);\noneOfFirstFiveNumbers;\n\n// This will then cut the enum's possible states to: 1 | 3\n\nisBelowFour(oneOfFirstFiveNumbers);\noneOfFirstFiveNumbers;\n\n// This is a primer on some of the features of assertion functions\n// in TypeScript 3.7 - you can find out more by reading the\n// release notes:\n//\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/"},{"path":["3-7","Types and Code Flow"],"title":"Recursive Type References","name":"Recursive Type References.ts","lang":"en","id":"recursive-type-references","sortIndex":2,"hash":"ec8662cfd23f3b41cb4792f3e19d18e0cc4de289fed6f6abb9eb654840196146f1e47d568278291f147d06ef0f7a6c08905d996bb203d089bf2730c7a54fa1f3","compilerSettings":{},"code":"// Choosing between using type vs interface is about the\n// constraints in the features for each. With 3.7, one of\n// the constrains on type but not in interface was removed.\n\n// You can find out more about this in example:types-vs-interfaces\n\n// It used to be that you could not refer to the type you\n// are defining inside the type itself. This was a limit\n// which didn't exist inside an interface, and could be worked\n// around with a little work.\n\n// For example, this is not feasible in 3.6:\ntype ValueOrArray = T | Array>;\n\n// An implementation would have looked like this, by mixing\n// the type with an interface.\ntype ValueOrArray2 = T | ArrayOfValueOrArray;\ninterface ArrayOfValueOrArray extends Array> { }\n\n// This allows for a comprehensive definition of JSON,\n// which works by referring to itself.\n\ntype Json = string | number | boolean | null | Json[] | { [key: string]: Json };\n\nconst exampleStatusJSON: Json = {\n available: true,\n username: \"Jean-loup\",\n room: {\n name: \"Highcrest\",\n // Cannot add functions into the Json type\n // update: () => {}\n },\n};\n\n// There's more to learn from the 3.7 beta release notes and its PR:\n//\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/\n// https://github.com/microsoft/TypeScript/pull/33050"},{"path":["3-7","Types and Code Flow"],"title":"Uncalled Function Checks","name":"Uncalled Function Checks.ts","lang":"en","id":"uncalled-function-checks","sortIndex":1,"hash":"41988fd3c184cb01ef29522c39cd97ca1c054d3f475a712aae56a572d02eb880681f22e26a8d4092d45bf0da8f859ef9e6cdb4c039a48c23662c09b7365840f0","compilerSettings":{},"code":"// New to 3.7 is a check inside if statements for\n// when you accidentally use a function instead\n// of the return value of a function.\n\n// This only applies when the function is known\n// to exist making the if statement always be true.\n\n// Here is an example plugin interface, where there\n// are optional and non-optional callbacks.\n\ninterface PluginSettings {\n pluginShouldLoad?: () => void;\n pluginIsActivated: () => void;\n}\n\ndeclare const plugin: PluginSettings;\n\n// Because pluginShouldLoad could not exist, then\n// the check is legitimate.\n\nif (plugin.pluginShouldLoad) {\n // Do something when pluginShouldLoad exists.\n}\n\n// In 3.6 and below, this was not an error.\n\nif (plugin.pluginIsActivated) {\n // Want to do something when the plugin is activated,\n // but instead of calling the method we used it as a\n // property.\n}\n\n// pluginIsActivated should always exist, but TypeScript\n// still allows the check, because the method is called\n// inside the if block.\n\nif (plugin.pluginIsActivated) {\n plugin.pluginIsActivated();\n}"},{"path":["3-8","Breaking Changes"],"title":"Checking Unions with Index Signatures","name":"Checking Unions with Index Signatures.ts","lang":"en","id":"checking-unions-with-index-signatures","sortIndex":1,"hash":"16e0b30e338d71415764867850049b940a9d362145ea871e64d738c3231e743a475c9bbf7596787c3a32441c23c0deede011fa763a32cd4fc5afd618ebbb3bfe","compilerSettings":{"ts":"3.8.3"},"code":"// In previous versions of TypeScript, the checker would not\n// verify that undeclared fields in a union conform to any indexed\n// types in the union.\n\n// You can learn about indexed types here: example:indexed-types\n\n// For example, the IdentifierCache below indicates that any\n// key on the object will be a number:\n\ntype IdentifierCache = { [key: string]: number };\n\n// Meaning this will fail, because 'file_a' has a\n// string value\n\nconst cacheWithString: IdentifierCache = { file_a: \"12343\" };\n\n// However, when you put that into a union, then the\n// validation check would not happen:\n\nlet userCache: IdentifierCache | { index: number };\nuserCache = { file_one: 5, file_two: \"abc\" };\n\n// This is fixed, and there would be an error about\n// 'file_two' from the compiler.\n\n// This also takes into account when the key is a different\n// type, for example: ([key: string] and [key: number])\n\ntype IdentifierResponseCache = { [key: number]: number };\n\nlet resultCache: IdentifierCache | IdentifierResponseCache;\nresultCache = { file_one: \"abc\" };"},{"path":["3-8","JSDoc Improvements"],"title":"Accessibility Modifiers","name":"Accessibility Modifiers.js","lang":"en","id":"accessibility-modifiers","sortIndex":1,"hash":"98a7a7972c4ba777335bff6490370a3b23815c8015eeae5dfe1fc09c09abe9e09863fb0b16bcf8dd3b6e87a99387b7dce3e436892ddbface3e8998ed8a2e63b2","compilerSettings":{"ts":"3.8.3"},"code":"// @ts-check\n\n// The JSDoc support for TypeScript was extended to support\n// the accessibility modifiers on class properties. There is:\n//\n// @public - the default, and what happens if you don't set one\n// @private - the field can only be accessed in the same class\n// where the field is defined\n// @protected - the field is accessible to the class where it is\n// defined and subclasses of that class\n//\n\n// This is a base class of Animal, it has both a private and\n// protected field. Subclasses can access \"this.isFast\" but\n// not \"this.type\".\n\n// Outside of these the class, both of these fields are not\n// visible and return a compiler error when // @ts-check is\n// turned on:\n\nclass Animal {\n constructor(type) {\n /** @private */\n this.type = type;\n /** @protected */\n this.isFast = type === \"cheetah\";\n }\n\n makeNoise() {\n // Supposedly these are pretty much silent\n if (this.type === \"bengal\") {\n console.log(\"\");\n } else {\n throw new Error(\"makeNoise was called on a base class\");\n }\n }\n}\n\nclass Cat extends Animal {\n constructor(type) {\n super(type || \"housecat\");\n }\n\n makeNoise() {\n console.log(\"meow\");\n }\n\n runAway() {\n if (this.isFast) {\n console.log(\"Got away\");\n } else {\n console.log(\"Did not make it\");\n }\n }\n}\n\nclass Cheetah extends Cat {\n constructor() {\n super(\"cheetah\");\n }\n}\n\nclass Bengal extends Cat {\n constructor() {\n super(\"bengal\");\n }\n}\n\nconst housecat = new Cat();\nhousecat.makeNoise();\n\n// These are not available\nhousecat.type;\nhousecat.isFast;\n\n// You can read more in the post\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#jsdoc-modifiers"},{"path":["3-8","Syntax and Messaging"],"title":"Export Modules From","name":"Export Modules From.ts","lang":"en","id":"export-modules-from","sortIndex":1,"hash":"fa8164eb6bdbaaae0185953166cf29e7432f4acb2098aeb1e6eb557b874497a10e915135490478f95396e061d1263e7c840630d26dbdd832ed6f6d9be72f7866","compilerSettings":{"ts":"3.8.3"},"code":"// TypeScripts ability to re-export got closer to supporting\n// the additional cases available in ES2018\n//\n// JavaScript exports have the ability to elegantly\n// re-export a part of a dependency:\n\nexport { ScriptTransformer } from \"@jest/transform\";\n\n// When you wanted to export the full object, that\n// becomes a little more verbose in previous versions\n// of TypeScript:\n\nimport * as console from \"@jest/console\";\nimport * as reporters from \"@jest/reporters\";\n\nexport { console, reporters };\n\n// With 3.8, TypeScript supports more of the export\n// statement forms in the JavaScript specs, letting\n// you write a single line to re-export a module\n\nexport * as jestConsole from \"@jest/console\";\nexport * as jestReporters from \"@jest/reporters\";"},{"path":["3-8","Syntax and Messaging"],"title":"Private Class Fields","name":"Private Class Fields.ts","lang":"en","id":"private-class-fields","sortIndex":1,"hash":"0e9828ae12056988a4edca22495672ad69c3ec317273ac20cc7553b5b4c387ccd19be6ce7f16ffc0f34a9937a3ea1aa644002c7b8986835522b599049db4bfc3","compilerSettings":{"ts":"3.8.3"},"code":"// 3.8 adds private fields, which are a way of declaring a class field to\n// be unavailable outside of the containing class, including to subclasses.\n\n// For example, the Person class below does not allow for anyone using an\n// instance of the class to read the firstName, lastName or prefix\n\nclass Person {\n #firstName: string;\n #lastName: string;\n #prefix: string;\n\n constructor(firstName: string, lastName: string, prefix: string) {\n this.#firstName = firstName;\n this.#lastName = lastName;\n this.#prefix = prefix;\n }\n\n greet() {\n // In iceland it is preferable to use a full name instead of [prefix] [lastname]\n // https://www.w3.org/International/questions/qa-personal-names#patronymic\n if (navigator.languages[0] === \"is\") {\n console.log(`Góðan dag, ${this.#firstName} ${this.#lastName}`);\n } else {\n console.log(`Hello, ${this.#prefix} ${this.#lastName}`);\n }\n }\n}\n\nlet jeremy = new Person(\"Jeremy\", \"Bearimy\", \"Mr\");\n\n// You can't get to any of the private fields from outside that class:\n\n// For example, this won't work:\nconsole.log(jeremy.#lastName);\n\n// Nor this:\nconsole.log(\"Person's last name:\", jeremy[\"#lastName\"]);\n\n// A common question we get is \"Why would you use this over the 'private'\n// keyword in a classfield?\" - let's look by making a comparison to\n// how it worked in TypeScript before 3.8:\n\nclass Dog {\n private _name: string;\n constructor(name: string) {\n this._name = name;\n }\n}\n\nlet oby = new Dog(\"Oby\");\n// Won't let you access via dot notation\noby._name = \"Spot\";\n// But TypeScript allows bracket notation as an escape clause\noby[\"_name\"] = \"Cherny\";\n\n// The TypeScript reference of private only exists at type-level\n// which means that you can only trust it so far. With private fields\n// soon to be a part of the JavaScript language, then you can make better\n// guarantees about the visibility of your code.\n\n// We don't plan on deprecating the `private` field keyword\n// in TypeScript, so your existing code will continue to work, but now\n// you can write code which is closer to the JavaScript language instead.\n\n// You can learn more about class fields in the tc39 proposal\n// https://github.com/tc39/proposal-class-fields/\n// and the beta release notes:\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#ecmascript-private-fields"},{"path":["3-8","Syntax and Messaging"],"title":"Type Imports","name":"Type Imports.tsx","lang":"en","id":"type-imports","sortIndex":1,"hash":"e6695e61e3fc69dd376eeb344a40dde3ec2c46479d9df14d0484365c8a6a35e40b66b1f3997d8ba25d84840524edfe87604caae63fb9ffd62c8793a07464c8aa","compilerSettings":{"ts":"3.8.3"},"code":"// In 3.8 we added new syntax for importing types, which\n// would be similar to users who have come from flow.\n\n// 'import type' provides a way to declare a type only import\n// which means you can be sure that the code will erased when\n// converting to JavaScript in a very predictable way because\n// it will always be removed!\n\n// For example, this line will never add an import or require\nimport type { CSSProperties } from \"react\";\n\n// Which is used here as a type\nconst style: CSSProperties = {\n textAlign: \"center\",\n};\n\n// This is in contrast to this import:\nimport * as React from \"react\";\n\n// Which will be included in the JavaScript\nexport class Welcome extends React.Component {\n render() {\n return (\n \n
Hello, world \n \n );\n }\n}\n\n// However, if the 'import' without types, only imports\n// types - it could also be removed. If you look in the\n// compiled JS output, this import is not included\n\nimport { FunctionComponent } from \"react\";\n\nexport const BetaNotice: FunctionComponent = () => {\n return This page is still in beta
;\n};\n\n// This is called import elision, and it can be the source\n// of confusion. The syntax 'import type' allows you to be\n// specific about what you want in JavaScript.\n\n// This is a small overview for one of the main use cases\n// for 'import types' but there are more which you can read\n// in the 3.8 release notes\n\n// https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#type-only-imports-exports"},{"path":["4-0","New Checks"],"title":"Class Constructor Code Flow","name":"Class Constructor Code Flow.ts","lang":"en","id":"class-constructor-code-flow","sortIndex":1,"hash":"54d7a7ead2770e6e554ed562b5a3f5d4fbdcb450f6796dc402f10e69d681661dafcd8c8cdb485643f4dda513a01c3d0ae69f0cb29179c23ca61af65f01e65a97","compilerSettings":{"ts":"4.0.2"},"code":"// In 4.0, we use control flow analysis to\n// infer the potential type of a class property based on\n// what values are set during the constructor.\n\nclass UserAccount {\n id; // Type is inferred as string | number\n constructor(isAdmin: boolean) {\n if (isAdmin) {\n this.id = \"admin\";\n } else {\n this.id = 0;\n }\n }\n}\n\n// In previous versions of TypeScript, `id` would\n// have been classed as an `any`."},{"path":["4-0","New JS Features"],"title":"JSDoc Deprecated","name":"JSDoc Deprecated.ts","lang":"en","id":"jsdoc-deprecated","sortIndex":1,"hash":"a61596eff52d1e181b4857c55298b0276001ff014cfcbe5d24bded709cb573db810760fa7f2a58633de8f1f2b52c6050d17b0a5e484f9eb288d4e3da3b0947eb","compilerSettings":{},"code":"// In 4.0 the JSDoc tag @deprecated is added to the\n// type system. You can use @deprecated anywhere\n// you can use JSDoc currently.\n\ninterface AccountInfo {\n name: string;\n gender: string;\n\n /** @deprecated use gender field instead */\n sex: \"male\" | \"female\";\n}\n\ndeclare const userInfo: AccountInfo;\nuserInfo.sex;\n\n// TypeScript will offer a non-blocking warning when a\n// deprecated property is accessed, and editors like\n// vscode will use show the deprecated info in places\n// like intellisense, outlines and in your code.\n"},{"path":["4-0","New JS Features"],"title":"Logical Operators and Assignment","name":"Logical Operators and Assignment.ts","lang":"en","id":"logical-operators-and-assignment","sortIndex":1,"hash":"5e5169c5f07cc447f16d95a22a415903328bf4e40b33de234ab89a90adf33ad432e56c1e8cafb94c380baae11c6c4aa0e88912367d669ddb68eff257b6bef35a","compilerSettings":{},"code":"// Logical Operators and Assignment are new features in\n// JavaScript for 2020. These are a suite of new operators\n// which edit a JavaScript object.\n\n// Their goal is to re-use the concept of mathematical \n// operators (e.g. += -= *=) but with logic instead.\n\ninterface User {\n id?: number\n name: string\n location: {\n postalCode?: string\n }\n}\n\nfunction updateUser(user: User) {\n // This code can be replaced \n if (!user.id) user.id = 1\n\n // Or this code:\n user.id = user.id || 1\n\n // With this code:\n user.id ||= 1\n}\n\n// The suites of operators can handle deeply nesting, which \n// can save on quite a lot of boilerplate code too.\n\ndeclare const user: User\nuser.location.postalCode ||= \"90210\"\n\n// There are three new operators: \n//\n// ||= shown above\n// &&= which uses 'and' logic instead of 'or'\n// ??= which builds on example:nullish-coalescing to offer a stricter\n// version of || which uses === instead\n\n// For more info on the proposal, see:\n// https://github.com/tc39/proposal-logical-assignment\n"},{"path":["4-0","New JS Features"],"title":"Nullish Coalescing","name":"Nullish Coalescing.ts","lang":"en","id":"nullish-coalescing","sortIndex":1,"hash":"46f56e1c25cff8d8e4b085f8806c3462b0d964d5b587c71ebf9dd6e778ed253e03c7f96f42b79f9669202adc93e7f29afe1c716d7eddb4a32af57d96a5123ae9","compilerSettings":{},"code":"// # Nullish Coalescing\n//\n// This is a new operator `??` which is meant to augment\n// the common usage of `||` in the same way `===` augments `==`\n// as a more strict form of equality.\n//\n// To understand it, let's look a how || works:\n\nconst response = {\n nullValue: null,\n headerText: \"\",\n animationDuration: 0,\n height: 400,\n showSplashScreen: false,\n} as const;\n\nconst undefinedValue = response.undefinedValue || \"some other default\";\n// This would be: 'some other default'\n\nconst nullValue = response.nullValue || \"some other default\";\n\n// These two examples work similar in most languages. As a tool || is\n// pretty good at defaulting things, but JavaScript's falsy checks\n// can surprise you for some common values:\n\n// Potentially unintended. '' is falsy, result: 'Hello, world!'\nconst headerText = response.headerText || \"Hello, world!\";\n\n// Potentially unintended. 0 is falsy, result: 300\nconst animationDuration = response.animationDuration || 300;\n\n// Potentially unintended. false is falsy, result: true\nconst showSplashScreen = response.showSplashScreen || true;\n\n// When switching to use ?? instead, then === equality is used\n// to compare the two sides:\n\nconst emptyHeaderText = response.headerText ?? \"Hello, world!\";\nconst zeroAnimationDuration = response.animationDuration ?? 300;\nconst skipSplashScreen = response.showSplashScreen ?? true;\n"},{"path":["4-0","New TS Features"],"title":"Named Tuples","name":"Named Tuples.ts","lang":"en","id":"named-tuples","sortIndex":1,"hash":"ddaee350b2cb2004c7ade0bb99ecd6464088fbbdf4b5ef5b6e054d94f9ed75a9944d05c9230feb0e5470c9e13b83043007e2284854aebad80af8bbeb9127c27b","compilerSettings":{"ts":"4.0.2"},"code":"// Tuples are arrays where the order is important to the type system,\n// you can learn more about them in example:tuples\n\n// In TypeScript 4.0, the type of a Tuple's gained the ability to give\n// a name to the different parts of the array.\n\n// For example, you used to write a Lat Long location via a tuple:\n\ntype OldLocation = [number, number]\n\nconst locations: OldLocation[] = [\n [40.7144, -74.006],\n [53.6458, -1.785]\n]\n\n// Knowing which is Latitude and Longitude is ambiguous, and so you\n// would more likely have called it a LatLong tuple.\n\n// With 4.0, you can write:\n\ntype NewLocation = [lat: number, long: number]\n\nconst newLocations: NewLocation[] = [\n [52.3702, 4.8952],\n [53.3498, -6.2603]\n]\n\n// The names now show up in the editor when you hover over\n// the 0 and 1 at the end of the next line\nconst firstLat = newLocations[0][0]\nconst firstLong = newLocations[0][1]\n\n// While that might seem a tad underwhelming, the main goal \n// is to ensure that information isn't lost when working\n// with the type system. For example, when extracting\n// parameters from a function using the Parameter \n// utility type:\n\nfunction centerMap(lng: number, lat: number) { }\n\n// In 4.0, this keeps lng and lat\ntype CenterMapParams = Parameters\n\n// In 3.9, this would look like\ntype OldCenterMapParams = [number, number]\n\n// Making some of the more complex type manipulation lossy\n// for the parameter information."},{"path":["4-0","New TS Features"],"title":"Unknown in Catch","name":"Unknown in Catch.ts","lang":"en","id":"unknown-in-catch","sortIndex":1,"hash":"652e7edb648a13b373910604a20f286978f350090de50a8c038bfe833c197a8f59ea8a387be26fb95e15df561399ccba553581999db9702fb405aa28b5a7d071","compilerSettings":{"ts":"4.0.2"},"code":"// Because JavaScript allows throwing any value, TypeScript\n// does not support declaring the type of an error\n\ntry {\n // ..\n} catch (e) { }\n\n// Historically, this has meant that the `e` in the catch\n// would default to any. This allowed for the freedom to\n// arbitrarily access any property. With 4.0, we've loosened\n// the restrictions on type assignment in the catch clause\n// to allow both `any` and `unknown`.\n\n// Same behavior with any:\ntry {\n // ..\n} catch (e) {\n e.stack;\n}\n\n// Explicit behavior with unknown:\n\ntry {\n // ..\n} catch (e: unknown) {\n // You cannot use `e` at all until the type\n // system learns what it is, for more info see:\n // example:unknown-and-never\n e.stack;\n\n if (e instanceof SyntaxError) {\n e.stack;\n }\n}"},{"path":["4-0","New TS Features"],"title":"Variadic Tuples","name":"Variadic Tuples.ts","lang":"en","id":"variadic-tuples","sortIndex":1,"hash":"d88a897abbbac99720fdd9ecc60c650963a0e6bd3b5be2626bf41f0222e91e504b7ba50e4054dada565a093d8d67efd0e8ddc6aa11f12f36572f80144c57db6a","compilerSettings":{"ts":"4.0.2"},"code":"// Variadic Tuples gives tuples the ability to handle the rest operator (...)\n// to pass types through type checker in a way that works like generics.\n\n// This is quite an advanced topic, so if you get lost do not worry too much.\n// It builds on example:generic-functions and example:tuples\n\n// To start off, here is a variadic tuple which will always prefix another\n// tuple with a number:\n\ntype AddMax = [max: number, ...rest: T];\n// ^ Generic used to constrain the T\n// ^ ... used to indicate where to merge\n\n// This can then be used for composition:\ntype MaxMin = AddMax<[min: number]>\ntype MaxMinDiameter = AddMax<[min: number, diameter: number]>\n\n// The same can be used after the tuple:\ntype SuffixDIContext = [...first: T, context: any];\ntype DIContainer = SuffixDIContext<[param: string]>\n\n// This mechanism can be combined with multiple input params. For example, this\n// function merges two arrays but uses '\\0' as a sigil to indicate where the arrays \n// start and stop.\nfunction joinWithNullTerminators(t: [...T], u: [...U]) {\n return ['\\0', ...t, '\\0', ...u, '\\0'] as const;\n}\n\n// TypeScript can infer the return type of a function like this:\nconst result = joinWithNullTerminators(['variadic', 'types'], [\"terminators\", 3]);\n\n// These tools make it possible to correctly type a function like curry which\n// is a well used concept in functional programming:\n\nfunction curry(f: (...args: [...T, ...U]) => R, ...a: T) {\n return (...b: U) => f(...a, ...b);\n}\n\n// There are three generic arguments:\n// - T: The params which are array of inputs to the curry function\n// - U: The parameters which _aren't_ passed into to curry function, and need applying to the return func\n// - R: the return type of the passed in function\n\nconst sum = (left: number, right: number,) => left + right\n\nconst a = curry(sum, 1, 2)\nconst b = curry(sum, 1)(2)\nconst c = curry(sum)(1, 2)\n\n// You can find a more in-depth explanation, with more code samples in\n// https://github.com/microsoft/TypeScript/pull/39094"},{"path":["4-1","New JS Features"],"title":"New JSX Support","name":"New JSX Support.tsx","lang":"en","id":"new-jsx-support","sortIndex":1,"hash":"a3d9a643f7a0616ca4dd26d4366f7082ba3ca8cac66d93a84a8609ecd5a9b53e54625f01c54b578d92d974ee46f4a3bfa05e9b2b1f07dfeadffd8185d0531edd","compilerSettings":{"ts":"4.1.0-dev.20201028","jsx":4},"code":"// In version 17, the React team introduced a new format\n// for the JavaScript emitted by JSX transforms. You\n// can see the JavaScript in the right hand side of\n// the playground in the \".JS\" tab ->\n\nimport { useState } from \"react\";\n\nexport function ExampleApp() {\n const [count, setCount] = useState(0);\n\n return (\n \n
You clicked {count} times
\n
setCount(count + 1)}> Click me \n
\n );\n}\n\n// The some of the main changes:\n//\n// - Use an `import` to provide functions instead of a React identifier\n// - Different functions for a single element (jsx) vs many children (jsxs)\n// - Key is separate from the props\n//\n// You can read the RFC which this change implements here\n// https://github.com/reactjs/rfcs/blob/createlement-rfc/text/0000-create-element-changes.md\n\n// These changes are mostly under-the-hood changes\n// which shouldn't affect you you write JSX code as\n// an end user."},{"path":["4-1","New JS Features"],"title":"See in JSDoc","name":"See in JSDoc.ts","lang":"en","id":"see-in-jsdoc","sortIndex":1,"hash":"9b8c2fcc85a5921c6225f60548665fb48baba59b4c3dcd6289d500a630ff56f52388366bbd465f73717c0d8e0b9f823f6f7be0915059c126206a5faf05107d88","compilerSettings":{"ts":"4.1.0-beta"},"code":"// With 4.1 the JSDoc parser in TypeScript\n// used in both JavaScript and TypeScript files\n// supports the @see parameter.\n\n// You can use the @see to help people quickly\n// jump to other related code via clicking\n// (cmd/ctrl + clicking) or getting hover info\n\n/**\n * @see hello\n */\nconst goodbye = \"Good\";\n\n/**\n * You say hi, I say low\n *\n * @see goodbye\n */\nconst hello = \"Hello, hello\";"},{"path":["4-1","Template Literals"],"title":"Intro to Template Literals","name":"Intro to Template Literals.ts","lang":"en","id":"intro-to-template-literals","sortIndex":1,"hash":"e57d1bcc716fe422bf16b64d41b921f0f36222511a287b361166445968405272c5e99d732c07b23613cf67c1bd332cd408e3c3fd296ab5817a522a6bce7cbdb5","compilerSettings":{"ts":"4.1.0-dev.20201028"},"code":"// TypeScript already supports treating an exact string/number \n// as a literal, for example this function only allows two\n// exact strings and no others:\n\ndeclare function enableFeature(command: \"redesign\" | \"newArtistPage\"): void;\nenableFeature(\"redesign\");\nenableFeature(`newArtistPage`);\nenableFeature(\"newPaymentSystem\");\n\n// String literals supports all the way you can write a \n// string in ES2020, with TypeScript 4.1 we've extended \n// support for interpolation inside a template string literal.\n\ntype Features = \"Redesign\" | \"newArtistPage\";\n\n// This takes the Features union above, and transforms\n// each part of the union to add `-branch` after the string\ntype FeatureBranch = `${Features}-branch`;\n\n// 4.1 supports a set of new generic-like keywords which\n// you can use inside a template literal to manipulate strings.\n// These are: Uppercase, Lowercase, Capitalize and Uncapitalize\n\ntype FeatureID = `${Lowercase}-id`;\ntype FeatureEnvVar = `${Uppercase}-ID`;\n\n// Strings in unions are cross multiplied, so if used more\n// than one union type then each union member is evaluated\n// against each member from the other union.\n\ntype EnabledStates = \"enabled\" | \"disabled\";\ntype FeatureUIStrings = `${Features} is ${EnabledStates}`;\n\n// This ensures that every possible combination of each\n// union is accounted for.\n\n// This type can then be used with an indexed signature\n// to quickly make a list of keys:\n\ntype SetFeatures = {\n [K in FeatureID]: boolean\n};\n\n// Continue learning more about template literals in\n// example:mapped-types-with-template-literals\n\n// Or read the announcement blog post:\n// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1-beta/#template-literal-types"},{"path":["4-1","Template Literals"],"title":"Mapped Types with Template Literals","name":"Mapped Types with Template Literals.ts","lang":"en","id":"mapped-types-with-template-literals","sortIndex":1,"hash":"0b79bf15704e013436f7aad33900f711bd354dad463ca49341472d175e41c538cb8f8604e8336898d1d2b6742dfd6e8b5bfbbbde0364d2275c9c075150282435","compilerSettings":{"ts":"4.1.0-dev.20201028"},"code":"// TypeScript 4.1 added support for template literals, you can\n// understand some of the basics in example:intro-to-template-literals\n\n// 4.1 introduces new syntax inside a mapped types declaration,\n// you can now use \"as `templated string`\" which can be used to transform\n// strings inside a union.\n\n// For example, this type will transform all of the properties of an existing\n// type into four functions which correspond to traditional REST calls.\n\n// Template strings literals to describe each API endpoint:\ntype GET = `get${Capitalize}`\ntype POST = `post${Capitalize}`\ntype PUT = `put${Capitalize}`\ntype DELETE = `delete${Capitalize}`\n\n// A union of the above literal types\ntype REST = GET | POST | PUT | DELETE\n\n// Takes a type, then for each string property in the type, map\n// that key to REST above, which would create the four functions.\n\ntype RESTify = {\n [Key in keyof Type as REST]: () => Type[Key]\n};\n\n// The `Key extends string ? Key : never` is needed because an object\n// can contain strings, numbers and symbols as keys. We can only handle\n// the string cases here.\n\n// Now we have a list of objects available through the API:\n\ninterface APIs {\n artwork: { id: string, title: string };\n artist: { id: string, name: string };\n location: { id: string, address: string, country: string }\n}\n\n// Then when we have an object which uses these types\ndeclare const api: RESTify\n\n// Then all these functions are automatically created \napi.getArtist()\napi.postArtist()\napi.putLocation()\n\n// Continue learning more about template literals in:\n// example:string-manipulation-with-template-literals\n\n// Or read the announcement blog post:\n// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1-beta/#template-literal-types"},{"path":["4-1","Template Literals"],"title":"String Manipulation with Template Literals","name":"String Manipulation with Template Literals.ts","lang":"en","id":"string-manipulation-with-template-literals","sortIndex":1,"hash":"c991fdd3b6bda4e1597c4613374782be2f902a929400447f52aeecdc10b968706a711b6be8dc71d689cf02d0931343ea5abaf4c1ee3f308b529ac174a3b24daf","compilerSettings":{"ts":"4.1.0-dev.20201028"},"code":"// Template literals can be used to extract and manipulate string literal types.\n// These string literal types, in turn, can be used as properties, and can describe\n// possible transformations from a string to an object in an API.\n\n// ## String Splitting To An Object\n\n// Template literals can use patterns as \"split-points\" to infer the\n// substrings in between. For example...\n\n// This type is a string literal which conforms to a SemVer-like string.\ntype TSVersion = \"4.1.2\"\n\n// We can create a type to extract the components of that string.\n// We'll split across two '.' characters.\ntype ExtractSemver =\n SemverString extends `${infer Major}.${infer Minor}.${infer Patch}` ?\n { major: Major, minor: Minor, patch: Patch } : { error: \"Cannot parse semver string\" }\n\n// Line 1 should be familiar if you've looked at the preceding examples:\n// example:intro-to-template-literals / example:mapped-types-with-template-literals\n\n// Line 2 is a conditional type, TypeScript validates that the infer pattern matches\n// against SemverString parameter.\n\n// Line 3 is the result of the conditional, if true then provide an object\n// with the substrings passed into different positions in an object. If the string\n// does not match, then return the type with an error shape.\n\ntype TS = ExtractSemver\n\n// This won't handle SemVer 100%, because it is an example:\ntype BadSemverButOKString = ExtractSemver<\"4.0.Four.4444\">\n\n// However, ExtractSemver will fail on strings which don't fit the format. This case\n// will only match when a string has the format \"X.Y.Z\", which the next line does not:\ntype SemverError = ExtractSemver<\"Four point Zero point Five\">\n\n// ## Recursive String Splitting\n\n// The previous example will only work when you have an exact string to match,\n// for more nuanced cases you want work with the TypeScript 4.0 feature: example:variadic-tuples.\n\n// To split a string into re-usable components, Tuples are a good way to keep\n// track of the results. Here's a split type:\n\ntype Split =\n string extends S ? string[] :\n S extends '' ? [] :\n S extends `${infer T}${D}${infer U}` ? [T, ...Split] : [S];\n\n// Line 1 declares two params, we'll use single characters for brevity.\n// S represents the string to split, and D is the deliminator. This\n// line ensures they are both strings.\n\n// Line 2 checks if string is a literal, by checking if a general string\n// can be extended from the input string. If so, return a string array. We\n// can't work with non-literal string.\n\n// E.g. this case:\ntype S1 = Split\n\n// Line 3 checks if the string is empty, if so return an empty tuple\ntype S2 = Split<\"\", \".\">\n\n// Line 4 has a similar check to our ExtractSemver. If the string matches\n// `[Prefix as T][Deliminator][Suffix as U]` then extract the prefix (T) into the\n// first parameter of a tuple, then re-run Split on the suffix (U) to ensure\n// that more than one match can be found.\n//\n// If the string does not include the deliminator, then return a tuple of 1 \n// length which contains the string passed in as an argument (S).\n\n// Simple case\ntype S3 = Split<\"1.2\", \".\">\n\n// Will recurse once to get all the .'s splitted\ntype S4 = Split<\"1.2.3\", \".\">\n\n// With this knowledge, you should be able to read and understand quite a\n// few of the community examples of template literals, for example:\n//\n// - An express route extractor by Dan Vanderkam\n// https://twitter.com/danvdk/status/1301707026507198464\n//\n// - A definition for document.querySelector by Mike Ryan\n// https://twitter.com/mikeryandev/status/1308472279010025477\n//\n// People have also experimented with quite complicated string parsers \n// using template string literals, which are fun - but not recommended for\n// production codebases.\n//\n// https://github.com/ghoullier/awesome-template-literal-types\n// \n// Or read the announcement blog post:\n// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1-beta/#template-literal-types"},{"path":["4-2","Fixits"],"title":"Create Function from Call","name":"Create Function from Call.ts","lang":"en","id":"create-function-from-call","sortIndex":1,"hash":"979b52d6faf64ce91d539afa1e0639248afd0bf687820c527a596151741d15113095921d786ed19586f3976a2c1d9e31c857265c168d346af6d76692c6a2dc57","compilerSettings":{"ts":"4.2.0-beta"},"code":"// In 4.2 a community member (@a-tarasyuk) added the ability to generate functions\n// from calls which aren't defined. For example if you select all the code on line 5,\n// then click \"Quick Fix\", you will see the option to have the missing function generated.\n\nconst id = generateUUID();\n\n// The fixit will take into account contextual information like the potential\n// return type for the function. For example, TypeScript knows the return type\n// because it is annotated at the variable declaration.\n\nconst idStr: string = generateUUID1();\n\n// The fixit will keep the same number of generics arguments when they are used:\n\nconst idObj = generateUUID3<{ id: string }>();\n\n// Parameters also act as you would expect:\n\nconst complexUUID = generateUUID4(\"SHA32\", 5, { namespace: \"typescriptlang.org\" });\n\n// It's not possible to show in the playground, but the codefix can create stubbed\n// functions across different modules too - lots to use."},{"path":["4-2","New TS Features"],"title":"Abstract Class Constructors","name":"Abstract Class Constructors.ts","lang":"en","id":"abstract-class-constructors","sortIndex":1,"hash":"198e0399282e978459d6deea9f2894fafab81433975d34f578edd39a3e8644ad4029837f12fa3fa4b21429af17c8118c2538078858a39f03018e2db89fb65509","compilerSettings":{"ts":"4.2.0-beta"},"code":"// TypeScript has supported abstract classes since 2015, which\n// provides compiler errors if you try to instantiate that class.\n\n// TypeScript 4.2 adds support for declaring that the constructor\n// function is abstract. This is mostly used by people who use\n// the mixin pattern ( example:mixins )\n\n// The mixin pattern involves having classes dynamically wrapping\n// each other to \"mixing in\" certain features to the end result.\n\n// This pattern is represented in TypeScript via a chain of constructor\n// functions of the classes, and by declaring one as abstract you can use\n// abstract classes inside your mixins.\n\n// All mixins start with a generic constructor to pass the T through, now\n// these can be abstract.\ntype AbstractConstructor = abstract new (...args: any[]) => T\n\n// We'll create an abstract class \"Animal\" where\n// the subclasses must override 'walk' \nabstract class Animal {\n abstract walk(): void;\n breath() { }\n}\n\n// A mixin which adds a new function (in this case, animate)\nfunction animatableAnimal>(Ctor: T) {\n abstract class StopWalking extends Ctor {\n animate() { }\n }\n return StopWalking;\n}\n\n// A subclass of the Animal, through the mixins, must still\n// handle the abstract contract for Animal. Which means it\n// needs to implement 'walk' below. Try deleting the function\n// to see what happens.\n\nclass Dog extends animatableAnimal(Animal) {\n walk() { }\n}\n\n\nconst dog = new Dog()\ndog.breath()\ndog.walk()\ndog.animate()"},{"path":["4-2","New TS Features"],"title":"Rest Elements in Tuple Types","name":"Rest Elements in Tuple Types.ts","lang":"en","id":"rest-elements-in-tuple-types","sortIndex":1,"hash":"d1062a8be053ce678028501b23ecd8436ac6e9b07d378847115c67545adbf722ec65a0da7de66a88d1b90038542488d5aba726c96f51dbc4d11fd0e2de699aa3","compilerSettings":{"ts":"4.2.0-beta"},"code":"// Tuple types are a feature where the position of a type in an\n// array is important. For example, in string[] (array) you know that all\n// elements in the array are a string, in [string] (tuple) you know that\n// only the first element is a string. According to the type system, a\n// [string] is an array with only one element, of string.\n\nconst stringArray: string[] = [\"sugar\", \"tea\", \"rum\"];\nconst singleStringTuple: [string] = [\"sugar\", \"tea\", \"rum\"];\n\n// Tuples allow TypeScript to describe arrays like: [string, number] - which\n// means that only a string and a number can be used in the first and second\n// positions respectively.\n\nconst stringNumberTuple: [string, number] = [\"Weeks from shore\", 2];\n\n// Under the hood, TypeScript uses Tuples to describe parameters for functions\n// which you can learn more from in:\n//\n// - example:tuples\n// - example:named-tuples\n\n// What was added in TypeScript 4.2 is the ability to describe functions which\n// take an unknown number of parameters but that have a particular start,\n// middle or end. This is done via the spread operator ... in a tuple to\n// indicate that the number varies (aka: variadic)\n\n// This type represents an unknown number of strings in the array but always\n// finishes with an object.\ntype StringsThenConfig = [...string[], { huh: boolean }];\n\nconst firstChorus: StringsThenConfig = [\"Blow\", \"Me Bully boys\", \"blow\", { huh: true }];\nconst secondChorus: StringsThenConfig = [\"We'll take our leave and go\", { huh: false }];\nconst thirdChorus: StringsThenConfig = [\"When she dived down below\", { huh: true }];\n\n// You can learn more about how the feature has evolved in the beta blog post:\n// https://devblogs.microsoft.com/typescript/announcing-typescript-4-2-beta/"},{"path":["4-2","New TS Features"],"title":"Smarter Type Alias Preservation","name":"Smarter Type Alias Preservation.ts","lang":"en","id":"smarter-type-alias-preservation","sortIndex":1,"hash":"a2b8eb532f8e84e342b215db378f25f363b7a5fef97b9a1aa0fa841237dc98e7dd0f3f15287ba2de70d49499de0e09f4d2eeb80ceab885d05a43e046a652f0ad","compilerSettings":{"ts":"4.2.0-beta"},"code":"// Type aliases differ from interfaces in that they aren't guaranteed to\n// keep their name as they are used throughout the compiler. In part, this\n// is a trade-off on what gives them their flexibility, but the downside\n// is that sometimes TypeScript shows an object instead of the name.\n\n// In 4.2, the compiler keeps track of the original name for a type alias\n// in more places. Reducing the size of error messages and hover hints.\n\ntype Shape =\n | { kind: \"circle\"; radius: number }\n | { kind: \"square\"; size: number }\n | { kind: \"rectangle\"; width: number; height: number };\n\ntype Named = { name: string };\n\n// Previously: Shape\ndeclare let shape: Shape;\n\n// No change there, but if you took the existing shape union and extend it,\n// then TypeScript used to 'lose' the original name:\n\n// Previously: { kind: \"circle\"; radius: number; | { kind: \"square\"; size: number; | { kind: \"rectangle\"; width: number; height: number; | undefined\ndeclare let optionalShape: Shape | undefined;\n\n// Previously: { kind: \"circle\"; radius: number; | { kind: \"square\"; size: number; | { kind: \"rectangle\"; width: number; height: number; | undefined\ndeclare let namedShape: Shape & Named;\n\n// Previously: ({ kind: \"circle\"; radius: number; Named) | ({ kind: \"square\"; size: number; Named) | ({ kind: \"rectangle\"; width: number; height: number; Named) | undefined\ndeclare let optionalNamedShape: (Shape & Named) | undefined; // (Shape & Named) | undefined"},{"path":["4-2","New TS Features"],"title":"Use Index Accessors for Index Signatures","name":"Use Index Accessors for Index Signatures.ts","lang":"en","id":"use-index-accessors-for-index-signatures","sortIndex":1,"hash":"edab3b9ce2eb623bfcdb6ed87e26ab9f9c23e0422c00f88c15bd05508aa93f180215c072c624afe191d6b073f23636a118105d43715d79ce9cf9b40e74bd3468","compilerSettings":{"ts":"4.2.0-beta","noPropertyAccessFromIndexSignature":true},"code":"// JavaScript has two ways to access an object on a property, the first is via\n// the dot operator x.y, the other is via square brackets x[\"y\"] - the second\n// syntax x[\"y\"] is called index accessors.\n\n// This syntax is reflected in the type system, where you can add an index\n// signature to a type, meaning any unknown property will have a particular type.\n\n// This type uses an index signature to indicate that you can ask\n// for any string and you will either get a string of undefined back.\n\ntype ENV = {\n [envVar: string]: string | undefined;\n};\n\n// In 4.2, there is a compiler flag to ensure consistency with how the\n// syntax for accessing the variable is consistent with how the variable\n// was declared.\n\n// For example, we can have a game ratings object, where games are\n// given a rank from 1 to 5. There are known games ahead of time,\n// but you could get back a lot of different objects\n\ntype Rating = 1 | 2 | 3 | 4 | 5;\n\ninterface GameRatingLibrary {\n hades: Rating;\n ringFitAdventures: Rating;\n discoElysium: Rating;\n\n // Unknown properties are covered by this index signature.\n [propName: string]: Rating;\n}\n\ndeclare const getYearRatings: (year: string) => GameRatingLibrary;\nconst ratings = getYearRatings(\"2020\");\n\n// These are known in above, and so you can safely use\n// the dot syntax.\nconst hadesScore = ratings.hades;\nconst ringFitScore = ratings.ringFitAdventures;\n\n// This game is not declared above, and the index signature\n// is used instead, this means you cannot use the dot\n// operator and must access via ratings[\"oriAndTheBlindForest\"]\nconst nodeEnv = ratings.oriAndTheBlindForest;"},{"path":["4-3","New TS Features"],"title":"No Implicit Class Overrides","name":"No Implicit Class Overrides.ts","lang":"en","id":"no-implicit-class-overrides","sortIndex":1,"hash":"2b0ef879edd1dfdd13c6629e6700fcde6169b7d8144916cddf8af45140ad5ad5262d6a5990aa26a73f7a7e7ab46cf136c1caf35ad961275667537059709d7e00","compilerSettings":{"ts":"4.3.4"},"code":"// When working with class hierarchies, it can be possible\n// to get the functions which you override out of sync because\n// prior to 4.3, there is no type-safe way to ensure a function\n// is always overriding an existing function.\n\n// For example, this Account class expects its one function to be\n// overridden when a user is an admin:\n\nclass Account {\n doSomething() {\n console.log(\"Make me a sandwich\");\n }\n}\n\nclass Admin extends Account {\n doSomething() {\n console.log(\"Sudo make me a sandwich\");\n }\n}\n\n// You can change the names of these functions, for example\n// maybe 'doSomething' is a bit vague. If you change the name\n// to 'performAction' in Account, but _not_ in Admin you have\n// decoupled the functions un-expectedly.\n\n// To enforce the consistency, there is a new flag: noImplicitOverride\n// and additional syntax. To see it in action, delete the space after the @\n// in the following comment:\n\n// @ noImplicitOverride\n\n// Then you need to add `override` where the red error markers are."},{"path":["4-3","New TS Features"],"title":"Separate Write vs Read on Properties","name":"Separate Write vs Read on Properties.ts","lang":"en","id":"separate-write-vs-read-on-properties","sortIndex":1,"hash":"4ddf1572ca8100735f6be415dc6465b5d1110b39dad67ccae8c89d47206a3870c57f83a32f26ae77dcf3a08b711ae6f8f18456f920f4258b9893ddf1a661b2e1","compilerSettings":{"ts":"4.3.4"},"code":"// TypeScript's type system aims to provide type tools\n// which match existing JavaScript code, and one of the\n// features which comes natural to JavaScript code is\n// to support many different types inputs for a value,\n// but to only provide one set output.\n\n// With TS 4.3, you can now have different set types\n// vs the get for a particular property with getters\n// and setters.\n\n// For example, this timer accepts many possible types\n// when setting the start property, but will only give\n// a Date object back.\n\nclass Timer {\n #start = new Date();\n\n get start(): Date {\n return this.#start;\n }\n\n set start(value: string | number | Date | undefined) {\n if (!value) this.#start = new Date();\n else if (value instanceof Date) this.#start = value;\n else this.#start = new Date(value);\n }\n}\n\nconst timer = new Timer();\n\ntimer.start = \"2021-06-28T14\";\nconsole.log(timer.start);\n\ntimer.start = 1624890417925;\nconsole.log(timer.start);\n\ntimer.start = new Date();\nconsole.log(timer.start);"},{"path":["4-4","New JS Features"],"title":"Control Flow Improvements","name":"Control Flow Improvements.ts","lang":"en","id":"control-flow-improvements","sortIndex":1,"hash":"b18741c4eb43e3c71036ce6e50a6bb8313f6606fa3b34b42ebe1360fe61bd003ae04110fba38caac591db0fa17e21df91500b155c36cec6710055453758b3b76","compilerSettings":{"ts":"4.4.2"},"code":"// Control Flow Analysis is the name for the system which\n// narrows the potential types an identifier can be based\n// on the code someone has wrote, you can get an overview\n// in example:type-widening-and-narrowing\n\n// Roughly, Control Flow Analysis lets you write code like\n// the following:\n\ndeclare const userInput: string | number;\nif (typeof userInput === \"string\") {\n userInput; // string\n} else {\n userInput; // number\n}\n\n// In this case, prior to TypeScript 4.4, code flow analysis\n// would only be applied to the code inside the if statement.\n// This meant that a very simple refactor like the following:\n\nconst isString = typeof userInput === \"string\";\n\n// Would have _not_ had control flow analysis applied:\n\nif (isString) {\n userInput; // string | number in 4.3\n} else {\n userInput; // string | number in 4.3\n}\n\n// In TypeScript 4.4 - the version you're currently on, the control flow\n// analysis can handle this sort of code. This works when TypeScript\n// can make reasonable assumptions that the variable hasn't changed since\n// it was created. For example, a `let` would not be able to be used\n// in analysis:\n\nlet isString2 = typeof userInput === \"string\";\nif (isString2) {\n userInput; // string | number in 4.4\n} else {\n userInput; // string | number in 4.4\n}\n\n// For full details see:\n// https://github.com/microsoft/TypeScript/pull/44730"},{"path":["4-4","New JS Features"],"title":"Errors for High Confidence Errors","name":"Errors for High Confidence Errors.ts","lang":"en","id":"errors-for-high-confidence-errors","sortIndex":1,"hash":"103f1377c46dcb1bf741372fc55f07cec5c4602b88635b9f0baa153cb14f3575be0e61672960726208b2a9bffb7059082630f6b4be0f4424e3a14e8ac45d8347","compilerSettings":{"ts":"4.4.2","useJavaScript":true,"checkJS":false},"code":"// For JavaScript users, TypeScript powers most of the tooling\n// for auto-complete and other IDE features like refactoring.\n\n// During the process of running the TypeScript compiler over\n// JavaScript files, TypeScript has typically _not_ raised errors\n// in an editor unless the project is opted-in via 'checkJS' or\n// has // @check-ts comment.\n\n// With 4.4, we are letting the TypeScript compiler suggest\n// suggest spelling corrections when it is confident that a\n// name is misspelled.\n\nconst album = {\n name: \"Afraid of Heights\",\n author: {\n name: \"Billy Talent\",\n releaseDate: \"2016\",\n },\n};\n\n// In a previous version of the TypeScript tooling for JavaScript,\n// this would not have suggested anything, even though there's\n// almost no way it's correct.\nalbum.nme;\n\n// For full details see:\n// https://github.com/microsoft/TypeScript/commit/e53f19f8f235ed21f405017a1f8670e9329027ce"},{"path":["4-4","New TS Features"],"title":"Exact Optional Properties","name":"Exact Optional Properties.ts","lang":"en","id":"exact-optional-properties","sortIndex":1,"hash":"ae05ff5ec4444ec17065f851a0f9b41b752e31c333eb9e35f9bbf1fe1c47946e0cac6ddc883ea8e35b45e6c564f904289bc0cde6656f7d57fde01953327dc6dd","compilerSettings":{"ts":"4.4.2","exactOptionalPropertyTypes":true},"code":"// With exactOptionalPropertyTypes enabled, TypeScript\n// has stricter rules around what you can set an optional\n// property to.\n\n// For example, this interface declares that there is a\n// property which can be one of two strings: 'dark' or 'light'\n// or it should not be in the object.\n\ninterface UserDefaults {\n // The absence of a value represents 'system'\n colorThemeOverride?: \"dark\" | \"light\";\n}\n\n// Without this flag enabled, there are three values which you can\n// set `colorThemeOverride` to be: \"dark\", \"light\" and `undefined`.\n\n// Setting the value to `undefined` will allow most JavaScript runtime\n// checks for the existence to fail, which is effectively falsy.\n// However, there's a fuzziness to this behavior. The definition\n// for `colorThemeOverride` _doesn't_ include `undefined`.\n\n// The flag `exactOptionalPropertyTypes` makes TypeScript accurately\n// match the definition provided as an optional property.\n\ndeclare function getUserSettings(): UserDefaults;\n\nconst settings = getUserSettings();\nsettings.colorThemeOverride = \"dark\";\nsettings.colorThemeOverride = \"light\";\n\n// But not:\nsettings.colorThemeOverride = undefined;\n\n// Prior to the flag `exactOptionalPropertyTypes`, this was not possible."},{"path":["4-4","New TS Features"],"title":"Use unknown for Catch Variables","name":"Use unknown for Catch Variables.ts","lang":"en","id":"use-unknown-for-catch-variables","sortIndex":1,"hash":"427958b0f13a35d990c13527355a7fe57980ea28afef00d52dbf13e9bb1d0ba642a37fc6ee083de1b96bb75b5e436f1619ae86f9243587630e734b06a864553c","compilerSettings":{"ts":"4.4.2","useUnknownInCatchVariables":true},"code":"// If `unknown` is new to you, read: example:unknown-and-never\n\n// Since TypeScript 4.0, you have been able to change the type\n// of the variable in a catch statement from the default of\n// `any` to `unknown` by manually assigning the type:\n\ntry {\n // @ts-ignore\n iWillCrash();\n} catch (err) {\n // This implicitly means you _have_ to declare\n // what the type is before you can write code\n // against `err`:\n console.log(err.message);\n\n // For example, we have to verify it is an\n // error before using it as one.\n if (err instanceof Error) {\n console.log(err.message);\n }\n}\n\n// Also: example:unknown-in-catch\n\n// By using the option `useUnknownInCatchVariables`, you\n// can have the compiler default to `unknown` instead of\n// `any`. Effectively forcing all usage of that variable\n// to be confirmed by the type system before usage.\n\n// You can opt-out on one-off catch statements by assigning\n// the variable to `any`.\n\ntry {\n // @ts-ignore\n iWillCrash();\n} catch (err: any) {\n // In which case, you can treat it\n // however you would like.\n console.log(err.message);\n}"},{"path":["4-7","New JS Features"],"title":"Control Flow Improvements","name":"Control Flow Improvements.ts","lang":"en","id":"control-flow-improvements","sortIndex":1,"hash":"4d70b774633e9e757bdb633b7b3ffa4c7abc786df4472ff586fe5381de50c49d6abef9610cd82d5b86dfb4cd2e09e5f5126f6d4cc520ad7ead3b570fb5592c7e","compilerSettings":{"ts":"4.7.3"},"code":"// In almost every release there are control flow improvements,\n// in 4.7 there are improvements in computed properties work when\n// narrowing. To learn more about narrowing see: example:type-widening-and-narrowing\n\nconst dog = \"stringer\";\n\nconst dogsToOwnersOrID = {\n hayes: \"The McShanes\",\n poppy: \"Pat\",\n stringer: \"Jane\",\n otto: 1,\n} as Record;\n\n// A computed property is a property access which is not hardcoded,\n// for example:\n\nconst owner = dogsToOwnersOrID[dog];\n// ^?\n\n// Prior to TypeScript 4.7, using a computed property would not\n// reliably narrow the type of a value.\n\nif (typeof dogsToOwnersOrID[dog] === \"string\") {\n const str = dogsToOwnersOrID[dog].toUpperCase();\n // ^?\n\n // In 4.6, this would still be 'string | number'.\n}"},{"path":["4-7","New JS Features"],"title":"Improved Function Inference","name":"Improved Function Inference.ts","lang":"en","id":"improved-function-inference","sortIndex":1,"hash":"79a09d53c652104dac47e59ce019748e6dba27321bbf04d4d4d8b295b0ff514e9a2153a241c26b612b7e94a7ce6488bafbc77cc9e2c83812f568c15f0564de00","compilerSettings":{"ts":"4.7.3"},"code":"// With TypeScript 4.7, there are improvements in how types\n// are inferred from typed function calls.\n\n// Take this function 'cache', it takes one type parameter, 'Type'\n// and one function argument, which contains two functions.\n\ndeclare function cache(arg: { add: (n: string) => Type; process: (x: Type) => void }): void;\n\n// When TypeScript is trying to infer the type of 'Type', it\n// has two potential places to look:\n//\n// - the return of 'add'\n// - the first parameter of 'remove'\n\n// TypeScript's code inference now takes more these cases into\n// account with more code styles, and also handle cases where the\n// types depend on each other.\n\ncache({\n add: n => n,\n process: x => x.toLowerCase(),\n});\n\n// Here Type = string because `add` returns a string\n// which is the parameter type. However, prior versions of\n// TypeScript would infer Type = any/unknown because both\n// add and process would be evaluated at the same time.\n\ncache({\n add: function (str) {\n return { value: str + \"!\" };\n },\n process: x => x.value.toLowerCase(),\n});\n\n// Here Type = { value: string }\n\ncache({\n add() {\n return 23;\n },\n process: x => x + 1,\n});\n\n// Here Type = number\n\n// This helps JavaScript and TypeScript users experience\n// less accidental anys when working across different\n// code styles."},{"path":["4-7","New TS Features"],"title":"Extends Constraints","name":"Extends Constraints.ts","lang":"en","id":"extends-constraints","sortIndex":1,"hash":"6e20e71ecd760671aedad3fa2f77b6cf53e9dbcc1fb3cca28bb5907e9e39f537cf559686e8ab1ee72b32dc9fd207f0944b70d802386d40841be8695c48b2ce34","compilerSettings":{"ts":"4.7.3"},"code":"// In 4.7 TypeScript added support for inlining an extends clause\n// in a conditional type. This can help reduce the complexity of\n// conditional types.\n\n// If you're new to conditional types see: example:conditional-types\n\n// As an example, here is a 4.6 conditional type which looks\n// at the return value of a function and only returns a type\n// if the return type is a string.\n\n// prettier-ignore\ntype ReturnTypeOnlyStrings47 =\n T extends (...args: any[]) => \n infer R ? (R extends string ? R : never) : never;\n\n// It's essentially two if statements, one for the return type\n// and one then to check if the return type is a string. In\n// 4.7 this can be done inside one statement.\n\n// prettier-ignore\ntype ReturnType2 =\n T extends (...args: any[]) =>\n (infer R extends string) ? R : never;\n\n// It's a little like being able to use an && inside the conditional\n// type check, which makes code a bit simpler and more readable."},{"path":["4-7","New TS Features"],"title":"Instantiation Expressions","name":"Instantiation Expressions.ts","lang":"en","id":"instantiation-expressions","sortIndex":1,"hash":"d37e983075138a9be24f2f3d4fd048ec07d8ace81cbf3dad56e8de79ddcd8485635b7793ee2efe755fe5983daa4d61ee9bf06bb6638863fe3c843113a658fb01","compilerSettings":{"ts":"4.7.3"},"code":"// Prior to TypeScript 4.7, you would have to do call a function\n// in order to narrow a generic type to something specific. For\n// example, let's take a Map object:\n\nconst map = new Map();\n// ^?\n\n// This map uses strings for keys, and numbers for values. Until\n// we created the map, the values for key (string) and value (number)\n// were yet to be defined and could still be anything.\n\n// Instantiation expressions means that we can create a version of the\n// Map function which will always accept only strings for keys and \n// numbers for values:\n\nconst MapStrNum = Map;\n\nconst map2 = new MapStrNum()\n// ^?\n\n// This feature allows us to elegantly create a more specific typed\n// functions without having to wrap the function in another function."},{"path":["JavaScript","External APIs"],"title":"TypeScript with Deno","name":"TypeScript with Deno.ts","lang":"en","id":"typescript-with-deno","sortIndex":3,"hash":"49e2032853406f96c7b3c8a3d43204577f3c41fd45f5ea1a1070f9bfbf03291e703946589e8b61d146a09bd8ad24cd16d35482e213c18e218ef8155255ea81dc","code":"// Deno is a work-in-progress JavaScript and TypeScript\n// runtime based on v8 with a focus on security.\n\n// https://deno.land\n\n// Deno has a sandbox-based permissions system which reduces the\n// access JavaScript has to the file-system or the network and uses\n// http based imports which are downloaded and cached locally.\n\n// Here is an example of using deno for scripting:\n\nimport compose from \"https://deno.land/x/denofun/lib/compose.ts\";\n\nfunction greet(name: string) {\n return `Hello, ${name}!`;\n}\n\nfunction makeLoud(x: string) {\n return x.toUpperCase();\n}\n\nconst greetLoudly = compose(makeLoud, greet);\n\n// Echos \"HELLO, WORLD!.\"\ngreetLoudly(\"world\");\n\nimport concat from \"https://deno.land/x/denofun/lib/concat.ts\";\n\n// Returns \"helloworld\"\nconcat(\"hello\", \"world\");"},{"path":["JavaScript","External APIs"],"title":"TypeScript with Node","name":"TypeScript with Node.js","lang":"en","id":"typescript-with-node","sortIndex":3,"hash":"8493b066343d2fa1c854b3a543f9286f34f85017da281b685b786e2e0f1774bd611b4b653207b998c8f43e14d6b26e482037e6fb87ddb79cd1a0b6200f8d49a5","code":"// Node.js is a very popular JavaScript runtime built on v8,\n// the JavaScript engine which powers Chrome. You can use it\n// to build servers, front-end clients and anything in-between.\n\n// https://nodejs.org/\n\n// Node.js comes with a set of core libraries which extend the\n// JavaScript runtime. They range from path handling:\n\nimport { join } from \"path\";\nconst myPath = join(\"~\", \"downloads\", \"todo_list.json\");\n\n// To file manipulation:\n\nimport { readFileSync } from \"fs\";\nconst todoListText = readFileSync(myPath, \"utf8\");\n\n// You can incrementally add types to your JavaScript projects\n// using JSDoc-style type. We'll make one for our TODO list item\n// based on the JSON structure:\n\n/**\n * @typedef {Object} TODO a TODO item\n * @property {string} title The display name for the TODO item\n * @property {string} body The description of the TODO item\n * @property {boolean} done Whether the TODO item is completed\n */\n\n// Now assign that to the return value of JSON.parse\n// to learn more about this, see: example:jsdoc-support\n\n/** @type {TODO[]} a list of TODOs */\nconst todoList = JSON.parse(todoListText);\n\n// And process handling:\nimport { spawnSync } from \"child_process\";\ntodoList\n .filter(todo => !todo.done)\n .forEach(todo => {\n // Use the ghi client to create an issue for every todo\n // list item which hasn't been completed yet.\n\n // Note that you get correct auto-complete and\n // docs in JS when you highlight 'todo.title' below.\n spawnSync(`ghi open --message \"${todo.title}\\n${todo.body}\"`);\n });\n\n// TypeScript has up-to-date type definitions for all of the\n// built in modules via DefinitelyTyped - which means you\n// can write node programs with strong type coverage."},{"path":["JavaScript","External APIs"],"title":"TypeScript with React","name":"TypeScript with React.tsx","lang":"en","id":"typescript-with-react","sortIndex":2,"hash":"83f31dfe4570b928766bcc80baac0a0ecf42412c5820bd603154a3e276ecfd5c77c3685d1176b69e16a1ca7b90207110eb63b3b68b1994f7e134922af9d66785","compilerSettings":{"jsx":2,"esModuleInterop":true},"code":"// React is a popular library for creating user interfaces.\n// It provides a JavaScript abstraction for creating view\n// components using a JavaScript language extension called\n// JSX.\n\n// TypeScript supports JSX, and provides a rich set of\n// type tools to richly model how components connect.\n\n// To understand how TypeScript works with React components\n// you may want a primer on generics:\n//\n// - example:generic-functions\n// - example:generic-classes\n\n// First we'll look at how generic interfaces are used to map\n// React components. This is a faux-React functional component:\n\ntype FauxactFunctionComponent = (\n props: Props,\n context?: any\n) => FauxactFunctionComponent | null | JSX.Element;\n\n// Roughly:\n//\n// FauxactFunctionComponent is a generic function which relies on\n// another type, Props. Props has to be an object (to make sure\n// you don't pass a primitive) and the Props type will be\n// re-used as the first argument in the function.\n\n// To use it, you need a props type:\n\ninterface DateProps {\n iso8601Date: string;\n message: string;\n}\n\n// We can then create a DateComponent which uses the\n// DateProps interface, and renders the date.\n\nconst DateComponent: FauxactFunctionComponent = props => (\n {props.message} \n);\n\n// This creates a function which is generic with a Props\n// variable which has to be an object. The component function\n// returns either another component function or null.\n\n// The other component API is a class-based one. Here's a\n// simplified version of that API:\n\ninterface FauxactClassComponent {\n props: Props;\n state: State;\n\n setState: (prevState: State, props: Props) => Props;\n callback?: () => void;\n render(): FauxactClassComponent | null;\n}\n\n// Because this class can have both Props and State - it has\n// two generic arguments which are used throughout the class.\n\n// The React library comes with its own type definitions\n// like these but are much more comprehensive. Let's bring\n// those into our playground and explore a few components.\n\nimport * as React from \"react\";\n\n// Your props are your public API, so it's worth taking the\n// time to use JSDoc to explain how it works:\n\nexport interface Props {\n /** The user's name */\n name: string;\n /** Should the name be rendered in bold */\n priority?: boolean;\n}\n\nconst PrintName: React.FC = props => {\n return (\n \n );\n};\n\n// You can play with the new component's usage below:\n\nconst ShowUser: React.FC = props => {\n return ;\n};\n\n// TypeScript supports providing intellisense inside\n// the {} in an attribute\n\nlet username = \"Cersei\";\nconst ShowStoredUser: React.FC = props => {\n return ;\n};\n\n// TypeScript works with modern React code too, here you can\n// see that count and setCount have correctly been inferred\n// to use numbers based on the initial value passed into\n// useState.\n\nimport { useState, useEffect } from \"react\";\n\nconst CounterExample = () => {\n const [count, setCount] = useState(0);\n\n useEffect(() => {\n document.title = `You clicked ${count} times`;\n });\n\n return (\n \n
You clicked {count} times
\n
setCount(count + 1)}>Click me \n
\n );\n};\n\n// React and TypeScript is a really, really big topic\n// but the fundamentals are pretty small: TypeScript\n// supports JSX, and the rest is handled by the React\n// typings from Definitely Typed.\n\n// You can learn more about using React with TypeScript\n// from these sites:\n//\n// https://github.com/typescript-cheatsheets/react-typescript-cheatsheet\n// https://egghead.io/courses/use-typescript-to-develop-react-applications\n// https://levelup.gitconnected.com/ultimate-react-component-patterns-with-typescript-2-8-82990c516935"},{"path":["JavaScript","External APIs"],"title":"TypeScript with Web","name":"TypeScript with Web.js","lang":"en","id":"typescript-with-web","sortIndex":1,"hash":"4f3be315ea8b94f9349315cdc99e954d05491f7c2e0d1881abefaa6433a23cdf6df6b09b397376113d374508ade521ff745d8294ab8b4ee6d609332e959f3761","code":"// The DOM (Document Object Model) is the underlying API for\n// working with a webpage, and TypeScript has great support\n// for that API.\n\n// Let's create a popover to show when you press \"Run\" in\n// the toolbar above.\n\nconst popover = document.createElement(\"div\");\npopover.id = \"example-popover\";\n\n// Note that popover is correctly typed to be a HTMLDivElement\n// specifically because we passed in \"div\".\n\n// To make it possible to re-run this code, we'll first\n// add a function to remove the popover if it was already there.\n\nconst removePopover = () => {\n const existingPopover = document.getElementById(popover.id);\n if (existingPopover && existingPopover.parentElement) {\n existingPopover.parentElement.removeChild(existingPopover);\n }\n};\n\n// Then call it right away.\n\nremovePopover();\n\n// We can set the inline styles on the element via the\n// .style property on a HTMLElement - this is fully typed.\n\npopover.style.backgroundColor = \"#0078D4\";\npopover.style.color = \"white\";\npopover.style.border = \"1px solid black\";\npopover.style.position = \"fixed\";\npopover.style.bottom = \"10px\";\npopover.style.left = \"20px\";\npopover.style.width = \"200px\";\npopover.style.height = \"100px\";\npopover.style.padding = \"10px\";\n\n// Including more obscure, or deprecated CSS attributes.\npopover.style.webkitBorderRadius = \"4px\";\n\n// To add content to the popover, we'll need to add\n// a paragraph element and use it to add some text.\n\nconst message = document.createElement(\"p\");\nmessage.textContent = \"Here is an example popover\";\n\n// And we'll also add a close button.\n\nconst closeButton = document.createElement(\"a\");\ncloseButton.textContent = \"X\";\ncloseButton.style.position = \"absolute\";\ncloseButton.style.top = \"3px\";\ncloseButton.style.right = \"8px\";\ncloseButton.style.color = \"white\";\ncloseButton.style.cursor = \"pointer\";\n\ncloseButton.onclick = () => {\n removePopover();\n};\n\n// Then add all of these elements on to the page.\npopover.appendChild(message);\npopover.appendChild(closeButton);\ndocument.body.appendChild(popover);\n\n// If you hit \"Run\" above, then a popup should appear\n// in the bottom left, which you can close by clicking\n// on the x in the top right of the popup.\n\n// This example shows how you can work with the DOM API\n// in JavaScript - but using TypeScript to provide great\n// tooling support.\n\n// There is an extended example for TypeScript tooling with\n// WebGL available here: example:typescript-with-webgl"},{"path":["JavaScript","External APIs"],"title":"TypeScript with WebGL","name":"TypeScript with WebGL.js","lang":"en","id":"typescript-with-webgl","sortIndex":5,"hash":"a5ef2ec58e8ef7278fd5c3e171f8f170aef73e57fceca6bc1f819666ee34a903c50faa848443b0b9085adc28f48b5d2e48675174ef877bb8e08aaa03ca17fa90","code":"// This example creates an HTML canvas which uses WebGL to\n// render spinning confetti using JavaScript. We're going\n// to walk through the code to understand how it works, and\n// see how TypeScript's tooling provides useful insight.\n\n// This example builds off: example:working-with-the-dom\n\n// First up, we need to create an HTML canvas element, which\n// we do via the DOM API and set some inline style attributes:\n\nconst canvas = document.createElement(\"canvas\");\ncanvas.id = \"spinning-canvas\";\ncanvas.style.backgroundColor = \"#0078D4\";\ncanvas.style.position = \"fixed\";\ncanvas.style.bottom = \"10px\";\ncanvas.style.right = \"20px\";\ncanvas.style.width = \"500px\";\ncanvas.style.height = \"400px\";\ncanvas.style.zIndex = \"100\";\n\n// Next, to make it easy to make changes, we remove any older\n// versions of the canvas when hitting \"Run\" - now you can\n// make changes and see them reflected when you press \"Run\"\n// or (cmd + enter):\n\nconst existingCanvas = document.getElementById(canvas.id);\nif (existingCanvas && existingCanvas.parentElement) {\n existingCanvas.parentElement.removeChild(existingCanvas);\n}\n\n// Tell the canvas element that we will use WebGL to draw\n// inside the element (and not the default raster engine):\n\nconst gl = canvas.getContext(\"webgl\");\n\n// Next we need to create vertex shaders - these roughly are\n// small programs that apply maths to a set of incoming\n// array of vertices (numbers).\n\n// You can see the large set of attributes at the top of the shader,\n// these are passed into the compiled shader further down the example.\n\n// There's a great overview on how they work here:\n// https://webglfundamentals.org/webgl/lessons/webgl-how-it-works.html\n\nconst vertexShader = gl.createShader(gl.VERTEX_SHADER);\ngl.shaderSource(\n vertexShader,\n `\nprecision lowp float;\n\nattribute vec2 a_position; // Flat square on XY plane\nattribute float a_startAngle;\nattribute float a_angularVelocity;\nattribute float a_rotationAxisAngle;\nattribute float a_particleDistance;\nattribute float a_particleAngle;\nattribute float a_particleY;\nuniform float u_time; // Global state\n\nvarying vec2 v_position;\nvarying vec3 v_color;\nvarying float v_overlight;\n\nvoid main() {\n float angle = a_startAngle + a_angularVelocity * u_time;\n float vertPosition = 1.1 - mod(u_time * .25 + a_particleY, 2.2);\n float viewAngle = a_particleAngle + mod(u_time * .25, 6.28);\n\n mat4 vMatrix = mat4(\n 1.3, 0.0, 0.0, 0.0,\n 0.0, 1.3, 0.0, 0.0,\n 0.0, 0.0, 1.0, 1.0,\n 0.0, 0.0, 0.0, 1.0\n );\n\n mat4 shiftMatrix = mat4(\n 1.0, 0.0, 0.0, 0.0,\n 0.0, 1.0, 0.0, 0.0,\n 0.0, 0.0, 1.0, 0.0,\n a_particleDistance * sin(viewAngle), vertPosition, a_particleDistance * cos(viewAngle), 1.0\n );\n\n mat4 pMatrix = mat4(\n cos(a_rotationAxisAngle), sin(a_rotationAxisAngle), 0.0, 0.0,\n -sin(a_rotationAxisAngle), cos(a_rotationAxisAngle), 0.0, 0.0,\n 0.0, 0.0, 1.0, 0.0,\n 0.0, 0.0, 0.0, 1.0\n ) * mat4(\n 1.0, 0.0, 0.0, 0.0,\n 0.0, cos(angle), sin(angle), 0.0,\n 0.0, -sin(angle), cos(angle), 0.0,\n 0.0, 0.0, 0.0, 1.0\n );\n\n gl_Position = vMatrix * shiftMatrix * pMatrix * vec4(a_position * 0.03, 0.0, 1.0);\n vec4 normal = vec4(0.0, 0.0, 1.0, 0.0);\n vec4 transformedNormal = normalize(pMatrix * normal);\n\n float dotNormal = abs(dot(normal.xyz, transformedNormal.xyz));\n float regularLighting = dotNormal / 2.0 + 0.5;\n float glanceLighting = smoothstep(0.92, 0.98, dotNormal);\n v_color = vec3(\n mix((0.5 - transformedNormal.z / 2.0) * regularLighting, 1.0, glanceLighting),\n mix(0.5 * regularLighting, 1.0, glanceLighting),\n mix((0.5 + transformedNormal.z / 2.0) * regularLighting, 1.0, glanceLighting)\n );\n\n v_position = a_position;\n v_overlight = 0.9 + glanceLighting * 0.1;\n}\n`\n);\ngl.compileShader(vertexShader);\n\n// This example also uses fragment shaders - a fragment\n// shader is another small program that runs through every\n// pixel in the canvas and sets its color.\n\n// In this case, if you play around with the numbers you can see how\n// this affects the lighting in the scene, as well as the border\n// radius on the confetti:\n\nconst fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);\ngl.shaderSource(\n fragmentShader,\n `\nprecision lowp float;\nvarying vec2 v_position;\nvarying vec3 v_color;\nvarying float v_overlight;\n\nvoid main() {\n gl_FragColor = vec4(v_color, 1.0 - smoothstep(0.8, v_overlight, length(v_position)));\n}\n`\n);\ngl.compileShader(fragmentShader);\n\n// Takes the compiled shaders and adds them to the canvas'\n// WebGL context so that can be used:\n\nconst shaderProgram = gl.createProgram();\ngl.attachShader(shaderProgram, vertexShader);\ngl.attachShader(shaderProgram, fragmentShader);\ngl.linkProgram(shaderProgram);\ngl.useProgram(shaderProgram);\n\ngl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());\n\n// We need to get/set the input variables into the shader in a\n// memory-safe way, so the order and the length of their\n// values needs to be stored.\n\nconst attrs = [\n { name: \"a_position\", length: 2, offset: 0 }, // e.g. x and y represent 2 spaces in memory\n { name: \"a_startAngle\", length: 1, offset: 2 }, // but angle is just 1 value\n { name: \"a_angularVelocity\", length: 1, offset: 3 },\n { name: \"a_rotationAxisAngle\", length: 1, offset: 4 },\n { name: \"a_particleDistance\", length: 1, offset: 5 },\n { name: \"a_particleAngle\", length: 1, offset: 6 },\n { name: \"a_particleY\", length: 1, offset: 7 },\n];\n\nconst STRIDE = Object.keys(attrs).length + 1;\n\n// Loop through our known attributes and create pointers in memory for the JS side\n// to be able to fill into the shader.\n\n// To understand this API a little bit: WebGL is based on OpenGL\n// which is a state-machine styled API. You pass in commands in a\n// particular order to render things to the screen.\n\n// So, the intended usage is often not passing objects to every WebGL\n// API call, but instead passing one thing to one function, then passing\n// another to the next. So, here we prime WebGL to create an array of\n// vertex pointers:\n\nfor (var i = 0; i < attrs.length; i++) {\n const name = attrs[i].name;\n const length = attrs[i].length;\n const offset = attrs[i].offset;\n const attribLocation = gl.getAttribLocation(shaderProgram, name);\n gl.vertexAttribPointer(attribLocation, length, gl.FLOAT, false, STRIDE * 4, offset * 4);\n gl.enableVertexAttribArray(attribLocation);\n}\n\n// Then on this line they are bound to an array in memory:\n\ngl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());\n\n// Set up some constants for rendering:\n\nconst NUM_PARTICLES = 200;\nconst NUM_VERTICES = 4;\n\n// Try reducing this one and hitting \"Run\" again,\n// it represents how many points should exist on\n// each confetti and having an odd number sends\n// it way out of whack.\n\nconst NUM_INDICES = 6;\n\n// Create the arrays of inputs for the vertex shaders\nconst vertices = new Float32Array(NUM_PARTICLES * STRIDE * NUM_VERTICES);\nconst indices = new Uint16Array(NUM_PARTICLES * NUM_INDICES);\n\nfor (let i = 0; i < NUM_PARTICLES; i++) {\n const axisAngle = Math.random() * Math.PI * 2;\n const startAngle = Math.random() * Math.PI * 2;\n const groupPtr = i * STRIDE * NUM_VERTICES;\n\n const particleDistance = Math.sqrt(Math.random());\n const particleAngle = Math.random() * Math.PI * 2;\n const particleY = Math.random() * 2.2;\n const angularVelocity = Math.random() * 2 + 1;\n\n for (let j = 0; j < 4; j++) {\n const vertexPtr = groupPtr + j * STRIDE;\n vertices[vertexPtr + 2] = startAngle; // Start angle\n vertices[vertexPtr + 3] = angularVelocity; // Angular velocity\n vertices[vertexPtr + 4] = axisAngle; // Angle diff\n vertices[vertexPtr + 5] = particleDistance; // Distance of the particle from the (0,0,0)\n vertices[vertexPtr + 6] = particleAngle; // Angle around Y axis\n vertices[vertexPtr + 7] = particleY; // Angle around Y axis\n }\n\n // Coordinates\n vertices[groupPtr] = vertices[groupPtr + STRIDE * 2] = -1;\n vertices[groupPtr + STRIDE] = vertices[groupPtr + STRIDE * 3] = +1;\n vertices[groupPtr + 1] = vertices[groupPtr + STRIDE + 1] = -1;\n vertices[groupPtr + STRIDE * 2 + 1] = vertices[groupPtr + STRIDE * 3 + 1] = +1;\n\n const indicesPtr = i * NUM_INDICES;\n const vertexPtr = i * NUM_VERTICES;\n indices[indicesPtr] = vertexPtr;\n indices[indicesPtr + 4] = indices[indicesPtr + 1] = vertexPtr + 1;\n indices[indicesPtr + 3] = indices[indicesPtr + 2] = vertexPtr + 2;\n indices[indicesPtr + 5] = vertexPtr + 3;\n}\n\n// Pass in the data to the WebGL context\ngl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);\ngl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);\n\nconst timeUniformLocation = gl.getUniformLocation(shaderProgram, \"u_time\");\nconst startTime = (window.performance || Date).now();\n\n// Start the background colour as black\ngl.clearColor(0, 0, 0, 1);\n\n// Allow alpha channels on in the vertex shader\ngl.enable(gl.BLEND);\ngl.blendFunc(gl.SRC_ALPHA, gl.ONE);\n\n// Set the WebGL context to be the full size of the canvas\ngl.viewport(0, 0, canvas.width, canvas.height);\n\n// Create a run-loop to draw all of the confetti\n(function frame() {\n gl.uniform1f(timeUniformLocation, ((window.performance || Date).now() - startTime) / 1000);\n\n gl.clear(gl.COLOR_BUFFER_BIT);\n gl.drawElements(gl.TRIANGLES, NUM_INDICES * NUM_PARTICLES, gl.UNSIGNED_SHORT, 0);\n requestAnimationFrame(frame);\n})();\n\n// Add the new canvas element into the bottom left\n// of the playground\ndocument.body.appendChild(canvas);\n\n// Credit: based on this JSFiddle by Subzey\n// https://jsfiddle.net/subzey/52sowezj/"},{"path":["JavaScript","Functions with JavaScript"],"title":"Function Chaining","name":"Function Chaining.ts","lang":"en","id":"function-chaining","sortIndex":2,"hash":"2c05aa57e2420890a6a288782aa1bdf0e6b670650b56d5ea19fc9afc57e24ab85ba19d0b23a2ffe9f16fb8725e69ff9e0912d6e3ef71776269b38a8f50c09bf2","compilerSettings":{"esModuleInterop":true},"code":"// Function chaining APIs are a common pattern in\n// JavaScript, which can make your code focused\n// with less intermediary values and easier to read\n// because of their nesting qualities.\n\n// A really common API which works via chaining\n// is jQuery. Here is an example of jQuery\n// being used with the types from DefinitelyTyped:\n\nimport $ from \"jquery\";\n\n// Here's an example use of the jQuery API:\n\n$(\"#navigation\").css(\"background\", \"red\").height(300).fadeIn(200);\n\n// If you add a dot on the line above, you'll see\n// a long list of functions. This pattern is easy to\n// reproduce in JavaScript. The key is to make sure\n// you always return the same object.\n\n// Here is an example API which creates a chaining\n// API. The key is to have an outer function which\n// keeps track of internal state, and an object which\n// exposes the API that is always returned.\n\nconst addTwoNumbers = (start = 1) => {\n let n = start;\n\n const api = {\n // Implement each function in your API\n add(inc: number = 1) {\n n += inc;\n return api;\n },\n\n print() {\n console.log(n);\n return api;\n },\n };\n return api;\n};\n\n// Which allows the same style of API as we\n// saw in jQuery:\n\naddTwoNumbers(1).add(3).add().print().add(1);\n\n// Here's a similar example which uses a class:\n\nclass AddNumbers {\n private n: number;\n\n constructor(start = 0) {\n this.n = start;\n }\n\n public add(inc = 1) {\n this.n = this.n + inc;\n return this;\n }\n\n public print() {\n console.log(this.n);\n return this;\n }\n}\n\n// Here it is in action:\n\nnew AddNumbers(2).add(3).add().print().add(1);\n\n// This example used the TypeScript\n// type inference to provide a way to\n// provide tooling to JavaScript patterns.\n\n// For more examples on this:\n//\n// - example:code-flow"},{"path":["JavaScript","Functions with JavaScript"],"title":"Generic Functions","name":"Generic Functions.ts","lang":"en","id":"generic-functions","sortIndex":1,"hash":"3c8d1eb865e4f035b1acdf529ba94526984f1bbd82650b9175f846529528f9497207fb264d0b5055b83edf62f286dfda505182f22c7ff8b0d9f214b6750f7577","compilerSettings":{},"code":"// Generics provide a way to use Types as variables in other\n// types. Meta.\n\n// We'll be trying to keep this example light, you can do\n// a lot with generics and it's likely you will see some very\n// complicated code using generics at some point - but that\n// does not mean that generics are complicated.\n\n// Let's start with an example where we wrap an input object\n// in an array. We will only care about one variable in this\n// case, the type which was passed in:\n\nfunction wrapInArray(input: Type): Type[] {\n return [input];\n}\n\n// Note: it's common to see Type referred to as T. This is\n// culturally similar to how people use i in a for loop to\n// represent index. T normally represents Type, so we'll\n// be using the full name for clarity.\n\n// Our function will use inference to always keep the type\n// passed in the same as the type passed out (though\n// it will be wrapped in an array).\n\nconst stringArray = wrapInArray(\"hello generics\");\nconst numberArray = wrapInArray(123);\n\n// We can verify this works as expected by checking\n// if we can assign a string array to a function which\n// should be an object array:\nconst notStringArray: string[] = wrapInArray({});\n\n// You can also skip the generic inference by adding the\n// type yourself also:\nconst stringArray2 = wrapInArray(\"\");\n\n// wrapInArray allows any type to be used, however there\n// are cases when you need to only allow a subset of types.\n// In these cases you can say the type has to extend a\n// particular type.\n\ninterface Drawable {\n draw: () => void;\n}\n\n// This function takes a set of objects which have a function\n// for drawing to the screen\nfunction renderToScreen(input: Type[]) {\n input.forEach((i) => i.draw());\n}\n\nconst objectsWithDraw = [{ draw: () => {} }, { draw: () => {} }];\nrenderToScreen(objectsWithDraw);\n\n// It will fail if draw is missing:\n\nrenderToScreen([{}, { draw: () => {} }]);\n\n// Generics can start to look complicated when you have\n// multiple variables. Here is an example of a caching\n// function that lets you have different sets of input types\n// and caches.\n\ninterface CacheHost {\n save: (a: any) => void;\n}\n\nfunction addObjectToCache