mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-10-28 03:25:02 +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 };
|