mirror of
				https://github.com/ecomfe/vue-echarts.git
				synced 2025-10-31 17:07:52 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			590 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			590 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import * as ts from "typescript";
 | |
| 
 | |
| class ExternalImportError extends Error {
 | |
|   constructor(readonly request?: string) {
 | |
|     super();
 | |
|     this.name = "ExternalImportError";
 | |
|   }
 | |
| }
 | |
| 
 | |
| interface AnalyzeRequest {
 | |
|   id: number;
 | |
|   code: string;
 | |
| }
 | |
| 
 | |
| type DiagnosticSeverity = "error" | "warning" | "info" | "hint";
 | |
| 
 | |
| interface AnalyzeDiagnostic {
 | |
|   message: string;
 | |
|   startLineNumber: number;
 | |
|   startColumn: number;
 | |
|   endLineNumber: number;
 | |
|   endColumn: number;
 | |
|   severity: DiagnosticSeverity;
 | |
|   code?: string;
 | |
|   source?: string;
 | |
| }
 | |
| 
 | |
| interface AnalyzeResponse {
 | |
|   id: number;
 | |
|   strategy: StrategyName;
 | |
|   diagnostics: AnalyzeDiagnostic[];
 | |
|   issues: AnalysisIssue[];
 | |
|   output?: string;
 | |
|   option?: unknown;
 | |
|   runtimeError?: string | null;
 | |
| }
 | |
| 
 | |
| type StrategyName = "expression" | "module";
 | |
| 
 | |
| type IssueKind = "syntax" | "runtime" | "format";
 | |
| 
 | |
| interface IssueRange {
 | |
|   startLineNumber: number;
 | |
|   startColumn: number;
 | |
|   endLineNumber: number;
 | |
|   endColumn: number;
 | |
| }
 | |
| 
 | |
| interface AnalysisIssue {
 | |
|   kind: IssueKind;
 | |
|   severity: DiagnosticSeverity;
 | |
|   message: string;
 | |
|   hint?: string;
 | |
|   range?: IssueRange;
 | |
| }
 | |
| 
 | |
| interface StrategySpec {
 | |
|   name: StrategyName;
 | |
|   prefix: string;
 | |
|   suffix: string;
 | |
|   enabled: (code: string) => boolean;
 | |
| }
 | |
| 
 | |
| const STRATEGIES: StrategySpec[] = [
 | |
|   {
 | |
|     name: "expression",
 | |
|     prefix: "const __ve_option__ = (\n",
 | |
|     suffix: "\n);\nexport default __ve_option__;\n",
 | |
|     enabled(code) {
 | |
|       return !/^\s*export\s+/m.test(code);
 | |
|     },
 | |
|   },
 | |
|   {
 | |
|     name: "module",
 | |
|     prefix: "",
 | |
|     suffix: "",
 | |
|     enabled() {
 | |
|       return true;
 | |
|     },
 | |
|   },
 | |
| ];
 | |
| 
 | |
| const compilerOptions: ts.CompilerOptions = {
 | |
|   allowJs: true,
 | |
|   esModuleInterop: true,
 | |
|   forceConsistentCasingInFileNames: true,
 | |
|   isolatedModules: true,
 | |
|   lib: ["es2020", "dom"],
 | |
|   module: ts.ModuleKind.CommonJS,
 | |
|   moduleResolution: ts.ModuleResolutionKind.Node10,
 | |
|   preserveConstEnums: true,
 | |
|   skipLibCheck: true,
 | |
|   strict: false,
 | |
|   target: ts.ScriptTarget.ES2020,
 | |
| };
 | |
| 
 | |
| const severityMap: Record<ts.DiagnosticCategory, DiagnosticSeverity> = {
 | |
|   [ts.DiagnosticCategory.Warning]: "warning",
 | |
|   [ts.DiagnosticCategory.Error]: "error",
 | |
|   [ts.DiagnosticCategory.Message]: "info",
 | |
|   [ts.DiagnosticCategory.Suggestion]: "hint",
 | |
| };
 | |
| 
 | |
| function countLines(value: string) {
 | |
|   const parts = value.split("\n");
 | |
|   const lines = parts.length - 1;
 | |
|   const lastLineLength = parts[parts.length - 1]?.length ?? 0;
 | |
|   return { lines, lastLineLength };
 | |
| }
 | |
| 
 | |
