From 152e735c68eade51bbabeed1d1b370fa1b18cfdb Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Fri, 26 May 2023 07:46:38 +0000 Subject: [PATCH] chore: (WIP) save current progress --- ambient.d.ts | 3 + cloudflare.ts | 37 ----- compilerOptions.ts | 207 +++++++++++++++++++++++++ complex-service.ts | 17 --- deps.ts | 3 + getInitialCode.ts | 31 ++++ index.ts | 360 ++++++++++++++++++++++++++++++++++++++++++++ release_data.ts | 7 + scripts/releases.ts | 62 ++++++++ tsserver.ts | 126 ++++++++++++++++ twoslashSupport.ts | 183 ++++++++++++++++++++++ types.ts | 30 ++++ 12 files changed, 1012 insertions(+), 54 deletions(-) create mode 100644 ambient.d.ts delete mode 100644 cloudflare.ts create mode 100644 compilerOptions.ts delete mode 100644 complex-service.ts create mode 100644 deps.ts create mode 100644 getInitialCode.ts create mode 100644 index.ts create mode 100644 release_data.ts create mode 100644 scripts/releases.ts create mode 100644 tsserver.ts create mode 100644 twoslashSupport.ts create mode 100644 types.ts diff --git a/ambient.d.ts b/ambient.d.ts new file mode 100644 index 0000000..d4ffb8a --- /dev/null +++ b/ambient.d.ts @@ -0,0 +1,3 @@ +declare module 'vfile-message' { + export interface VFileMessage {} + } \ No newline at end of file diff --git a/cloudflare.ts b/cloudflare.ts deleted file mode 100644 index 5f942b5..0000000 --- a/cloudflare.ts +++ /dev/null @@ -1,37 +0,0 @@ -const DENO_TYPE_PATH = "/@deno-types"; - -export default { - async fetch(request, env) { - try { - const { pathname, searchParams } = new URL(request.url); - - if (pathname === "/") - return Response.redirect("https://github.com/okikio/deno-github-proxy"); - - const url = new URL(pathname.replace(DENO_TYPE_PATH, ""), `https://raw.githubusercontent.com`); - const res = await fetch(url); - - const headers = Object.fromEntries([...res.headers]); - const typescriptTypes = new URL(DENO_TYPE_PATH + url.pathname, "https://github-ts.okikio.workers.dev"); - const finalHeaders = { - ...headers, - ...Object.fromEntries([ - ["content-type", searchParams.has("js") ? "text/javascript" : "text/typescript"], - ['accept-ranges', 'bytes'], - ['access-control-allow-origin', '*'], - ['cache-control', 'max-age=30, public'], - ...(!pathname.startsWith(DENO_TYPE_PATH) ? [['x-typescript-types', typescriptTypes.toString()]] : []) - ]) - }; - - return new Response(await res.arrayBuffer(), { - headers: finalHeaders, - status: 200, - }); - } catch(e) { - return new Response(JSON.stringify({ - err - }), { status: 500 }) - } - } -} \ No newline at end of file diff --git a/compilerOptions.ts b/compilerOptions.ts new file mode 100644 index 0000000..1ab5de9 --- /dev/null +++ b/compilerOptions.ts @@ -0,0 +1,207 @@ +import type { SandboxConfig, CompilerOptions } from "./types.ts" +import type { Sandbox } from "./index.ts" + +import Typescript from "https://esm.sh/typescript@5.0.4"; + +/** + * These are the defaults, but they also act as the list of all compiler options + * which are parsed in the query params. + */ +export function getDefaultSandboxCompilerOptions( + config: SandboxConfig, + ts: { versionMajorMinor: string } +) { + const [major] = ts.versionMajorMinor.split(".").map(v => parseInt(v)) as [number, number] + const useJavaScript = config.filetype === "js" + const settings: CompilerOptions = { + strict: true, + + noImplicitAny: true, + strictNullChecks: !useJavaScript, + strictFunctionTypes: true, + strictPropertyInitialization: true, + strictBindCallApply: true, + noImplicitThis: true, + noImplicitReturns: true, + noUncheckedIndexedAccess: false, + + // 3.7 off, 3.8 on I think + useDefineForClassFields: false, + + alwaysStrict: true, + allowUnreachableCode: false, + allowUnusedLabels: false, + + downlevelIteration: false, + noEmitHelpers: false, + noLib: false, + noStrictGenericChecks: false, + noUnusedLocals: false, + noUnusedParameters: false, + + esModuleInterop: true, + preserveConstEnums: false, + removeComments: false, + skipLibCheck: false, + + checkJs: useJavaScript, + allowJs: useJavaScript, + declaration: true, + + importHelpers: false, + + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: Typescript.ModuleResolutionKind.NodeNext, + + target: Typescript.ScriptTarget.ES2017, + jsx: Typescript.JsxEmit.React, + module: Typescript.ModuleKind.ESNext, + } + + if (major >= 5) { + settings.experimentalDecorators = false + settings.emitDecoratorMetadata = false + } + + return { ...settings, ...config.compilerOptions } +} + +/** + * Loop through all of the entries in the existing compiler options then compare them with the + * query params and return an object which is the changed settings via the query params + */ +export const getCompilerOptionsFromParams = ( + playgroundDefaults: CompilerOptions, + ts: typeof Typescript, + params: URLSearchParams +): CompilerOptions => { + const returnedOptions: CompilerOptions = {} + + params.forEach((val, key) => { + // First use the defaults object to drop compiler flags which are already set to the default + if (playgroundDefaults[key]) { + let toSet = undefined + if (val === "true" && playgroundDefaults[key] !== true) { + toSet = true + } else if (val === "false" && playgroundDefaults[key] !== false) { + toSet = false + } else if (!isNaN(parseInt(val, 10)) && playgroundDefaults[key] !== parseInt(val, 10)) { + toSet = parseInt(val, 10) + } + + if (toSet !== undefined) returnedOptions[key] = toSet + } else { + // If that doesn't work, double check that the flag exists and allow it through + // @ts-ignore: Typescript isn't happy + const flagExists = ts.optionDeclarations.find(opt => opt.name === key) + if (flagExists) { + let realValue: number | boolean = true + if (val === "false") realValue = false + if (!isNaN(parseInt(val, 10))) realValue = parseInt(val, 10) + returnedOptions[key] = realValue + } + } + }) + + return returnedOptions +} + +// Can't set sandbox to be the right type because the param would contain this function + +/** Gets a query string representation (hash + queries) */ +export const createURLQueryWithCompilerOptions = (location: Location | URL, _sandbox: any, paramOverrides?: any): string => { + const sandbox = _sandbox as Sandbox + const initialOptions = new URLSearchParams(location.search) + + const compilerOptions = sandbox.getCompilerOptions() + const compilerDefaults = sandbox.compilerDefaults + const diff = Object.entries(compilerOptions).reduce((acc, [key, value]) => { + if (value !== compilerDefaults[key]) { + // @ts-ignore + acc[key] = compilerOptions[key] + } + + return acc + }, {}) + + // The text of the TS/JS as the hash + const hash = `code/${sandbox.lzstring.compressToEncodedURIComponent(sandbox.getText())}` + + let urlParams: any = Object.assign({}, diff) + for (const param of ["lib", "ts"]) { + const params = new URLSearchParams(location.search) + if (params.has(param)) { + // Special case the nightly where it uses the TS version to hardcode + // the nightly build + if (param === "ts" && (params.get(param) === "Nightly" || params.get(param) === "next")) { + urlParams["ts"] = sandbox.ts.version + } else { + urlParams["ts"] = params.get(param) + } + } + } + + // Support sending the selection, but only if there is a selection, and it's not the whole thing + const s = sandbox.editor.getSelection() + + const isNotEmpty = + (s && s.selectionStartLineNumber !== s.positionLineNumber) || (s && s.selectionStartColumn !== s.positionColumn) + + const range = sandbox.editor.getModel()!.getFullModelRange() + const isFull = + s && + s.selectionStartLineNumber === range.startLineNumber && + s.selectionStartColumn === range.startColumn && + s.positionColumn === range.endColumn && + s.positionLineNumber === range.endLineNumber + + if (s && isNotEmpty && !isFull) { + urlParams["ssl"] = s.selectionStartLineNumber + urlParams["ssc"] = s.selectionStartColumn + urlParams["pln"] = s.positionLineNumber + urlParams["pc"] = s.positionColumn + } else { + urlParams["ssl"] = undefined + urlParams["ssc"] = undefined + urlParams["pln"] = undefined + urlParams["pc"] = undefined + } + + if (sandbox.config.filetype !== "ts") urlParams["filetype"] = sandbox.config.filetype + + if (paramOverrides) { + urlParams = { ...urlParams, ...paramOverrides } + } + + // @ts-ignore - this is in MDN but not libdom + const hasInitialOpts = initialOptions.keys().length > 0 + + if (Object.keys(urlParams).length > 0 || hasInitialOpts) { + let queryString = Object.entries(urlParams) + .filter(([_k, v]) => v !== undefined) + .filter(([_k, v]) => v !== null) + .map(([key, value]) => { + return `${key}=${encodeURIComponent(value as string)}` + }) + .join("&") + + // We want to keep around custom query variables, which + // are usually used by playground plugins, with the exception + // being the install-plugin param and any compiler options + // which have a default value + + initialOptions.forEach((value, key) => { + const skip = ["ssl", "ssc", "pln", "pc"] + if (skip.includes(key)) return + if (queryString.includes(key)) return + if (compilerOptions[key]) return + + queryString += `&${key}=${value}` + }) + + return `?${queryString}#${hash}` + } else { + return `#${hash}` + } +} \ No newline at end of file diff --git a/complex-service.ts b/complex-service.ts deleted file mode 100644 index b05b3a8..0000000 --- a/complex-service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as neo4j from 'https://github-ts.okikio.workers.dev/neo4j/neo4j-javascript-driver/5.0/packages/neo4j-driver-deno/lib/mod.ts'; - -/** - * Create a new driver instance to connect to Neo4j - * @type {neo4j.Driver} - * @see https://neo4j.com/docs/api/javascript-driver/current/class/src/driver.js~Driver.html - * @see https://neo4j.com/docs/api/javascript-driver/current/global.html#Config - */ -const driver: neo4j.Driver = neo4j.driver( - Deno.env.get('NEO4J_URI') as string, - neo4j.auth.basic( - Deno.env.get('NEO4J_USERNAME') as string, - Deno.env.get('NEO4J_PASSWORD') as string - ) -); - -export default driver; \ No newline at end of file diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..53cf506 --- /dev/null +++ b/deps.ts @@ -0,0 +1,3 @@ +export { default as Typescript } from "https://esm.sh/typescript@5.0.4" +export * as tsvfs from "https://esm.sh/@typescript/vfs@1.4.0" +export * as ata from "https://esm.sh/@typescript/ata@0.9.3" \ No newline at end of file diff --git a/getInitialCode.ts b/getInitialCode.ts new file mode 100644 index 0000000..ffc666f --- /dev/null +++ b/getInitialCode.ts @@ -0,0 +1,31 @@ +import { decompressFromEncodedURIComponent } from "npm:@amoutonbrady/lz-string" + +/** + * Grabs the sourcecode for an example from the query hash or local storage + * @param fallback if nothing is found return this + * @param location DI'd copy of document.location + */ +export const getInitialCode = (fallback: string, location: URL | Location) => { + // Old school support + if (location.hash.startsWith("#src")) { + const code = location.hash.replace("#src=", "").trim() + return decodeURIComponent(code) + } + + // New school support + if (location.hash.startsWith("#code")) { + const code = location.hash.replace("#code/", "").trim() + let userCode = decompressFromEncodedURIComponent(code) + // Fallback incase there is an extra level of decoding: + // https://gitter.im/Microsoft/TypeScript?at=5dc478ab9c39821509ff189a + if (!userCode) userCode = decompressFromEncodedURIComponent(decodeURIComponent(code)) + return userCode + } + + // Local copy fallback + if (localStorage.getItem("sandbox-history")) { + return localStorage.getItem("sandbox-history")! + } + + return fallback +} \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d4e3f75 --- /dev/null +++ b/index.ts @@ -0,0 +1,360 @@ +import type { SandboxConfig, CompilerOptions } from "./types.ts"; + +import { tsvfs, ata, Typescript } from "./deps.ts"; + +import { getDefaultSandboxCompilerOptions,getCompilerOptionsFromParams,createURLQueryWithCompilerOptions } from "./compilerOptions.ts"; +import { getInitialCode } from "./getInitialCode.ts"; + +import { supportedReleases } from "./release_data.ts" +import { extractTwoSlashCompilerOptions, twoslashCompletions } from "./twoslashSupport.ts" + +const { setupTypeAcquisition } = ata; + +const languageType = (config: SandboxConfig) => (config.filetype === "js" ? "javascript" : "typescript") + +/** The default settings which we apply a partial over */ +export function defaultPlaygroundSettings() { + const config: SandboxConfig = { + text: "", + compilerOptions: {}, + acquireTypes: true, + filetype: "ts", + supportTwoslashCompilerOptions: false, + logger: console, + } + return config +} + +function defaultFilePath(config: SandboxConfig, compilerOptions: CompilerOptions) { + const isJSX = compilerOptions.jsx !== Typescript.JsxEmit.None + const ext = isJSX && config.filetype !== "d.ts" ? config.filetype + "x" : config.filetype + return "input." + ext +} + +/** Creates a monaco file reference, basically a fancy path */ +function createFileUri(config: SandboxConfig, compilerOptions: CompilerOptions) { + return new URL(defaultFilePath(config, compilerOptions), "http://localhost:3000/") +} + +/** Creates a sandbox editor, and returns a set of useful functions and the editor */ +export const createTypeScriptSandbox = ( + partialConfig: Partial, + ts: typeof Typescript, + location: URL | Location + ) => { + const config = { ...defaultPlaygroundSettings(), ...partialConfig } + + const defaultText = config.suppressAutomaticallyGettingDefaultText + ? config.text + : getInitialCode(config.text, location) + + // Defaults + const compilerDefaults = getDefaultSandboxCompilerOptions(config, ts) + + // Grab the compiler flags via the query params + let compilerOptions: CompilerOptions + if (!config.suppressAutomaticallyGettingCompilerFlags) { + const params = new URLSearchParams(location.search) + let queryParamCompilerOptions = getCompilerOptionsFromParams(compilerDefaults, ts, params) + if (Object.keys(queryParamCompilerOptions).length) + config.logger.log("[Compiler] Found compiler options in query params: ", queryParamCompilerOptions) + compilerOptions = { ...compilerDefaults, ...queryParamCompilerOptions } + } else { + compilerOptions = compilerDefaults + } + + const isJSLang = config.filetype === "js" + // Don't allow a state like allowJs = false + if (isJSLang) { + compilerOptions.allowJs = true + } + + const language = languageType(config) + const filePath = createFileUri(config, compilerOptions) + + const model = monaco.editor.createModel(defaultText, language, filePath) + + const getWorker = isJSLang + ? monaco.languages.typescript.getJavaScriptWorker + : monaco.languages.typescript.getTypeScriptWorker + + const defaults = isJSLang + ? monaco.languages.typescript.javascriptDefaults + : monaco.languages.typescript.typescriptDefaults + + defaults.setDiagnosticsOptions({ + ...defaults.getDiagnosticsOptions(), + noSemanticValidation: false, + // This is when tslib is not found + diagnosticCodesToIgnore: [2354], + }) + + // In the future it'd be good to add support for an 'add many files' + const addLibraryToRuntime = (code: string, _path: string) => { + const path = "file://" + _path + defaults.addExtraLib(code, path) + const uri = new URL(path) + if (monaco.editor.getModel(uri) === null) { + monaco.editor.createModel(code, "javascript", uri) + } + config.logger.log(`[ATA] Adding ${path} to runtime`, { code }) + } + + const getTwoSlashCompilerOptions = extractTwoSlashCompilerOptions(ts) + + // Auto-complete twoslash comments + if (config.supportTwoslashCompilerOptions) { + const langs = ["javascript", "typescript"] + langs.forEach(l => + monaco.languages.registerCompletionItemProvider(l, { + triggerCharacters: ["@", "/", "-"], + provideCompletionItems: twoslashCompletions(ts, monaco), + }) + ) + } + + const ata = setupTypeAcquisition({ + projectName: "TypeScript Playground", + typescript: ts, + logger: console, + delegate: { + receivedFile: addLibraryToRuntime, + progress: (downloaded: number, total: number) => { + // console.log({ dl, ttl }) + }, + started: () => { + console.log("ATA start") + }, + finished: f => { + console.log("ATA done") + }, + }, + }) + + const textUpdated = () => { + const code = editor.getModel()!.getValue() + + if (config.supportTwoslashCompilerOptions) { + const configOpts = getTwoSlashCompilerOptions(code) + updateCompilerSettings(configOpts) + } + + if (config.acquireTypes) { + ata(code) + } + } + + config.logger.log("[Compiler] Set compiler options: ", compilerOptions) + defaults.setCompilerOptions(compilerOptions) + + // To let clients plug into compiler settings changes + let didUpdateCompilerSettings = (opts: CompilerOptions) => {} + + const updateCompilerSettings = (opts: CompilerOptions) => { + const newKeys = Object.keys(opts) + if (!newKeys.length) return + + // Don't update a compiler setting if it's the same + // as the current setting + newKeys.forEach(key => { + if (compilerOptions[key] == opts[key]) delete opts[key] + }) + + if (!Object.keys(opts).length) return + + config.logger.log("[Compiler] Updating compiler options: ", opts) + + compilerOptions = { ...compilerOptions, ...opts } + defaults.setCompilerOptions(compilerOptions) + didUpdateCompilerSettings(compilerOptions) + } + + const updateCompilerSetting = (key: keyof CompilerOptions, value: any) => { + config.logger.log("[Compiler] Setting compiler options ", key, "to", value) + compilerOptions[key] = value + defaults.setCompilerOptions(compilerOptions) + didUpdateCompilerSettings(compilerOptions) + } + + const setCompilerSettings = (opts: CompilerOptions) => { + config.logger.log("[Compiler] Setting compiler options: ", opts) + compilerOptions = opts + defaults.setCompilerOptions(compilerOptions) + didUpdateCompilerSettings(compilerOptions) + } + + const getCompilerOptions = () => { + return compilerOptions + } + + const setDidUpdateCompilerSettings = (func: (opts: CompilerOptions) => void) => { + didUpdateCompilerSettings = func + } + + /** Gets the results of compiling your editor's code */ + const getEmitResult = async () => { + const model = editor.getModel()! + const client = await getWorkerProcess() + return await client.getEmitOutput(model.uri.toString()) + } + + /** Gets the JS of compiling your editor's code */ + const getRunnableJS = async () => { + // This isn't quite _right_ in theory, we can downlevel JS -> JS + // but a browser is basically always esnext-y and setting allowJs and + // checkJs does not actually give the downlevel'd .js file in the output + // later down the line. + if (isJSLang) { + return getText() + } + const result = await getEmitResult() + const firstJS = result.outputFiles.find((o: any) => o.name.endsWith(".js") || o.name.endsWith(".jsx")) + return (firstJS && firstJS.text) || "" + } + + /** Gets the DTS for the JS/TS of compiling your editor's code */ + const getDTSForCode = async () => { + const result = await getEmitResult() + return result.outputFiles.find((o: any) => o.name.endsWith(".d.ts"))!.text + } + + const getWorkerProcess = async (): Promise => { + const worker = await getWorker() + // @ts-ignore + return await worker(model.uri) + } + + const getDomNode = () => editor.getDomNode()! + const getModel = () => editor.getModel()! + const getText = () => getModel().getValue() + const setText = (text: string) => getModel().setValue(text) + + const setupTSVFS = async (fsMapAdditions?: Map) => { + const fsMap = await tsvfs.createDefaultMapFromCDN(compilerOptions, ts.version, true, ts, lzstring) + fsMap.set(filePath.path, getText()) + if (fsMapAdditions) { + fsMapAdditions.forEach((v, k) => fsMap.set(k, v)) + } + + const system = tsvfs.createSystem(fsMap) + const host = tsvfs.createVirtualCompilerHost(system, compilerOptions, ts) + + const program = ts.createProgram({ + rootNames: [...fsMap.keys()], + options: compilerOptions, + host: host.compilerHost, + }) + + return { + program, + system, + host, + fsMap, + } + } + + /** + * Creates a TS Program, if you're doing anything complex + * it's likely you want setupTSVFS instead and can pull program out from that + * + * Warning: Runs on the main thread + */ + const createTSProgram = async () => { + const tsvfs = await setupTSVFS() + return tsvfs.program + } + + const getAST = async () => { + const program = await createTSProgram() + program.emit() + return program.getSourceFile(filePath.path)! + } + + // Pass along the supported releases for the playground + const supportedVersions = supportedReleases + + textUpdated() + + return { + /** The same config you passed in */ + config, + /** A list of TypeScript versions you can use with the TypeScript sandbox */ + supportedVersions, + /** The monaco editor instance */ + editor, + /** Either "typescript" or "javascript" depending on your config */ + language, + /** The outer monaco module, the result of require("monaco-editor") */ + monaco, + /** Gets a monaco-typescript worker, this will give you access to a language server. Note: prefer this for language server work because it happens on a webworker . */ + getWorkerProcess, + /** A copy of require("@typescript/vfs") this can be used to quickly set up an in-memory compiler runs for ASTs, or to get complex language server results (anything above has to be serialized when passed)*/ + tsvfs, + /** Get all the different emitted files after TypeScript is run */ + getEmitResult, + /** Gets just the JavaScript for your sandbox, will transpile if in TS only */ + getRunnableJS, + /** Gets the DTS output of the main code in the editor */ + getDTSForCode, + /** The monaco-editor dom node, used for showing/hiding the editor */ + getDomNode, + /** The model is an object which monaco uses to keep track of text in the editor. Use this to directly modify the text in the editor */ + getModel, + /** Gets the text of the main model, which is the text in the editor */ + getText, + /** Shortcut for setting the model's text content which would update the editor */ + setText, + /** Gets the AST of the current text in monaco - uses `createTSProgram`, so the performance caveat applies there too */ + getAST, + /** The module you get from require("typescript") */ + ts, + /** Create a new Program, a TypeScript data model which represents the entire project. As well as some of the + * primitive objects you would normally need to do work with the files. + * + * The first time this is called it has to download all the DTS files which is needed for an exact compiler run. Which + * at max is about 1.5MB - after that subsequent downloads of dts lib files come from localStorage. + * + * Try to use this sparingly as it can be computationally expensive, at the minimum you should be using the debounced setup. + * + * Accepts an optional fsMap which you can use to add any files, or overwrite the default file. + * + * TODO: It would be good to create an easy way to have a single program instance which is updated for you + * when the monaco model changes. + */ + setupTSVFS, + /** Uses the above call setupTSVFS, but only returns the program */ + createTSProgram, + /** The Sandbox's default compiler options */ + compilerDefaults, + /** The Sandbox's current compiler options */ + getCompilerOptions, + /** Replace the Sandbox's compiler options */ + setCompilerSettings, + /** Overwrite the Sandbox's compiler options */ + updateCompilerSetting, + /** Update a single compiler option in the SAndbox */ + updateCompilerSettings, + /** A way to get callbacks when compiler settings have changed */ + setDidUpdateCompilerSettings, + /** A copy of lzstring, which is used to archive/unarchive code */ + lzstring, + /** Returns compiler options found in the params of the current page */ + createURLQueryWithCompilerOptions, + /** + * @deprecated Use `getTwoSlashCompilerOptions` instead. + * + * Returns compiler options in the source code using twoslash notation + */ + getTwoSlashComplierOptions: getTwoSlashCompilerOptions, + /** Returns compiler options in the source code using twoslash notation */ + getTwoSlashCompilerOptions, + /** Gets to the current monaco-language, this is how you talk to the background webworkers */ + languageServiceDefaults: defaults, + /** The path which represents the current file using the current compiler options */ + filepath: filePath.path, + /** Adds a file to the vfs used by the editor */ + addLibraryToRuntime, + } + } + + export type Sandbox = ReturnType \ No newline at end of file diff --git a/release_data.ts b/release_data.ts new file mode 100644 index 0000000..a975de4 --- /dev/null +++ b/release_data.ts @@ -0,0 +1,7 @@ +// This is auto-generated by scripts/downloadReleases.js +/** Every prod version **/ +export const allReleases = ["5.0.4", "5.0.3", "5.0.2", "4.9.5", "4.9.4", "4.9.3", "4.8.4", "4.8.3", "4.8.2", "4.7.4", "4.7.3", "4.7.2", "4.6.4", "4.6.2", "4.5.5", "4.5.4", "4.5.3", "4.5.2", "4.4.4", "4.4.3", "4.4.2", "4.3.5", "4.3.4", "4.3.3", "4.3.2", "4.2.3", "4.2.2", "4.1.5", "4.1.3", "4.1.2", "4.0.5", "4.0.3", "4.0.2", "3.9.7", "3.9.2", "3.8.3", "3.8.2", "3.7.5", "3.6.3", "3.5.1", "3.3.3", "3.1.6", "3.0.1", "2.8.1", "2.7.2", "2.4.1"] as const +/** The latest major.min version **/ +export const supportedReleases = ["5.1.0-beta", "5.0.4", "4.9.5", "4.8.4", "4.7.4", "4.6.4", "4.5.5", "4.4.4", "4.3.5", "4.2.3", "4.1.5", "4.0.5", "3.9.7", "3.8.3", "3.7.5", "3.6.3", "3.5.1", "3.3.3", "3.1.6", "3.0.1", "2.8.1", "2.7.2", "2.4.1"] as const +/** A type of all versions **/ +export type ReleaseVersions = "5.1.0-beta" | "5.1.1-rc" | "5.0.4" | "5.0.3" | "5.0.2" | "4.9.5" | "4.9.4" | "4.9.3" | "4.8.4" | "4.8.3" | "4.8.2" | "4.7.4" | "4.7.3" | "4.7.2" | "4.6.4" | "4.6.2" | "4.5.5" | "4.5.4" | "4.5.3" | "4.5.2" | "4.4.4" | "4.4.3" | "4.4.2" | "4.3.5" | "4.3.4" | "4.3.3" | "4.3.2" | "4.2.3" | "4.2.2" | "4.1.5" | "4.1.3" | "4.1.2" | "4.0.5" | "4.0.3" | "4.0.2" | "3.9.7" | "3.9.2" | "3.8.3" | "3.8.2" | "3.7.5" | "3.6.3" | "3.5.1" | "3.3.3" | "3.1.6" | "3.0.1" | "2.8.1" | "2.7.2" | "2.4.1" \ No newline at end of file diff --git a/scripts/releases.ts b/scripts/releases.ts new file mode 100644 index 0000000..bea6175 --- /dev/null +++ b/scripts/releases.ts @@ -0,0 +1,62 @@ +import { join, fromFileUrl, dirname } from "https://deno.land/std@0.189.0/path/mod.ts" + +interface ReleasesResponse { + versions: string[] +} + +const response = await fetch("https://typescript.azureedge.net/indexes/releases.json") +const releases: ReleasesResponse = await response.json() +const versions = releases.versions.reverse() + +// Look through the prereleases to see if the beta and RC are included in the pre-releases +// and add those to the list of versions. +const preReleaseResponse = await fetch("https://typescript.azureedge.net/indexes/pre-releases.json") +const preReleases: ReleasesResponse = await preReleaseResponse.json() +const latestStable = versions[0] + +// e.g. 4.3.1 -> 4.4.0-beta +// this won't work for 5.0 specifically, but that's an ok edge case for me +const possibleBeta = `${latestStable.split(".")[0]}.${Number(latestStable.split(".")[1]) + 1}.0-beta` +const addBeta = preReleases.versions.includes(possibleBeta) + +const possibleRc = `${latestStable.split(".")[0]}.${Number(latestStable.split(".")[1]) + 1}.1-rc` +const addRc = preReleases.versions.includes(possibleRc) + +// Get the highest maj/min ignoring patch versions +const latestMajMin = new Map() +versions.forEach(v => { + const majMin = v.split(".")[0] + "." + v.split(".")[1] + if (!latestMajMin.has(majMin)) { + latestMajMin.set(majMin, v) + } +}) + +// prettier-ignore +// Adds RC and Beta to the versions automatically +const supportedVersions = [ + addRc ? possibleRc : "", + addBeta ? possibleBeta : "", + ...latestMajMin.values() +].filter(Boolean) + +const code = `// This is auto-generated by scripts/releases.js +/** Every prod version **/ +export const allReleases = ["${versions.join('", "')}"] as const + +/** The latest major.min version **/ +export const supportedReleases = ["${supportedVersions.join('", "')}"] as const + +/** A type of all versions **/ +export type ReleaseVersions = "${[possibleBeta, possibleRc, ...versions].join('" | "')}" +`.split("\n").map(x => x.trim()).filter(x => x) + +const path = join(dirname(fromFileUrl(import.meta.url)), ".", "release_data.ts") +Deno.writeFile(path, new TextEncoder().encode(code.join("\n"))) +console.log({ + code, + releases, + supportedVersions, + possibleBeta, + possibleRc, + versions +}) \ No newline at end of file diff --git a/tsserver.ts b/tsserver.ts new file mode 100644 index 0000000..33d6530 --- /dev/null +++ b/tsserver.ts @@ -0,0 +1,126 @@ + +import { tsvfs, Typescript } from "./deps.ts" +import type { CompilerOptions } from "./types.ts" + +const { + createDefaultMapFromCDN, + createSystem, + createVirtualTypeScriptEnvironment, +} = tsvfs; + +const compilerOpts: CompilerOptions = { + target: Typescript.ScriptTarget.ES2022, + module: Typescript.ModuleKind.ES2022, + "lib": [ + "es2022", + "dom", + "webworker", + ], + "esModuleInterop": true, +}; +const ENTRY_POINT = "index.ts"; + +export async function createTypescriptLanguageService(initialText = "const hello = 'hi'") { + const fsMap = await createDefaultMapFromCDN( + compilerOpts, + Typescript.version, + false, + Typescript, + ); + fsMap.set(ENTRY_POINT, initialText); + + const system = createSystem(fsMap); + const env = createVirtualTypeScriptEnvironment( + system, + [ENTRY_POINT], + Typescript, + compilerOpts, + ); + + return { + fsMap, + system, + env, + version: Typescript.version, + } +} + +export function createAutoComplete(env: tsvfs.VirtualTypeScriptEnvironment, path = ENTRY_POINT, pos: number, options?: Typescript.GetCompletionsAtPositionOptions, formattingSettings?: Typescript.FormatCodeSettings | Typescript.FormatCodeOptions, preferences?: Typescript.UserPreferences) { + const result = env.languageService.getCompletionsAtPosition( + path, + pos, + options, + formattingSettings + ); + + const details = result?.entries.map(x => { + return Object.assign({}, x, { + details: env.languageService.getCompletionEntryDetails(path, pos, x.name, formattingSettings, x.source, preferences, x.data) + }) + }); + + return Object.assign({}, result, { entries: details }) +} + +export function createTooltip(env: tsvfs.VirtualTypeScriptEnvironment, path = ENTRY_POINT, pos: number, ) { + const result = env.languageService.getQuickInfoAtPosition(path, pos); + return result ? + { + result, + tootltipText: Typescript.displayPartsToString(result.displayParts) + + (result.documentation?.length + ? "\n" + Typescript.displayPartsToString(result.documentation) + : ""), + } : + { result, tooltipText: "" } +} + +export function createLint(env: tsvfs.VirtualTypeScriptEnvironment, path = ENTRY_POINT, formatOptions: Typescript.FormatCodeSettings = {}, preferences: Typescript.UserPreferences = {}) { + const SyntacticDiagnostics = env.languageService.getSyntacticDiagnostics(path); + const SemanticDiagnostic = env.languageService.getSemanticDiagnostics(path); + const SuggestionDiagnostics = env.languageService.getSuggestionDiagnostics(path); + + const result = [ + ...SyntacticDiagnostics, + ...SemanticDiagnostic, + ...SuggestionDiagnostics, + ]; + + return result + .map((v) => { + if ( + typeof v.start !== "number" || + typeof v.length !== "number" + ) return null; + + const from = v.start; + const to = from + v.length; + const codeActions = env.languageService.getCodeFixesAtPosition(path, from, to, [v.category], formatOptions, preferences); + + const diag = { + from, + to, + message: v.messageText, + source: v?.source, + severity: [ + "warning", + "error", + "info", + "info", + ][v.category], + actions: codeActions + }; + + return diag; + }) + .filter(x => x) +} + +export function createReferences(env: tsvfs.VirtualTypeScriptEnvironment, path = ENTRY_POINT) { + const result = env.languageService.getFileReferences(path); + return result +} + +export function updateFile(env: tsvfs.VirtualTypeScriptEnvironment, path = ENTRY_POINT, value = "const hello = 'hi'") { + return env.updateFile(path, value); +} diff --git a/twoslashSupport.ts b/twoslashSupport.ts new file mode 100644 index 0000000..e856e00 --- /dev/null +++ b/twoslashSupport.ts @@ -0,0 +1,183 @@ +import type { Typescript, CompilerOptions } from "./types.ts" + +const booleanConfigRegexp = /^\/\/\s?@(\w+)$/ + +// https://regex101.com/r/8B2Wwh/1 +const valuedConfigRegexp = /^\/\/\s?@(\w+):\s?(.+)$/ + +/** + * This is a port of the twoslash bit which grabs compiler options + * from the source code + */ + +export const extractTwoSlashCompilerOptions = (ts: typeof Typescript) => { + const optMap = new Map() + + if (!("optionDeclarations" in ts)) { + console.error("Could not get compiler options from ts.optionDeclarations - skipping twoslash support.") + } else { + // @ts-ignore - optionDeclarations is not public API + for (const opt of ts.optionDeclarations) { + optMap.set(opt.name.toLowerCase(), opt) + } + } + + return (code: string) => { + const codeLines = code.split("\n") + const options = {} as any + + codeLines.forEach(_line => { + let match + const line = _line.trim() + if ((match = booleanConfigRegexp.exec(line))) { + if (optMap.has(match[1].toLowerCase())) { + options[match[1]] = true + setOption(match[1], "true", options, optMap) + } + } else if ((match = valuedConfigRegexp.exec(line))) { + if (optMap.has(match[1].toLowerCase())) { + setOption(match[1], match[2], options, optMap) + } + } + }) + return options + } +} + +function setOption(name: string, value: string, opts: CompilerOptions, optMap: Map) { + const opt = optMap.get(name.toLowerCase()) + + if (!opt) return + switch (opt.type) { + case "number": + case "string": + case "boolean": + opts[opt.name] = parsePrimitive(value, opt.type) + break + + case "list": { + const elementType = opt.element!.type + const strings = value.split(",") + if (typeof elementType === "string") { + opts[opt.name] = strings.map(v => parsePrimitive(v, elementType)) + } else { + opts[opt.name] = strings.map(v => getOptionValueFromMap(opt.name, v, elementType as Map)!).filter(Boolean) + } + break + } + + default: { + // It's a map! + const optMap = opt.type as Map + opts[opt.name] = getOptionValueFromMap(opt.name, value, optMap) + } + } + + if (opts[opt.name] === undefined) { + const keys = Array.from(opt.type.keys() as any) + console.log(`Invalid value ${value} for ${opt.name}. Allowed values: ${keys.join(",")}`) + } +} + +export function parsePrimitive(value: string, type: string): any { + switch (type) { + case "number": + return +value + case "string": + return value + case "boolean": + return value.toLowerCase() === "true" || value.length === 0 + } + console.log(`Unknown primitive type ${type} with - ${value}`) +} + + +function getOptionValueFromMap(name: string, key: string, optMap: Map) { + const result = optMap.get(key.toLowerCase()) + if (result === undefined) { + const keys = Array.from(optMap.keys() as any) + + console.error( + `Invalid inline compiler value`, + `Got ${key} for ${name} but it is not a supported value by the TS compiler.`, + `Allowed values: ${keys.join(",")}` + ) + } + return result +} + +// Function to generate autocompletion results +// export const twoslashCompletions = (ts: TS, monaco: typeof import("monaco-editor")) => ( +// model: import("monaco-editor").editor.ITextModel, +// position: import("monaco-editor").Position, +// _token: any +// ): import("monaco-editor").languages.CompletionList => { +// const result: import("monaco-editor").languages.CompletionItem[] = [] + +// // Split everything the user has typed on the current line up at each space, and only look at the last word +// const thisLine = model.getValueInRange({ +// startLineNumber: position.lineNumber, +// startColumn: 0, +// endLineNumber: position.lineNumber, +// endColumn: position.column, +// }) + +// // Not a comment +// if (!thisLine.startsWith("//")) { +// return { suggestions: [] } +// } + +// const words = thisLine.replace("\t", "").split(" ") + +// // Not the right amount of +// if (words.length !== 2) { +// return { suggestions: [] } +// } + +// const word = words[1] +// if (word.startsWith("-")) { +// return { +// suggestions: [ +// { +// label: "---cut---", +// kind: 14, +// detail: "Twoslash split output", +// insertText: "---cut---".replace(word, ""), +// } as any, +// ], +// } +// } + +// // Not a @ at the first word +// if (!word.startsWith("@")) { +// return { suggestions: [] } +// } + +// const knowns = [ +// "noErrors", +// "errors", +// "showEmit", +// "showEmittedFile", +// "noStaticSemanticInfo", +// "emit", +// "noErrorValidation", +// "filename", +// ] +// // @ts-ignore - ts.optionDeclarations is private +// const optsNames = ts.optionDeclarations.map(o => o.name) +// knowns.concat(optsNames).forEach(name => { +// if (name.startsWith(word.slice(1))) { +// // somehow adding the range seems to not give autocomplete results? +// result.push({ +// label: name, +// kind: 14, +// detail: "Twoslash comment", +// insertText: name, +// } as any) +// } +// }) + +// return { +// suggestions: result, +// } +// } \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..93ae24d --- /dev/null +++ b/types.ts @@ -0,0 +1,30 @@ +import type { Typescript } from "./deps.ts"; + +export type CompilerOptions = Typescript.CompilerOptions; +export type { Typescript }; + +export type SandboxConfig = { + /** The default source code for the playground */ + text: string + /** @deprecated */ + useJavaScript?: boolean + /** The default file for the playground */ + filetype: "js" | "ts" | "d.ts" + /** Compiler options which are automatically just forwarded on */ + compilerOptions: CompilerOptions + /** Acquire types via type acquisition */ + acquireTypes: boolean + /** Support twoslash compiler options */ + supportTwoslashCompilerOptions: boolean + /** Get the text via query params and local storage, useful when the editor is the main experience */ + suppressAutomaticallyGettingDefaultText?: true + /** Suppress setting compiler options from the compiler flags from query params */ + suppressAutomaticallyGettingCompilerFlags?: true + /** Logging system */ + logger: { + log: (...args: any[]) => void + error: (...args: any[]) => void + groupCollapsed: (...args: any[]) => void + groupEnd: (...args: any[]) => void + } +} \ No newline at end of file -- 2.51.2