scriptcat icon indicating copy to clipboard operation
scriptcat copied to clipboard

支援ES Module以及top level await

Open mon-jai opened this issue 8 months ago • 1 comments

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'
  }
`)

mon-jai avatar Apr 14 '25 18:04 mon-jai

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'
  }
`)

CodFrm avatar Jun 16 '25 03:06 CodFrm

@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 avatar Jun 27 '25 15:06 mon-jai

@mon-jai 但是这样怕有没有处理到的情况,比如一个字符串的import * from xxx,这是不应该处理的

如果可以用const xx = await import(xxx))来支持 ES Module,有什么必要一定要使用 import * from xxx的形式呢?

CodFrm avatar Jun 28 '25 13:06 CodFrm

没有很强的需求,另外也有替代方案,不打算处理 ES Module的问题,如果有必要,可以考虑增加一个类似 @es-module之类的属性,指明这是一个es module的脚本,使用 <script type="module"> 的方式去注入

CodFrm avatar Aug 31 '25 07:08 CodFrm