| function sanitizeDiagnosticMessage(message: string): string {
 | |
|   return message
 | |
|     .replace(/__ve_option__/g, "option")
 | |
|     .replace(/module\.exports/g, "export")
 | |
|     .replace(/__ve_option__\.([a-zA-Z_$][\w$]*)/g, "option.$1");
 | |
| }
 | |
| 
 | |
| interface ConvertedDiagnostics {
 | |
|   diagnostics: AnalyzeDiagnostic[];
 | |
|   issues: AnalysisIssue[];
 | |
| }
 | |
| 
 | |
| function convertDiagnostics(
 | |
|   sourceFile: ts.SourceFile,
 | |
|   diagnostics: readonly ts.Diagnostic[] | undefined,
 | |
|   strategy: StrategySpec,
 | |
| ): ConvertedDiagnostics {
 | |
|   if (!diagnostics?.length) {
 | |
|     return { diagnostics: [], issues: [] };
 | |
|   }
 | |
| 
 | |
|   const prefixInfo = countLines(strategy.prefix);
 | |
|   const suffixInfo = countLines(strategy.suffix);
 | |
|   const lastLineIndex = sourceFile.getLineAndCharacterOfPosition(
 | |
|     sourceFile.text.length,
 | |
|   ).line;
 | |
|   const results: AnalyzeDiagnostic[] = [];
 | |
|   const issues: AnalysisIssue[] = [];
 | |
| 
 | |
|   diagnostics.forEach((diagnostic) => {
 | |
|     const file = diagnostic.file ?? sourceFile;
 | |
|     if (typeof diagnostic.start !== "number") {
 | |
|       const message = sanitizeDiagnosticMessage(
 | |
|         ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
 | |
|       );
 | |
|       const mapped: AnalyzeDiagnostic = {
 | |
|         message,
 | |
|         startLineNumber: 1,
 | |
|         startColumn: 1,
 | |
|         endLineNumber: 1,
 | |
|         endColumn: 1,
 | |
|         severity: severityMap[diagnostic.category] ?? "error",
 | |
|         code:
 | |
|           typeof diagnostic.code === "number"
 | |
|             ? String(diagnostic.code)
 | |
|             : undefined,
 | |
|         source: diagnostic.source,
 | |
|       };
 | |
|       results.push(mapped);
 | |
|       if (mapped.severity === "error") {
 | |
|         issues.push({
 | |
|           kind: "syntax",
 | |
|           severity: "error",
 | |
|           message,
 | |
|           range: {
 | |
|             startLineNumber: 1,
 | |
|             startColumn: 1,
 | |
|             endLineNumber: 1,
 | |
|             endColumn: 1,
 | |
|           },
 | |
|         });
 | |
|       }
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const startOffset = diagnostic.start;
 | |
|     const endOffset = startOffset + (diagnostic.length ?? 0);
 | |
|     const start = file.getLineAndCharacterOfPosition(startOffset);
 | |
|     const end = file.getLineAndCharacterOfPosition(endOffset);
 | |
| 
 | |
|     if (start.line < prefixInfo.lines) {
 | |
|       return;
 | |
|     }
 | |
|     if (end.line > lastLineIndex - suffixInfo.lines) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const toUserLine = (line: number) => line - prefixInfo.lines + 1;
 | |
|     const toUserColumn = (line: number, column: number) => {
 | |
|       if (line === prefixInfo.lines) {
 | |
|         const offset = prefixInfo.lastLineLength;
 | |
|         const adjusted = column - offset;
 | |
|         return Math.max(1, adjusted + 1);
 | |
|       }
 | |
|       return column + 1;
 | |
|     };
 | |
| 
 | |
|     const startLineNumber = toUserLine(start.line);
 | |
|     const endLineNumber = toUserLine(end.line);
 | |
| 
 | |
|     if (startLineNumber < 1 || endLineNumber < startLineNumber) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const startColumn = toUserColumn(start.line, start.character);
 | |
|     const endColumn = toUserColumn(end.line, end.character);
 | |
| 
 | |
|     const message = sanitizeDiagnosticMessage(
 | |
|       ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
 | |
|     );
 | |
|     const mapped: AnalyzeDiagnostic = {
 | |
|       message,
 | |
|       startLineNumber,
 | |
|       startColumn,
 | |
|       endLineNumber,
 | |
|       endColumn,
 | |
|       severity: severityMap[diagnostic.category] ?? "error",
 | |
|       code:
 | |
|         typeof diagnostic.code === "number"
 | |
|           ? String(diagnostic.code)
 | |
|           : undefined,
 | |
|       source: diagnostic.source,
 | |
|     };
 | |
|     results.push(mapped);
 | |
|     if (mapped.severity === "error") {
 | |
|       issues.push({
 | |
|         kind: "syntax",
 | |
|         severity: "error",
 | |
|         message,
 | |
|         range: {
 | |
|           startLineNumber,
 | |
|           startColumn,
 | |
|           endLineNumber,
 | |
|           endColumn,
 | |
|         },
 | |
|       });
 | |
|     }
 | |
|   });
 | |
| 
 | |
|   return { diagnostics: results, issues };
 | |
| }
 | |
| 
 | |
| async function evaluateModule(js: string) {
 | |
|   try {
 | |
|     const exports: Record<string, unknown> = {};
 | |
|     const module = { exports } as { exports: Record<string, unknown> };
 | |
|     const requireShim = (request?: string) => {
 | |
|       throw new ExternalImportError(request);
 | |
|     };
 | |
| 
 | |
|     const fn = new Function("exports", "module", "require", js) as (
 | |
|       exports: Record<string, unknown>,
 | |
|       module: { exports: Record<string, unknown> },
 | |
|       require: () => unknown,
 | |
|     ) => void;
 | |
|     fn(exports, module, requireShim);
 | |
| 
 | |
|     const candidate = module.exports?.default ?? module.exports;
 | |
|     if (
 | |
|       candidate &&
 | |
|       typeof (candidate as Promise<unknown>).then === "function"
 | |
|     ) {
 | |
|       try {
 | |
|         const value = await (candidate as Promise<unknown>);
 | |
|         return { value } as const;
 | |
|       } catch (error) {
 | |
|         return { error } as const;
 | |
|       }
 | |
|     }
 | |
|     return { value: candidate } as const;
 | |
|   } catch (error) {
 | |
|     return { error } as const;
 | |
|   }
 | |
| }
 | |
| 
 | |
| function cloneSerializable(value: unknown) {
 | |
|   try {
 | |
|     if (typeof structuredClone === "function") {
 | |
|       return { result: structuredClone(value), error: null };
 | |
|     }
 | |
|   } catch (error) {
 | |
|     return {
 | |
|       result: null,
 | |
|       error: error instanceof Error ? error.message : String(error ?? ""),
 | |
|     } as const;
 | |
|   }
 | |
| 
 | |
|   try {
 | |
|     return {
 | |
|       result: JSON.parse(JSON.stringify(value)) as unknown,
 | |
|       error: null,
 | |
|     } as const;
 | |
|   } catch (error) {
 | |
|     return {
 | |
|       result: null,
 | |
|       error: error instanceof Error ? error.message : String(error ?? ""),
 | |
|     } as const;
 | |
|   }
 | |
| }
 | |
| 
 | |
| function buildFallback(sourceFile: ts.SourceFile): string {
 | |
|   if (!sourceFile.statements.length) {
 | |
|     return "";
 | |
|   }
 | |
|   const last = sourceFile.statements[sourceFile.statements.length - 1];
 | |
|   if (!ts.isVariableStatement(last)) {
 | |
|     return "";
 | |
|   }
 | |
|   const names: string[] = [];
 | |
|   last.declarationList.declarations.forEach((declaration) => {
 | |
|     if (ts.isIdentifier(declaration.name)) {
 | |
|       names.push(declaration.name.text);
 | |
|     }
 | |
|   });
 | |
|   if (!names.length) {
 | |
|     return "";
 | |
|   }
 | |
|   const guards = names
 | |
|     .map(
 | |
|       (name) => `  if (typeof ${name} !== "undefined") {
 | |
|     module.exports = { default: ${name} };
 | |
|   }
 | |
| `,
 | |
|     )
 | |
|     .join("\n");
 | |
|   return `if (typeof module !== "undefined" && module && module.exports && Object.keys(module.exports).length === 0) {
 | |
| ${guards}}
 | |
| `;
 | |
| }
 | |
| 
 | |
| async function analyze(code: string): Promise<Omit<AnalyzeResponse, "id">> {
 | |
|   const candidates = STRATEGIES.filter((strategy) =>
 | |
|     strategy.enabled(code),
 | |
|   ).map((strategy) => {
 | |
|     const baseWrapped = `${strategy.prefix}${code}${strategy.suffix}`;
 | |
|     const baseSourceFile = ts.createSourceFile(
 | |
|       "ve-option.ts",
 | |
|       baseWrapped,
 | |
|       ts.ScriptTarget.ES2020,
 | |
|       true,
 | |
|       ts.ScriptKind.TSX,
 | |
|     );
 | |
| 
 | |
|     const fallback = buildFallback(baseSourceFile);
 | |
|     const wrapped = fallback ? `${baseWrapped}\n${fallback}` : baseWrapped;
 | |
|     const sourceFile = fallback
 | |
|       ? ts.createSourceFile(
 | |
|           "ve-option.ts",
 | |
|           wrapped,
 | |
|           ts.ScriptTarget.ES2020,
 | |
|           true,
 | |
|           ts.ScriptKind.TSX,
 | |
|         )
 | |
|       : baseSourceFile;
 | |
| 
 | |
|     const result = ts.transpileModule(wrapped, {
 | |
|       compilerOptions,
 | |
|       reportDiagnostics: true,
 | |
|       fileName: sourceFile.fileName,
 | |
|     });
 | |
| 
 | |
|     const { diagnostics, issues: syntaxIssues } = convertDiagnostics(
 | |
|       sourceFile,
 | |
|       result.diagnostics,
 | |
|       strategy,
 | |
|     );
 | |
| 
 | |
|     const errorCount = syntaxIssues.length;
 | |
| 
 | |
|     return {
 | |
|       strategy,
 | |
|       wrapped,
 | |
|       diagnostics,
 | |
|       syntaxIssues,
 | |
|       errorCount,
 | |
|       output: result.outputText,
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   if (!candidates.length) {
 | |
|     return {
 | |
|       strategy: "module",
 | |
|       diagnostics: [],
 | |
|       issues: [
 | |
|         {
 | |
|           kind: "runtime",
 | |
|           severity: "error",
 | |
|           message:
 | |
|             "Option analyzer could not find a parsing strategy for this code.",
 | |
|           hint: "Ensure the option is a valid JavaScript/TypeScript expression or module export.",
 | |
|         },
 | |
|       ],
 | |
|       runtimeError:
 | |
|         "Option analyzer could not find a parsing strategy for this code.",
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   candidates.sort((a, b) => a.errorCount - b.errorCount);
 | |
|   const winner = candidates[0];
 | |
|   const issues: AnalysisIssue[] = [...winner.syntaxIssues];
 | |
| 
 | |
|   if (winner.errorCount > 0 || !winner.output) {
 | |
|     return {
 | |
|       strategy: winner.strategy.name,
 | |
|       diagnostics: winner.diagnostics,
 | |
|       issues,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   const { value, error } = await evaluateModule(winner.output);
 | |
| 
 | |
|   if (error) {
 | |
|     const runtimeIssue = normalizeRuntimeIssue(error);
 | |
|     issues.push(runtimeIssue);
 | |
|     return {
 | |
|       strategy: winner.strategy.name,
 | |
|       diagnostics: winner.diagnostics,
 | |
|       issues,
 | |
|       output: winner.output,
 | |
|       runtimeError: runtimeIssue.message,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   const validation = validateOptionExport(value);
 | |
|   if (!validation.ok) {
 | |
|     issues.push(validation.issue);
 | |
|     return {
 | |
|       strategy: winner.strategy.name,
 | |
|       diagnostics: winner.diagnostics,
 | |
|       issues,
 | |
|       output: winner.output,
 | |
|       runtimeError: validation.issue.message,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   const { result: clonedValue, error: cloneError } = cloneSerializable(
 | |
|     validation.value,
 | |
|   );
 | |
|   if (cloneError) {
 | |
|     const serializationIssue = normalizeSerializationIssue(cloneError);
 | |
|     issues.push(serializationIssue);
 | |
|     return {
 | |
|       strategy: winner.strategy.name,
 | |
|       diagnostics: winner.diagnostics,
 | |
|       issues,
 | |
|       output: winner.output,
 | |
|       runtimeError: serializationIssue.message,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     strategy: winner.strategy.name,
 | |
|     diagnostics: winner.diagnostics,
 | |
|     issues,
 | |
|     output: winner.output,
 | |
|     option: clonedValue,
 | |
|     runtimeError: null,
 | |
|   };
 | |
| }
 | |
| 
 | |
| self.onmessage = async (event: MessageEvent<AnalyzeRequest>) => {
 | |
|   const { id, code } = event.data;
 | |
|   const response = await analyze(code);
 | |
|   (self as unknown as Worker).postMessage({
 | |
|     id,
 | |
|     ...response,
 | |
|   } satisfies AnalyzeResponse);
 | |
| };
 | |
| 
 | |
| function normalizeRuntimeIssue(error: unknown): AnalysisIssue {
 | |
|   if (error instanceof ExternalImportError) {
 | |
|     const source = error.request;
 | |
|     return {
 | |
|       kind: "runtime",
 | |
|       severity: "error",
 | |
|       message: source
 | |
|         ? `Imports from "${source}" can't be resolved in this editor.`
 | |
|         : "Imports that reference other files can't be resolved in this editor.",
 | |
|       hint: "Inline the referenced values directly inside the option snippet before generating imports.",
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   const message = toUserFacingMessage(error);
 | |
|   if (/Dynamic require/i.test(message)) {
 | |
|     return {
 | |
|       kind: "runtime",
 | |
|       severity: "error",
 | |
|       message:
 | |
|         "Imports that reference other files can't be resolved in this editor.",
 | |
|       hint: "Inline the referenced values directly inside the option snippet before generating imports.",
 | |
|     };
 | |
|   }
 | |
|   return {
 | |
|     kind: "runtime",
 | |
|     severity: "error",
 | |
|     message,
 | |
|   };
 | |
| }
 | |
| 
 | |
| function normalizeSerializationIssue(detail: string): AnalysisIssue {
 | |
|   const message =
 | |
|     "The exported option includes values that cannot be serialized (e.g. functions or DOM nodes).";
 | |
|   return {
 | |
|     kind: "format",
 | |
|     severity: "error",
 | |
|     message,
 | |
|     hint: detail,
 | |
|   };
 | |
| }
 | |
| 
 | |
| function toUserFacingMessage(error: unknown): string {
 | |
|   if (error instanceof Error) {
 | |
|     return error.message;
 | |
|   }
 | |
|   if (typeof error === "string") {
 | |
|     return error;
 | |
|   }
 | |
|   return "The option module threw an unknown error.";
 | |
| }
 | |
| 
 | |
| function validateOptionExport(
 | |
|   value: unknown,
 | |
| ):
 | |
|   | { ok: true; value: Record<string, unknown> }
 | |
|   | { ok: false; issue: AnalysisIssue } {
 | |
|   if (value === null || value === undefined) {
 | |
|     return {
 | |
|       ok: false,
 | |
|       issue: createMissingExportIssue(),
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   if (typeof value !== "object" || Array.isArray(value)) {
 | |
|     return {
 | |
|       ok: false,
 | |
|       issue: createInvalidExportIssue(value),
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     ok: true,
 | |
|     value: value as Record<string, unknown>,
 | |
|   };
 | |
| }
 | |
| 
 | |
| function createMissingExportIssue(): AnalysisIssue {
 | |
|   return {
 | |
|     kind: "format",
 | |
|     severity: "error",
 | |
|     message:
 | |
|       "No ECharts option export was found. Export your option object as the default value.",
 | |
|     hint: "Use `export default { ... }` or assign the option to the last declared variable.",
 | |
|   };
 | |
| }
 | |
| 
 | |
| function createInvalidExportIssue(value: unknown): AnalysisIssue {
 | |
|   const type = typeof value;
 | |
|   const base: AnalysisIssue = {
 | |
|     kind: "format",
 | |
|     severity: "error",
 | |
|     message: "The default export must be an ECharts option object.",
 | |
|   };
 | |
| 
 | |
|   if (type === "function") {
 | |
|     return {
 | |
|       ...base,
 | |
|       hint: "Call the function and export its return value instead of the function itself.",
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   if (type !== "object") {
 | |
|     return {
 | |
|       ...base,
 | |
|       hint: `Received a ${type}. Export an object with fields such as series or xAxis instead.`,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   if (Array.isArray(value)) {
 | |
|     return {
 | |
|       ...base,
 | |
|       hint: "Arrays are not valid options. Wrap your data in an object with option properties.",
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   return base;
 | |
| }
 | |
| 
 | |
| export { analyze };
 | |
| export type { AnalysisIssue, IssueKind, IssueRange };
 | 
