支援ES Module以及top level await
https://github.com/BlackGlory/eternity/blob/master/src/utils/esm.ts#L3 我看了一下代码,他好像是把代码编译成可以执行的,不过没有细究
关于这个需求,哥哥可以开一个新的issue,等mv3完成和稳定后再进行讨论
Originally posted by @CodFrm in #85
同樣基於MV3的 https://github.com/BlackGlory/eternity 支援import指令,但是scriptcat更全面,比較希望可以使用scriptcat
// @name Hello World
// @match <all_urls>
import { addStyleSheet } from "https://esm.sh/[email protected]"
addStyleSheet(`
*:before {
content: 'Hello'
}
*:after {
content: 'World'
}
`)
top level await 已支持,但是import * from xxx不太好在脚本猫中实现,这样会影响到GM API的调用与脚本的加载速度,你可以考虑下面这种形式(const xx = await import(xxx)):
// ==UserScript==
// @name New Userscript
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// @match https://bbs.tampermonkey.net.cn/
// ==/UserScript==
const { addStyleSheet } = await import('https://esm.sh/[email protected]');
console.log(addStyleSheet);
addStyleSheet(`
*:before {
content: 'Hello'
}
*:after {
content: 'World'
}
`)
@wangyizhi 或者我們可以在保存的時候,把 const xx = await import(xxx)) 轉譯成 import * from xxx 嗎?
我用LLM寫了一個初步的版本,應該不會很復雜:https://g.co/gemini/share/4dc65c370bd1
/**
* Represents a single named export within an import statement.
*/
type NamedExport = {
original: string;
alias?: string; // The alias name, if different from 'original'. Optional because it might not be renamed.
};
/**
* Represents the structured information extracted from an import statement.
*/
interface ImportInfo {
type: 'namespace' | 'default' | 'named' | 'combined' | 'sideEffect' | 'unknown';
modulePath: string; // The quoted module path (as captured from the original import, e.g., "'./module.js'")
alias?: string; // For 'namespace' type, e.g., 'path' from 'import * as path from "path";'
defaultAlias?: string; // For 'default' or 'combined' types, e.g., 'fs' from 'import fs from "fs";'
namedExports?: NamedExport[]; // Array of named imports, handling 'as' renames
}
/**
* Parses the specifier part of an import statement (the content between 'import ' and ' from ')
* to determine its specific type and extract relevant aliases/exports.
*
* @param importSpecifierContent The string containing the import specifiers (e.g., "fs, { readFile, SomeOther as SO }").
* @returns An `ImportInfo` object with the identified type and extracted details.
*/
function parseSpecifierPart(importSpecifierContent: string): Pick<ImportInfo, 'type' | 'alias' | 'defaultAlias' | 'namedExports'> {
const trimmedSpecifierContent = importSpecifierContent.trim();
// 1. Check for Namespace Import (e.g., `* as name`)
if (trimmedSpecifierContent.startsWith('* as ')) {
return {
type: 'namespace',
alias: trimmedSpecifierContent.substring(5).trim()
};
}
// 2. Handle Default, Named, and Combined Imports
let defaultAlias: string | undefined;
let namedExports: NamedExport[] | undefined = undefined; // Initialize as undefined
// Regex to match a default alias followed by an optional comma (e.g., `fs,` or `fs`)
const defaultAliasRegex = /^(?<alias>[a-zA-Z_$][0-9a-zA-Z_$]*)\s*,?/s;
const defaultAliasMatch = trimmedSpecifierContent.match(defaultAliasRegex);
if (defaultAliasMatch && defaultAliasMatch.groups!.alias) {
defaultAlias = defaultAliasMatch.groups!.alias;
}
// Regex to find the content within curly braces for named exports (e.g., `{ readFile, SomeOther as SO }`)
// Using `s` flag for `.` to match newlines within curly braces
const namedExportsBlockRegex = /\{\s*(?<content>(?:.|\n)*?)\s*\}/s;
const namedExportsBlockMatch = trimmedSpecifierContent.match(namedExportsBlockRegex);
if (namedExportsBlockMatch && namedExportsBlockMatch.groups!.content !== undefined && namedExportsBlockMatch.groups!.content.length > 0) {
const rawNamedExportsContent = namedExportsBlockMatch.groups!.content; // Content is already trimmed by regex
namedExports = []; // Initialize as an empty array if content is present
// Regex to parse each individual named export string (e.g., "readFile" or "SomeOther as SO")
// Captures: `original` (required), and `alias` (optional, for 'as' renamed imports)
const namedExportItemRegex = /^(?<original>[a-zA-Z_$][0-9a-zA-Z_$]*)(?:\s+as\s+(?<alias>[a-zA-Z_$][0-9a-zA-Z_$]*))?$/;
namedExports = rawNamedExportsContent.split(",")
.map(itemString => itemString.trim())
.filter(itemString => itemString.length > 0) // Filter out empty strings from splitting (e.g., "a,,b")
.map(itemString => {
const itemMatch = itemString.match(namedExportItemRegex);
if (itemMatch && itemMatch.groups) {
const original = itemMatch.groups.original;
const alias = itemMatch.groups.alias; // Will be undefined if 'as' is not present
// Conditionally add alias if it exists
return { original, ...(alias && { alias }) };
}
// Fallback for unexpected format - should ideally not be reached with correct regex and filter
return { original: itemString };
});
}
if (defaultAlias && namedExports !== undefined) {
// Combined default and named imports (e.g., `import fs, { readFile } from ...`)
return {
type: 'combined',
defaultAlias,
namedExports
};
} else if (defaultAlias) {
// Default import only (e.g., `import fs from ...`)
return {
type: 'default',
defaultAlias
};
} else if (namedExports !== undefined) { // Check for namedExports being an empty array (valid)
// Named imports only (e.g., `import { readFile } from ...`)
return {
type: 'named',
namedExports
};
}
// If none of the specific import types were identified, it's unknown.
return { type: 'unknown' };
}
/**
* Converts static JavaScript/TypeScript import statements to dynamic `await import()` calls.
* This function is zero-dependency and uses `String.prototype.replace` with a single,
* comprehensive regular expression to process the entire code, making it compatible
* with multi-line import statements. It robustly extracts module paths and unifies
* handling of default, named, and renamed imports.
*
* @param code The input JavaScript/TypeScript code as a string.
* @returns The transformed code with static imports converted to dynamic ones.
*/
function convertStaticImportsToDynamic(code: string): string {
// This comprehensive regex aims to match all types of static import statements.
// Flags:
// - `g`: Global, to replace all occurrences.
// - `m`: Multiline, so `^` and `$` match the start and end of lines (important for leading/trailing whitespace).
// - `s`: DotAll, so `.` matches newline characters (crucial for multi-line import specifiers).
//
// Breakdown of named capture groups:
// - `leadingWhitespace`: Any whitespace at the beginning of the line.
// - `specifierContent`: The content between 'import ' and ' from ' (optional, for side-effect imports).
// - `modulePath`: The ENTIRE QUOTED module path (e.g., "'./module.js'" or '"./lib"').
const importRegex = new RegExp(
`^(?<leadingWhitespace>\\s*)` + // 1. Leading whitespace (captured for re-insertion)
`import\\s+` + // 2. 'import ' keyword
`(` + // Start capturing entire import content after 'import ' (Group 1)
`(?<specifierContent>(?:.|\n)*?)` + // Captures specifiers like `* as name`, `default, { named }`, `{ named }`
`\\s+from\\s+` + // ' from ' keyword (if present)
`|` + // OR (for side-effect imports)
`(?!)` + // Empty non-capturing group to make the 'from' part optional for side-effects
`)?` + // End Group 1 (optional for side-effect imports)
`(?<modulePath>['"].*?['"])` + // Capture the full quoted path directly into `modulePath`
`;?` + // 5. Optional semicolon
`\\s*$` // 6. Optional trailing whitespace and end of line
, 'gms');
// Use String.prototype.replace with a replacer function
const convertedCode = code.replace(importRegex, (match, ...args) => {
// Safely destructure named capture groups from the last argument in 'args'
const groups = args[args.length - 1];
const { leadingWhitespace, specifierContent, modulePath } = groups;
let importInfo: ImportInfo | null = null;
// If a specifierContent was captured, parse it to determine the import type.
if (specifierContent) {
const parsedSpecifiers = parseSpecifierPart(specifierContent);
// Assign the captured *quoted* module path directly to importInfo.modulePath.
importInfo = { ...parsedSpecifiers, modulePath };
} else {
// If no specifierContent, it's a side-effect import (e.g., `import 'module';`)
// Assign the captured *quoted* module path directly to importInfo.modulePath.
importInfo = { type: 'sideEffect', modulePath };
}
// Construct the new dynamic import statement based on the parsed info.
let rewrittenLine = '';
if (importInfo) {
// All cases now directly use importInfo.modulePath, which holds the original quoted path.
switch (importInfo.type) {
case 'namespace':
rewrittenLine = `const ${importInfo.alias} = await import(${importInfo.modulePath});`;
break;
case 'default':
rewrittenLine = `const { default: ${importInfo.defaultAlias} } = await import(${importInfo.modulePath});`;
break;
case 'named':
// Reconstruct named exports from the array of objects
const namedExportsString = importInfo.namedExports!
.map(exp => exp.alias ? `${exp.original}: ${exp.alias}` : exp.original)
.join(', ');
rewrittenLine = `const { ${namedExportsString} } = await import(${importInfo.modulePath});`;
break;
case 'combined':
// Reconstruct named exports from the array of objects
const combinedNamedExportsString = importInfo.namedExports!
.map(exp => exp.alias ? `${exp.original}: ${exp.alias}` : exp.original)
.join(', ');
rewrittenLine = `const { default: ${importInfo.defaultAlias}, ${combinedNamedExportsString} } = await import(${importInfo.modulePath});`;
break;
case 'sideEffect':
rewrittenLine = `await import(${importInfo.modulePath});`;
break;
case 'unknown':
default:
// If the import type is unknown, return the original match to avoid unintended changes.
// This acts as a fallback for unsupported or malformed import syntax.
return match;
}
} else {
// Should not happen if regex is correct, but as a safeguard.
return match;
}
// Re-add the leading whitespace to maintain original indentation.
return leadingWhitespace + rewrittenLine;
});
return convertedCode;
}
測試:
const originalCode = `
import * as path from "path";
import fs, {
readFile,
SomeOther as SO
} from "fs/promises";
import { SomeClass, AnotherType as AT } from "./my-module";
import "reflect-metadata";
import { config } from "../config";
console.log('Hello');
// This import will be ignored as it's commented out
// import express from 'express';
const dynamicLoad = async () => {
const { default: chalk } = await import('chalk'); // Already dynamic, will be ignored
console.log(chalk.red('Dynamic import example'));
};
`;
const convertedCode = convertStaticImportsToDynamic(originalCode);
console.log(convertedCode);
結果:
const path = await import("path");
const { default: fs, readFile, SomeOther: SO } = await import("fs/promises");
const { SomeClass, AnotherType: AT } = await import("./my-module");
const { config } = await import("../config");
console.log('Hello');
// This import will be ignored as it's commented out
// import express from 'express';
const dynamicLoad = async () => {
const { default: chalk } = await import('chalk'); // Already dynamic, will be ignored
console.log(chalk.red('Dynamic import example'));
};
@mon-jai 但是这样怕有没有处理到的情况,比如一个字符串的import * from xxx,这是不应该处理的
如果可以用const xx = await import(xxx))来支持 ES Module,有什么必要一定要使用 import * from xxx的形式呢?
没有很强的需求,另外也有替代方案,不打算处理 ES Module的问题,如果有必要,可以考虑增加一个类似 @es-module之类的属性,指明这是一个es module的脚本,使用 <script type="module"> 的方式去注入