From dca43efe0df013b6c40955f76296048187c721a4 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 16 Jul 2023 14:38:56 -0500 Subject: [PATCH] feat: Add `wxt zip` command (#47) --- demo/package.json | 3 +- package.json | 4 +- pnpm-lock.yaml | 13 ++++ scripts/build.ts | 19 +----- src/@types/modules.d.ts | 16 +++++ src/cli/commands/index.ts | 1 + src/cli/commands/zip.ts | 31 ++++++++++ src/cli/index.ts | 10 ++++ src/core/log/printBuildSummary.ts | 48 ++------------- src/core/log/printFileList.ts | 50 ++++++++++++++++ src/core/types/external.ts | 51 ++++++++++++++++ src/core/types/internal.ts | 7 +++ src/core/utils/__tests__/strings.test.ts | 17 ++++++ src/core/utils/getInternalConfig.ts | 21 +++++++ src/core/utils/manifest.ts | 20 +------ src/core/utils/package.ts | 22 +++++++ src/core/utils/strings.ts | 6 ++ src/core/zip.ts | 75 ++++++++++++++++++++++++ src/testing/fake-objects.ts | 7 +++ 19 files changed, 340 insertions(+), 81 deletions(-) create mode 100644 src/@types/modules.d.ts create mode 100644 src/cli/commands/zip.ts create mode 100644 src/core/log/printFileList.ts create mode 100644 src/core/utils/__tests__/strings.test.ts create mode 100644 src/core/utils/package.ts create mode 100644 src/core/utils/strings.ts create mode 100644 src/core/zip.ts diff --git a/demo/package.json b/demo/package.json index 49a43c1f..7478ee08 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,5 +1,5 @@ { - "name": "Demo", + "name": "WXT Demo", "version": "1.0.0", "description": "Demo extension for WXT", "type": "module", @@ -11,6 +11,7 @@ "build:all:chrome-mv2": "wxt build --mv2", "build:all:firefox-mv3": "wxt build -b firefox --mv3", "build:all:firefox-mv2": "wxt build -b firefox", + "zip": "pnpm -w build && wxt zip", "postinstall": "pnpm -w build && wxt prepare" }, "dependencies": { diff --git a/package.json b/package.json index 98538af9..b8dc8977 100644 --- a/package.json +++ b/package.json @@ -73,13 +73,15 @@ "jiti": "^1.18.2", "json5": "^2.2.3", "linkedom": "^0.14.26", + "minimatch": "^9.0.3", "picocolors": "^1.0.0", "picomatch": "^2.3.1", "unimport": "^3.0.8", "vite": "^4.3.9", "vite-tsconfig-paths": "^4.2.0", "web-ext": "^7.6.2", - "webextension-polyfill": "^0.10.0" + "webextension-polyfill": "^0.10.0", + "zip-dir": "^2.0.0" }, "devDependencies": { "@faker-js/faker": "^8.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13e21edc..cd000f7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: linkedom: specifier: ^0.14.26 version: 0.14.26 + minimatch: + specifier: ^9.0.3 + version: 9.0.3 picocolors: specifier: ^1.0.0 version: 1.0.0 @@ -71,6 +74,9 @@ importers: webextension-polyfill: specifier: ^0.10.0 version: 0.10.0 + zip-dir: + specifier: ^2.0.0 + version: 2.0.0 devDependencies: '@faker-js/faker': specifier: ^8.0.2 @@ -3851,6 +3857,13 @@ packages: brace-expansion: 2.0.1 dev: false + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: false diff --git a/scripts/build.ts b/scripts/build.ts index 3cb045c8..ecf21e5b 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,7 +1,6 @@ import tsup from 'tsup'; import glob from 'fast-glob'; -import pc from 'picocolors'; -import path from 'path'; +import { printFileList } from '../src/core/log/printFileList'; import { formatDuration } from '../src/core/utils/formatDuration'; import ora from 'ora'; import fs from 'fs-extra'; @@ -73,18 +72,6 @@ await Promise.all([ spinner.succeed(); const duration = Date.now() - startTime; -const outFiles = await glob(`${outDir}/**`); -outFiles.forEach((file, i) => { - const color = file.endsWith('.map') - ? pc.dim - : file.endsWith('.d.ts') - ? pc.blue - : pc.cyan; - const prefix = i === outFiles.length - 1 ? ' └─' : ' ├─'; - console.log( - `${pc.gray(prefix)} ${pc.dim(outDir + path.sep)}${color( - path.relative(outDir, file), - )}`, - ); -}); +const outFiles = await glob(`${outDir}/**`, { absolute: true }); +await printFileList(consola.log, outDir, outFiles); consola.success(`Finished in ${formatDuration(duration)}`); diff --git a/src/@types/modules.d.ts b/src/@types/modules.d.ts new file mode 100644 index 00000000..cd6aa5d6 --- /dev/null +++ b/src/@types/modules.d.ts @@ -0,0 +1,16 @@ +declare module 'zip-dir' { + // Represents the options object for zipdir function + interface ZipDirOptions { + saveTo?: string; + filter?: (path: string, stat: import('fs').Stats) => boolean; + each?: (path: string) => void; + } + + function zipdir( + dirPath: string, + options?: ZipDirOptions, + callback?: (error: Error | null, buffer: Buffer) => void, + ): Promise; + + export = zipdir; +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index c27e10bc..452b0c16 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -3,3 +3,4 @@ export * from './dev'; export * from './init'; export * from './prepare'; export * from './publish'; +export * from './zip'; diff --git a/src/cli/commands/zip.ts b/src/cli/commands/zip.ts new file mode 100644 index 00000000..e1856952 --- /dev/null +++ b/src/cli/commands/zip.ts @@ -0,0 +1,31 @@ +import * as wxt from '../..'; +import { buildInternal } from '../../core/build'; +import { getInternalConfig } from '../../core/utils/getInternalConfig'; +import { zipExtension } from '../../core/zip'; +import { defineCommand } from '../utils/defineCommand'; + +export const zip = defineCommand< + [ + root: string | undefined, + flags: { + mode?: string; + config?: string; + browser?: wxt.TargetBrowser; + mv3?: boolean; + mv2?: boolean; + }, + ] +>(async (root, flags) => { + const mode = flags.mode ?? 'production'; + const cliConfig: wxt.InlineConfig = { + root, + mode, + browser: flags.browser, + manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined, + configFile: flags.config, + }; + + const config = await getInternalConfig(cliConfig, 'build'); + const output = await buildInternal(config); + await zipExtension(config, output); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 97656e3f..37e03974 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -28,6 +28,16 @@ cli .option('--mv2', 'target manifest v2') .action(commands.build); +// ZIP +cli + .command('zip [root]', 'build for production and zip output') + .option('-c, --config ', 'use specified config file') + .option('-m, --mode ', 'set env mode') + .option('-b, --browser ', 'specify a browser') + .option('--mv3', 'target manifest v3') + .option('--mv2', 'target manifest v2') + .action(commands.zip); + // PREPARE cli .command('prepare [root]', 'prepare') diff --git a/src/core/log/printBuildSummary.ts b/src/core/log/printBuildSummary.ts index 8990735a..a4c8016a 100644 --- a/src/core/log/printBuildSummary.ts +++ b/src/core/log/printBuildSummary.ts @@ -1,9 +1,6 @@ -import path, { relative, resolve } from 'path'; +import { resolve } from 'path'; import { BuildOutput, InternalConfig } from '../types'; -import { printTable } from './printTable'; -import pc from 'picocolors'; -import fs from 'fs-extra'; -import { filesize } from 'filesize'; +import { printFileList } from './printFileList'; export async function printBuildSummary( output: BuildOutput, @@ -20,31 +17,8 @@ export async function printBuildSummary( return l.fileName.localeCompare(r.fileName); }); - let totalSize = 0; - - const chunkRows: string[][] = await Promise.all( - chunks.map(async (chunk, i) => { - const file = [ - relative(process.cwd(), config.outDir) + path.sep, - chunk.fileName, - ]; - const prefix = i === chunks.length - 1 ? ' └─' : ' ├─'; - const color = getChunkColor(chunk.fileName); - const stats = await fs.lstat(resolve(config.outDir, chunk.fileName)); - totalSize += stats.size; - const size = String(filesize(stats.size)); - return [ - `${pc.gray(prefix)} ${pc.dim(file[0])}${color(file[1])}`, - pc.dim(size), - ]; - }), - ); - - printTable(config.logger.log, chunkRows); - - config.logger.log( - `${pc.cyan('Σ Total size:')} ${String(filesize(totalSize))}`, - ); + const files = chunks.map((chunk) => resolve(config.outDir, chunk.fileName)); + await printFileList(config.logger.log, config.outDir, files); } const DEFAULT_SORT_WEIGHT = 100; @@ -62,17 +36,3 @@ function getChunkSortWeight(filename: string) { )?.[1] ?? DEFAULT_SORT_WEIGHT ); } - -const DEFAULT_COLOR = pc.blue; -const CHUNK_COLORS: Record string> = { - '.js.map': pc.gray, - '.html': pc.green, - '.css': pc.magenta, - '.js': pc.cyan, -}; -function getChunkColor(filename: string) { - return ( - Object.entries(CHUNK_COLORS).find(([key]) => filename.endsWith(key))?.[1] ?? - DEFAULT_COLOR - ); -} diff --git a/src/core/log/printFileList.ts b/src/core/log/printFileList.ts new file mode 100644 index 00000000..9c8971a7 --- /dev/null +++ b/src/core/log/printFileList.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; +import pc from 'picocolors'; +import fs from 'fs-extra'; +import { filesize } from 'filesize'; +import { printTable } from './printTable'; + +export async function printFileList( + log: (message: string) => void, + baseDir: string, + files: string[], +): Promise { + let totalSize = 0; + + const fileRows: string[][] = await Promise.all( + files.map(async (file, i) => { + const parts = [ + path.relative(process.cwd(), baseDir) + path.sep, + path.relative(baseDir, file), + ]; + const prefix = i === files.length - 1 ? ' └─' : ' ├─'; + const color = getChunkColor(file); + const stats = await fs.lstat(file); + totalSize += stats.size; + const size = String(filesize(stats.size)); + return [ + `${pc.gray(prefix)} ${pc.dim(parts[0])}${color(parts[1])}`, + pc.dim(size), + ]; + }), + ); + + printTable(log, fileRows); + + log(`${pc.cyan('Σ Total size:')} ${String(filesize(totalSize))}`); +} + +const DEFAULT_COLOR = pc.blue; +const CHUNK_COLORS: Record string> = { + '.js.map': pc.gray, + '.html': pc.green, + '.css': pc.magenta, + '.js': pc.cyan, + '.zip': pc.yellow, +}; +function getChunkColor(filename: string) { + return ( + Object.entries(CHUNK_COLORS).find(([key]) => filename.endsWith(key))?.[1] ?? + DEFAULT_COLOR + ); +} diff --git a/src/core/types/external.ts b/src/core/types/external.ts index 1bada2b9..11b644f0 100644 --- a/src/core/types/external.ts +++ b/src/core/types/external.ts @@ -92,6 +92,57 @@ export interface InlineConfig { * Custom runner options. Options set here can be overridden in a `web-ext.config.ts` file. */ runner?: ExtensionRunnerConfig; + zip?: { + /** + * Configure the filename output when zipping files. + * + * Available template variables: + * + * - `{{name}}` - The project's name converted to kebab-case + * - `{{version}} - The version_name or version from the manifest + * - `{{browser}} - The target browser from the `--browser` CLI flag + * - `{{manifestVersion}}` - Either "2" or "3" + * + * @default "{{name}}-{{version}}-{{browser}}.zip" + */ + artifactTemplate?: string; + /** + * Configure the filename output when zipping files. + * + * Available template variables: + * + * - `{{name}}` - The project's name converted to kebab-case + * - `{{version}} - The version_name or version from the manifest + * - `{{browser}} - The target browser from the `--browser` CLI flag + * - `{{manifestVersion}}` - Either "2" or "3" + * + * @default "{{name}}-{{version}}-sources.zip" + */ + sourcesTemplate?: string; + /** + * Override the artifactTemplate's `{{name}}` template variable. Defaults to the package.json's + * name, or if that doesn't exist, the current working directories name. + */ + name?: string; + /** + * Root directory to ZIP. The ZIP can be uploaded to the Firefox Addon Store as your source + * code. Defaults to the `config.root` directory. + */ + sourcesRoot?: string; + /** + * [Minimatch](https://www.npmjs.com/package/minimatch) patterns of files to exclude when + * creating a ZIP of all your source code for Firfox. Patterns are relative to your + * `config.zip.sourcesRoot`. + * + * Hidden files, node_modules, and tests are ignored by default. + * + * @example + * [ + * "coverage", // Ignore the coverage directory in the `sourcesRoot` + * ] + */ + ignoredSources?: string[]; + }; } export interface WxtInlineViteConfig diff --git a/src/core/types/internal.ts b/src/core/types/internal.ts index ce597566..0a67bbd9 100644 --- a/src/core/types/internal.ts +++ b/src/core/types/internal.ts @@ -36,6 +36,13 @@ export interface InternalConfig { fsCache: FsCache; server?: WxtDevServer; runnerConfig: ResolvedConfig; + zip: { + name?: string; + artifactTemplate: string; + sourcesTemplate: string; + ignoredSources: string[]; + sourcesRoot: string; + }; } export type EntrypointGroup = Entrypoint | Entrypoint[]; diff --git a/src/core/utils/__tests__/strings.test.ts b/src/core/utils/__tests__/strings.test.ts new file mode 100644 index 00000000..fbddcccd --- /dev/null +++ b/src/core/utils/__tests__/strings.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { kebabCaseAlphanumeric } from '../strings'; + +describe('String utils', () => { + describe('kebabCaseAlphanumeric', () => { + it.each([ + ['HELLO', 'hello'], + ['Hello, World!', 'hello-world'], + ['hello123', 'hello123'], + ['Hello World This Is A Test', 'hello-world-this-is-a-test'], + ['Hello World', 'hello-world'], + ['hello-world', 'hello-world'], // Ensure hyphens are preserved + ])('should convert "%s" to "%s"', (input, expected) => { + expect(kebabCaseAlphanumeric(input)).toBe(expected); + }); + }); +}); diff --git a/src/core/utils/getInternalConfig.ts b/src/core/utils/getInternalConfig.ts index 034428d3..b7624a4c 100644 --- a/src/core/utils/getInternalConfig.ts +++ b/src/core/utils/getInternalConfig.ts @@ -99,6 +99,26 @@ export async function getInternalConfig( typesDir, fsCache: createFsCache(wxtDir), manifest, + zip: { + sourcesTemplate: '{{name}}-{{version}}-sources.zip', + artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip', + sourcesRoot: root, + ...userConfig.zip, + ...config.zip, + ignoredSources: [ + '**/node_modules', + // WXT files + '**/web-ext.config.ts', + // Hidden files + '**/.*', + // Tests + '**/__tests__/**', + '**/*.+(test|spec).?(c|m)+(j|t)s?(x)', + // User config + ...(userConfig.zip?.ignoredSources ?? []), + ...(config.zip?.ignoredSources ?? []), + ], + }, }; // Customize the default vite config @@ -144,6 +164,7 @@ type InternalConfigNoUserDirs = Omit< | 'typesDir' | 'fsCache' | 'manifest' + | 'zip' >; async function resolveManifestConfig( diff --git a/src/core/utils/manifest.ts b/src/core/utils/manifest.ts index b0a755fc..a06ee7a6 100644 --- a/src/core/utils/manifest.ts +++ b/src/core/utils/manifest.ts @@ -16,6 +16,7 @@ import { hashContentScriptOptions, mapWxtOptionsToContentScript, } from './content-scripts'; +import { getPackageJson } from './package'; /** * Writes the manifest to the output directory and the build output. @@ -88,25 +89,6 @@ export async function generateMainfest( return manifest; } -/** - * Read the package.json from the current directory. - * - * TODO: look in root and up directories until it's found - */ -async function getPackageJson( - config: InternalConfig, -): Promise> | undefined> { - const file = resolve(config.root, 'package.json'); - try { - return await fs.readJson(file); - } catch (err) { - config.logger.debug( - `Failed to read package.json at: ${file}. Returning undefined.`, - ); - return {}; - } -} - /** * Removes suffixes from the version, like X.Y.Z-alpha1 (which brosers don't allow), so it's a * simple version number, like X or X.Y or X.Y.Z, which browsers allow. diff --git a/src/core/utils/package.ts b/src/core/utils/package.ts new file mode 100644 index 00000000..570edba1 --- /dev/null +++ b/src/core/utils/package.ts @@ -0,0 +1,22 @@ +import { resolve } from 'node:path'; +import { InternalConfig } from '../types'; +import fs from 'fs-extra'; + +/** + * Read the project's package.json. + * + * TODO: look in root and up directories until it's found + */ +export async function getPackageJson( + config: InternalConfig, +): Promise> | undefined> { + const file = resolve(config.root, 'package.json'); + try { + return await fs.readJson(file); + } catch (err) { + config.logger.debug( + `Failed to read package.json at: ${file}. Returning undefined.`, + ); + return {}; + } +} diff --git a/src/core/utils/strings.ts b/src/core/utils/strings.ts new file mode 100644 index 00000000..d1114c43 --- /dev/null +++ b/src/core/utils/strings.ts @@ -0,0 +1,6 @@ +export function kebabCaseAlphanumeric(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9-\s]/g, '') // Remove all non-alphanumeric, non-hyphen characters + .replace(/\s+/g, '-'); // Replace spaces with hyphens +} diff --git a/src/core/zip.ts b/src/core/zip.ts new file mode 100644 index 00000000..562af6c7 --- /dev/null +++ b/src/core/zip.ts @@ -0,0 +1,75 @@ +import { BuildOutput, InternalConfig } from './types'; +import zipdir from 'zip-dir'; +import { dirname, relative, resolve } from 'node:path'; +import fs from 'fs-extra'; +import { kebabCaseAlphanumeric } from './utils/strings'; +import { getPackageJson } from './utils/package'; +import { minimatch } from 'minimatch'; +import { formatDuration } from './utils/formatDuration'; +import { printFileList } from './log/printFileList'; + +/** + * Zip the extension for distribution. Does not build, just zips the output that should exist for + * the given config. + * + * Returns a list of ZIP files that were created. + */ +export async function zipExtension( + config: InternalConfig, + buildOutput: BuildOutput, +): Promise { + const start = Date.now(); + config.logger.info('Zipping extension...'); + const zipFiles: string[] = []; + + const projectName = + config.zip.name ?? + kebabCaseAlphanumeric( + (await getPackageJson(config))?.name || dirname(process.cwd()), + ); + const applyTemplate = (template: string): string => + template + .replaceAll('{{name}}', projectName) + .replaceAll('{{browser}}', config.browser) + .replaceAll( + '{{version}}', + buildOutput.manifest.version_name ?? buildOutput.manifest.version, + ) + .replaceAll('{{manifestVersion}}', `mv${config.manifestVersion}`); + + await fs.ensureDir(config.outBaseDir); + + // ZIP output directory + + const outZipFilename = applyTemplate(config.zip.artifactTemplate); + const outZipPath = resolve(config.outBaseDir, outZipFilename); + await zipdir(config.outDir, { + saveTo: outZipPath, + }); + zipFiles.push(outZipPath); + + // ZIP sources for Firefox + + if (config.browser === 'firefox') { + const sourcesZipFilename = applyTemplate(config.zip.sourcesTemplate); + const sourcesZipPath = resolve(config.outBaseDir, sourcesZipFilename); + await zipdir(config.zip.sourcesRoot, { + saveTo: sourcesZipPath, + filter(path) { + const relativePath = relative(config.zip.sourcesRoot, path); + const matchedPattern = config.zip.ignoredSources.find((pattern) => + minimatch(relativePath, pattern), + ); + return matchedPattern == null; + }, + }); + zipFiles.push(sourcesZipPath); + } + + config.logger.success( + `Zipped extension in ${formatDuration(Date.now() - start)}`, + ); + await printFileList(config.logger.log, config.outBaseDir, zipFiles); + + return zipFiles; +} diff --git a/src/testing/fake-objects.ts b/src/testing/fake-objects.ts index ac2ac8bb..f673c94b 100644 --- a/src/testing/fake-objects.ts +++ b/src/testing/fake-objects.ts @@ -218,4 +218,11 @@ export const fakeInternalConfig = fakeObjectCreator(() => ({ vite: {}, wxtDir: fakeDir(), server: mock(), + zip: { + artifactTemplate: '{{name}}-{{version}}.zip', + ignoredSources: [], + sourcesRoot: fakeDir(), + sourcesTemplate: '{{name}}-sources.zip', + name: faker.person.firstName().toLowerCase(), + }, }));