// export const ts = (window as any).ts; // import * as typescriptlib from '!!raw-loader!../../../node_modules/typescript/lib/lib.d.ts' // import * as typescriptes5 from '!!raw-loader!../../../node_modules/typescript/lib/lib.es5.d.ts' import typescriptlib from 'type_decls.d'; import * as ts from 'typescript'; import { Doc, FieldType } from '../../fields/Doc'; import { RefField } from '../../fields/RefField'; import { ScriptField } from '../../fields/ScriptField'; import { ScriptingGlobals, scriptingGlobals } from './ScriptingGlobals'; export { ts }; export interface ScriptSuccess { success: true; result: unknown; } export interface ScriptError { success: false; error: unknown; result: unknown; } export type ScriptResult = ScriptSuccess | ScriptError; export type ScriptParam = { [name: string]: string }; export interface CompiledScript { readonly compiled: true; readonly originalScript: string; // eslint-disable-next-line no-use-before-define readonly options: Readonly; run(args?: { [name: string]: unknown }, onError?: (res: string) => void, errorVal?: unknown): ScriptResult; } export interface CompileError { compiled: false; errors: ts.Diagnostic[]; } export type CompileResult = CompiledScript | CompileError; export function isCompileError(toBeDetermined: CompileResult): toBeDetermined is CompileError { if ((toBeDetermined as CompileError).errors) { return true; } return false; } // eslint-disable-next-line no-use-before-define function Run(script: string | undefined, customParams: string[], diagnostics: ts.Diagnostic[], originalScript: string, options: ScriptOptions): CompileResult { const errors = diagnostics.filter(diag => diag.category === ts.DiagnosticCategory.Error).filter(diag => diag.code !== 2304 && diag.code !== 2339); if ((options.typecheck !== false && errors.length) || !script) { return { compiled: false, errors }; } const paramNames = Object.keys(scriptingGlobals); const params = paramNames.map(key => scriptingGlobals[key]); // let fieldTypes = [Doc, ImageField, PdfField, VideoField, AudioField, List, RichTextField, ScriptField, ComputedField, CompileScript]; // let paramNames = ["Docs", ...fieldTypes.map(fn => fn.name)]; // let params: any[] = [Docs, ...fieldTypes]; const compiledFunction = (() => { try { return new Function(...paramNames, `return ${script}`); } catch (e) { console.log(e); return undefined; } })(); if (!compiledFunction) return { compiled: false, errors }; const { capturedVariables = {} } = options; const run = (args: { [name: string]: unknown } = {}, onError?: (e: string) => void, errorVal?: ts.Diagnostic): ScriptResult => { const argsArray: unknown[] = []; for (const name of customParams) { if (name !== 'this') { argsArray.push(name in args ? args[name] : capturedVariables[name]); } } const thisParam = args.this || capturedVariables.this; let batch: { end(): void } | undefined; try { if (!options.editable) { batch = Doc.MakeReadOnly(); } const result = compiledFunction.apply(thisParam, params).apply(thisParam, argsArray); batch?.end(); return { success: true, result }; } catch (error) { batch?.end(); onError?.(script + ' ' + (error as string).toString()); return { success: false, error, result: errorVal }; } }; return { compiled: true, run, originalScript, options }; } interface File { fileName: string; content: string; } // class ScriptingCompilerHost implements ts.CompilerHost { class ScriptingCompilerHost { files: File[] = []; // getSourceFile(fileName: string, languageVersion: ts.ScriptTarget, onError?: ((message: string) => void) | undefined, shouldCreateNewSourceFile?: boolean | undefined): ts.SourceFile | undefined { getSourceFile(fileName: string, languageVersion: ts.ScriptTarget | ts.CreateSourceFileOptions /* , onError?: ((message: string) => void) | undefined, shouldCreateNewSourceFile?: boolean | undefined */): ts.SourceFile | undefined { const contents = this.readFile(fileName); if (contents !== undefined) { return ts.createSourceFile(fileName, contents, languageVersion, true); } return undefined; } // getDefaultLibFileName(options: ts.CompilerOptions): string { getDefaultLibFileName(/* options: any */): string { return 'node_modules/typescript/lib/lib.d.ts'; // No idea what this means... } writeFile(fileName: string, content: string) { const file = this.files.find(f => f.fileName === fileName); if (file) { file.content = content; } else { this.files.push({ fileName, content }); } } getCurrentDirectory(): string { return ''; } getCanonicalFileName(fileName: string): string { return this.useCaseSensitiveFileNames() ? fileName : fileName.toLowerCase(); } useCaseSensitiveFileNames(): boolean { return true; } getNewLine(): string { return '\n'; } fileExists(fileName: string): boolean { return this.files.some(file => file.fileName === fileName); } readFile(fileName: string): string | undefined { const file = this.files.find(f => f.fileName === fileName); if (file) { return file.content; } return undefined; } } export type Traverser = (node: ts.Node, indentation: string) => boolean | void; export type TraverserParam = Traverser | { onEnter: Traverser; onLeave: Traverser }; export type Transformer = { transformer: ts.TransformerFactory; getVars?: () => { [name: string]: FieldType }; }; export interface ScriptOptions { requiredType?: string; // does function required a typed return value addReturn?: boolean; // does the compiler automatically add a return statement params?: { [name: string]: string }; // list of function parameters and their types capturedVariables?: { [name: string]: Doc | number | string | boolean | undefined }; // list of captured variables typecheck?: boolean; // should the compiler perform typechecking editable?: boolean; // can the script edit Docs traverser?: TraverserParam; transformer?: Transformer; // does the editor display a text label by each document that can be used as a captured document reference globals?: { [name: string]: unknown }; } // function forEachNode(node:ts.Node, fn:(node:any) => void); function forEachNode(node: ts.Node, onEnter: Traverser, onExit?: Traverser, indentation = '') { return ( onEnter(node, indentation) || // eslint-disable-next-line @typescript-eslint/no-explicit-any ts.forEachChild(node, (n: any) => { forEachNode(n, onEnter, onExit, indentation + ' '); }) || (onExit && onExit(node, indentation)) ); } export function CompileScript(script: string, options: ScriptOptions = {}): CompileResult { const captured = options.capturedVariables ?? {}; const signature = Object.keys(captured).reduce((p, v) => { const formatCapture = (obj: FieldType | undefined) => `${v}=${obj instanceof RefField ? 'XXX' : obj?.toString()}`; const captureVal = captured[v]; if (captureVal instanceof Array) return p + captureVal.map(formatCapture); return p + formatCapture(captured[v]); }, ''); const found = ScriptField.GetScriptFieldCache(script + ':' + signature); // if already compiled, found is the result; cache set below if (found) return found as CompiledScript; options.typecheck = true; const { requiredType = '', addReturn = false, params = {}, capturedVariables = {}, typecheck = true } = options; if (options.params && !options.params.this) options.params.this = Doc.name; if (options.globals) { ScriptingGlobals.setScriptingGlobals(options.globals); } const host = new ScriptingCompilerHost(); if (options.traverser) { const sourceFile = ts.createSourceFile('script.ts', script, ts.ScriptTarget.ES2015, true); const onEnter = typeof options.traverser === 'object' ? options.traverser.onEnter : options.traverser; const onLeave = typeof options.traverser === 'object' ? options.traverser.onLeave : undefined; forEachNode(sourceFile, onEnter, onLeave); } if (options.transformer) { const sourceFile = ts.createSourceFile('script.ts', script, ts.ScriptTarget.ES2015, true); const result = ts.transform(sourceFile, [options.transformer.transformer]); const newCaptures = options.transformer.getVars?.(); if (Object.keys(newCaptures ?? {}).length) { // tslint:disable-next-line: prefer-object-spread // options.capturedVariables = Object.assign(capturedVariables, newCaptures!) as any; const { transformed } = result; const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, }); // eslint-disable-next-line no-param-reassign script = printer.printFile(transformed[0].getSourceFile()); } result.dispose(); } const paramNames: string[] = []; if ('this' in params || 'this' in capturedVariables) { paramNames.push('this'); } paramNames.push(...Object.keys(params).filter(p => p!== 'this' && !Object.keys(capturedVariables).includes(p))); const paramList = paramNames.map(key => { const val = typeof params[key] === "string" && params[key].length && !"\"'`".includes(params[key][0]) ? `"${params[key]}"` : params[key]; return `${key}: ${val}`; }); for (const key in capturedVariables) { if (key !== 'this') { const val = capturedVariables[key]; paramNames.push(key); paramList.push(`${key}: ${typeof val === 'object' ? Object.getPrototypeOf(val).constructor.name : typeof val}`); } } const paramString = paramList.join(', '); const body = addReturn && !script.startsWith('{ return') ? `return ${script};` : script; const reqTypes = requiredType ? `: ${requiredType}` : ''; const funcScript = `(function(${paramString})${reqTypes} { ${body} })`; host.writeFile('file.ts', funcScript); if (typecheck) host.writeFile('node_modules/typescript/lib/lib.d.ts', typescriptlib); const program = ts.createProgram(['file.ts'], {}, host); const testResult = program.emit(); const outputText = host.readFile('file.js'); const diagnostics = ts.getPreEmitDiagnostics(program).concat(testResult.diagnostics); if (script.startsWith('@')) options.typecheck = true; // need the compilation to fail so that the script will return itself as a string (instead of nothing) const result = Run(outputText, paramNames, diagnostics, script, options); if (options.globals) { ScriptingGlobals.resetScriptingGlobals(); } !signature.includes('XXX') && ScriptField._scriptFieldCache.set(script + ':' + signature, result as CompiledScript); return result; }