diff --git a/README.md b/README.md index fd315991..18cadabe 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,15 @@ cyfer, wavex, webext, aura, iron, reyna, aggron, wxt, yiga - [x] Supports all browsers - [x] MV2 & MV3 support - [x] Directory based entrypoints -- [ ] Great DX: auto-imports, TypeScript, HMR, and fast reload for content scripts -- [ ] ~~_Infer permissions from code_~~ +- [ ] Open browser during development with extension installed +- [ ] Great DX + - [x] Auto-imports + - [x] TypeScript, + - [ ] HMR for HTML pages + - [ ] auto-reload for background and content scripts - [ ] Automated publishing - [x] Download and bundle remote dependencies -- [ ] Supports all major frontend frameworks (Vue, React, Svelte) +- [x] Supports all major frontend frameworks (Vue, React, Svelte) - [ ] Bundle analysis +- [ ] Project bootstrap +- [ ] ~~_Infer permissions from code_~~ Maybe in the future... diff --git a/demo/entrypoints/background.ts b/demo/entrypoints/background.ts index 37774960..b87ce680 100644 --- a/demo/entrypoints/background.ts +++ b/demo/entrypoints/background.ts @@ -1,6 +1,3 @@ -import { defineBackgroundScript } from 'exvite/client'; -import browser from 'webextension-polyfill'; - export default defineBackgroundScript(() => { console.log(browser.runtime.id); }); diff --git a/demo/entrypoints/overlay.content/index.ts b/demo/entrypoints/overlay.content/index.ts index 7cb843c3..4a7e2027 100644 --- a/demo/entrypoints/overlay.content/index.ts +++ b/demo/entrypoints/overlay.content/index.ts @@ -1,5 +1,3 @@ -import { defineContentScript, mountContentScriptUi } from 'exvite/client'; -import browser from 'webextension-polyfill'; import '../../common/style.css'; export default defineContentScript({ diff --git a/demo/tsconfig.json b/demo/tsconfig.json index cf811839..57a1376c 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -1,4 +1,3 @@ { - "extends": "../tsconfig.base.json", - "exclude": [".output"] + "extends": ["../tsconfig.base.json", "./.exvite/tsconfig.json"] } diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 2b4e9d46..f8b54e85 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -1,9 +1,10 @@ import * as exvite from '../..'; +import { defineCommand } from '../utils/defineCommand'; -export async function build(root: any, { mode, config }: any) { +export const build = defineCommand(async (root: any, { mode, config }: any) => { await exvite.build({ mode, root, configFile: config, }); -} +}); diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index f2246f62..3389e499 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -1,9 +1,10 @@ import * as exvite from '../..'; +import { defineCommand } from '../utils/defineCommand'; -export async function dev(root: any, { mode, config }: any) { +export const dev = defineCommand(async (root: any, { mode, config }: any) => { await exvite.createServer({ mode, root, configFile: config, }); -} +}); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 489f4c50..2d38a08c 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,5 +1,6 @@ import { consola } from 'consola'; +import { defineCommand } from '../utils/defineCommand'; -export async function init(directory: any) { +export const init = defineCommand(async (directory: any) => { consola.warn('exvite init: Not implemented'); -} +}); diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 2b3ca89d..c3679b48 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -1,5 +1,17 @@ -import { consola } from 'consola'; +import { getInternalConfig } from '../../utils/getInternalConfig'; +import { findEntrypoints } from '../../utils/findEntrypoints'; +import { generateTypesDir } from '../../utils/generateTypesDir'; +import { defineCommand } from '../utils/defineCommand'; -export async function prepare(root: any, { mode, config }: any) { - consola.warn('exvite prepare: Not implemented'); -} +export const prepare = defineCommand( + async (root: any, { mode, config }: any) => { + const internalConfig = await getInternalConfig( + { root, mode, configFile: config }, + 'build', + ); + internalConfig.logger.info('Generating types...'); + + const entrypoints = await findEntrypoints(internalConfig); + await generateTypesDir(entrypoints, internalConfig); + }, +); diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 1d9b0ebb..2e14736d 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -1,5 +1,6 @@ import { consola } from 'consola'; +import { defineCommand } from '../utils/defineCommand'; -export async function publish(root: any, { mode, config }: any) { +export const publish = defineCommand(async (root: any, { config }: any) => { consola.warn('exvite publish: Not implemented'); -} +}); diff --git a/src/cli/utils/defineCommand.ts b/src/cli/utils/defineCommand.ts new file mode 100644 index 00000000..698640e3 --- /dev/null +++ b/src/cli/utils/defineCommand.ts @@ -0,0 +1,18 @@ +import { consola } from 'consola'; +import { printHeader } from './printHeader'; + +export function defineCommand(cb: (...args: any[]) => void | Promise) { + return async (...args: any[]) => { + const startTime = Date.now(); + try { + printHeader(); + + await cb(...args); + + consola.success(`Done in ${Date.now() - startTime} ms`); + } catch (err) { + consola.fail(`Command failed after ${Date.now() - startTime} ms`, err); + process.exit(1); + } + }; +} diff --git a/src/cli/utils/printHeader.ts b/src/cli/utils/printHeader.ts new file mode 100644 index 00000000..91c0a789 --- /dev/null +++ b/src/cli/utils/printHeader.ts @@ -0,0 +1,7 @@ +import pc from 'picocolors'; +import { version } from '../..'; +import { consola } from 'consola'; + +export function printHeader() { + consola.log(`\n${pc.dim('Exvite')} ${pc.dim(pc.bold(version))}`); +} diff --git a/src/index.ts b/src/index.ts index e2ce82e7..ea0903ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,9 @@ import { buildEntrypoints } from './utils/buildEntrypoints'; import { generateMainfest, writeManifest } from './utils/manifest'; import { printBuildSummary } from './utils/printBuildSummary'; import fs from 'fs-extra'; +import { generateTypesDir } from './utils/generateTypesDir'; +export { version } from '../package.json'; export * from './types/external'; export * from './utils/defineConfig'; @@ -18,12 +20,13 @@ export async function build(config: InlineConfig): Promise { await fs.ensureDir(internalConfig.outDir); const entrypoints = await findEntrypoints(internalConfig); + await generateTypesDir(entrypoints, internalConfig); const output = await buildEntrypoints(entrypoints, internalConfig); const manifest = await generateMainfest(entrypoints, output, internalConfig); await writeManifest(manifest, internalConfig); - printBuildSummary(output, internalConfig); + await printBuildSummary(output, internalConfig); return output; } diff --git a/src/types/external.ts b/src/types/external.ts index 5a98c88a..6b177617 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -1,6 +1,6 @@ import * as vite from 'vite'; -import { UnimportPluginOptions } from 'unimport/unplugin'; import { Manifest } from 'webextension-polyfill'; +import { UnimportOptions } from 'unimport'; export interface InlineConfig { root?: string; @@ -13,7 +13,7 @@ export interface InlineConfig { edge?: string; }; mode?: string; - imports?: Partial; + imports?: Partial; browser?: TargetBrowser; manifestVersion?: TargetManifestVersion; logger?: Logger; diff --git a/src/types/internal.ts b/src/types/internal.ts index 1ea8f693..4daaba1b 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -6,11 +6,15 @@ import { TargetManifestVersion, UserManifest, } from './external'; +import { UnimportOptions } from 'unimport'; export interface InternalConfig { root: string; srcDir: string; + exviteDir: string; + typesDir: string; entrypointsDir: string; + outBaseDir: string; outDir: string; storeIds: { chrome?: string; @@ -22,6 +26,7 @@ export interface InternalConfig { browser: TargetBrowser; manifestVersion: TargetManifestVersion; logger: Logger; + imports: Partial; vite: vite.InlineConfig; manifest: UserManifest; fsCache: FsCache; diff --git a/src/utils/generateTypesDir.ts b/src/utils/generateTypesDir.ts new file mode 100644 index 00000000..f84c9f63 --- /dev/null +++ b/src/utils/generateTypesDir.ts @@ -0,0 +1,136 @@ +import { UnimportOptions, createUnimport } from 'unimport'; +import { Entrypoint, InternalConfig } from '../types'; +import * as vite from 'vite'; +import fs from 'fs-extra'; +import { relative, resolve } from 'path'; +import { getEntrypointBundlePath } from './entrypoints'; + +/** + * Generate and write all the files inside the `InternalConfig.typesDir` directory. + */ +export async function generateTypesDir( + entrypoints: Entrypoint[], + config: InternalConfig, +): Promise { + await fs.ensureDir(config.typesDir); + + const references: string[] = []; + references.push(await writeImportsDeclarationFile(config)); + references.push(await writePathsDeclarationFile(entrypoints, config)); + + const mainReference = await writeMainDeclarationFile(references, config); + await writeTsConfigFile(mainReference, config); +} + +async function writeImportsDeclarationFile( + config: InternalConfig, +): Promise { + const filePath = resolve(config.typesDir, 'imports.d.ts'); + + const defaultOptions: Partial = { + debugLog: config.logger.debug, + imports: [ + { name: '*', as: 'browser', from: 'webextension-polyfill' }, + { name: 'defineConfig', from: 'exvite' }, + ], + warn: config.logger.warn, + dirs: ['components', 'composables', 'hooks', 'utils'], + }; + const merged = vite.mergeConfig( + defaultOptions, + config.imports, + ) as Partial; + + const unimport = createUnimport(merged); + // Load project imports into unimport memory so they are output via generateTypeDeclarations + await unimport.scanImportsFromDir(undefined, { cwd: config.srcDir }); + await unimport.scanImportsFromFile( + resolve('node_modules/exvite/dist/client/index.js'), + ); + + await fs.writeFile( + filePath, + ['// Generated by exvite', await unimport.generateTypeDeclarations()].join( + '\n', + ) + '\n', + ); + + return filePath; +} + +async function writePathsDeclarationFile( + entrypoints: Entrypoint[], + config: InternalConfig, +): Promise { + const filePath = resolve(config.typesDir, 'paths.d.ts'); + + await fs.writeFile( + filePath, + [ + '// Generated by exvite', + 'type EntrypointPath =', + ...entrypoints + .map((entry) => { + const path = getEntrypointBundlePath( + entry, + config.outDir, + entry.inputPath.endsWith('.html') ? '.html' : '.js', + ); + return ` | "/${path}"`; + }) + .sort(), + ].join('\n') + '\n', + ); + + return filePath; +} + +async function writeMainDeclarationFile( + references: string[], + config: InternalConfig, +): Promise { + const dir = config.exviteDir; + const filePath = resolve(dir, 'exvite.d.ts'); + await fs.writeFile( + filePath, + [ + '// Generated by exvite', + ...references.map( + (ref) => `/// `, + ), + ].join('\n') + '\n', + ); + return filePath; +} + +async function writeTsConfigFile( + mainReference: string, + config: InternalConfig, +) { + const dir = config.exviteDir; + await fs.writeFile( + resolve(dir, 'tsconfig.json'), + `{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + + /* Type Checking */ + "strict": true, + + /* Completeness */ + "skipLibCheck": true + }, + "include": [ + "${relative(dir, config.root)}/**/*", + "./${relative(dir, mainReference)}" + ], + "exclude": ["${relative(dir, config.outBaseDir)}"] +}`, + ); +} diff --git a/src/utils/getInternalConfig.ts b/src/utils/getInternalConfig.ts index e3ec6400..a18fc133 100644 --- a/src/utils/getInternalConfig.ts +++ b/src/utils/getInternalConfig.ts @@ -1,5 +1,5 @@ import { InlineConfig, InternalConfig, UserConfig } from '../types'; -import path from 'node:path'; +import path, { resolve } from 'node:path'; import * as vite from 'vite'; import { consola } from 'consola'; import { importTsFile } from './importTsFile'; @@ -24,30 +24,33 @@ export async function getInternalConfig( ); const mode = config.mode ?? (command === 'build' ? 'production' : 'development'); + const exviteDir = resolve(srcDir, '.exvite'); + const typesDir = resolve(exviteDir, 'types'); const browser = config.browser ?? 'chromium'; const manifestVersion = config.manifestVersion ?? (browser === 'chromium' ? 3 : 2); - const outDir = path.resolve( - root, - '.output', - `${browser}-mv${manifestVersion}`, - ); + const outBaseDir = path.resolve(root, '.output'); + const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`); const logger = config.logger ?? consola; const baseConfig: InternalConfig = { root, srcDir, entrypointsDir, + exviteDir, + typesDir, + outDir, + outBaseDir, storeIds: config.storeIds ?? {}, browser, manifestVersion, mode, command, - outDir, logger, vite: config.vite ?? {}, manifest: config.manifest ?? {}, fsCache: createFsCache(srcDir), + imports: config.imports ?? {}, }; // Load user config from file @@ -66,13 +69,13 @@ export async function getInternalConfig( // Customize the default vite config merged.vite.root = root; merged.vite.configFile = false; + merged.vite.logLevel = 'silent'; + merged.vite.build ??= {}; merged.vite.build.outDir = outDir; merged.vite.build.emptyOutDir = false; - merged.vite.logLevel = 'silent'; - merged.vite.plugins ??= []; - merged.vite.plugins.push(plugins.unimport(srcDir, userConfig.imports)); + merged.vite.plugins ??= []; merged.vite.plugins.push(plugins.download(merged)); return merged; diff --git a/src/utils/importTsFile.ts b/src/utils/importTsFile.ts index c9620084..dafc3b76 100644 --- a/src/utils/importTsFile.ts +++ b/src/utils/importTsFile.ts @@ -1,13 +1,14 @@ import { consola } from 'consola'; import createJITI from 'jiti'; import transform from 'jiti/dist/babel'; +import { resolve } from 'path'; +import { scanExports } from 'unimport'; export async function importTsFile(path: string): Promise { + const clientImports = await scanExports( + resolve('node_modules/exvite/dist/client/index.js'), + ); const jiti = createJITI(__filename, { - alias: { - 'webextension-polyfill': 'exvite', - '*.css': 'exvite', - }, cache: false, esmResolve: true, interopDefault: true, @@ -15,6 +16,27 @@ export async function importTsFile(path: string): Promise { transform(opts) { // Remove CSS imports from the source code - Jiti can't handle them. opts.source = opts.source.replace(/^import ['"].*\.css['"];?$/gm, ''); + opts.source = opts.source.replace( + /^import\s+.*\s+from ['"]webextension-polyfill['"];?$/gm, + '', + ); + + // Append any exvite/client functions so babel doesn't complain about undefined variables + if (opts.filename === path) { + // TODO: Only append import if it isn't already imported + const imports = + clientImports + .map((i) => `import { ${i.name} } from "${i.from}";`) + .join('\n') + '\n'; + opts.source = imports + opts.source; + } + + // console.log( + // `---\n${path}\n---\n${opts.source}---\n`, + // imports, + // '\n\n\n\n', + // ); + // Call the default babel transformer with our modified source code return transform(opts); }, diff --git a/src/vite-plugins/index.ts b/src/vite-plugins/index.ts index 42b30e97..a8174df1 100644 --- a/src/vite-plugins/index.ts +++ b/src/vite-plugins/index.ts @@ -1,3 +1,2 @@ export * from './download'; -export * from './unimport'; export * from './multipageMove'; diff --git a/src/vite-plugins/unimport.ts b/src/vite-plugins/unimport.ts deleted file mode 100644 index 9333d496..00000000 --- a/src/vite-plugins/unimport.ts +++ /dev/null @@ -1,43 +0,0 @@ -import Unimport, { UnimportPluginOptions } from 'unimport/unplugin'; -import { UserConfig } from '../types'; -import path from 'node:path'; -import * as vite from 'vite'; -import fs from 'fs-extra'; - -/** - * Apply defaults and returns an instance of the unimport plugin. - * - * @param root Absolute path to the vite root - * @param srcDir Absolute path to the source directory - * @param imports Custom import settings defined by the user - */ -export function unimport(srcDir: string, imports: UserConfig['imports']) { - const declartionFile = path.resolve(srcDir, '.exvite/types/imports.d.ts'); - const declarationDir = path.dirname(declartionFile); - fs.ensureDirSync(declarationDir); - - const defaultOptions: UnimportPluginOptions = { - include: srcDir, - exclude: [], - addons: [], - debugLog: () => {}, - dts: declartionFile, - imports: [{ name: '*', as: 'browser', from: 'webextension-polyfill' }], - presets: [ - // Scan for exported functions from the client package - { package: 'exvite/client' }, - ], - virtualImports: [], - warn: () => {}, - dirs: ['components', 'composables', 'hooks', 'utils'], - }; - const unimportConfig = vite.mergeConfig( - defaultOptions, - imports ?? {}, - ) as UnimportPluginOptions; - const unimport: typeof Unimport.vite = - // @ts-expect-error: esm availabe within the default object? - Unimport.vite ?? Unimport.default?.vite; - - return unimport(unimportConfig); -}