diff --git a/packages/nuxi/src/commands/build-module.ts b/packages/nuxi/src/commands/build-module.ts new file mode 100644 index 00000000000..dda6ad15715 --- /dev/null +++ b/packages/nuxi/src/commands/build-module.ts @@ -0,0 +1,82 @@ +import { promises as fsp } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { resolve } from 'pathe' +import consola from 'consola' + +import { writeModuleTypes, writeModuleCJSStub, loadUnbuild } from '../utils/build-module' +import { defineNuxtCommand } from './index' + +import type { NuxtModule } from '@nuxt/schema' + +export default defineNuxtCommand({ + meta: { + name: 'build-module', + usage: 'npx nuxi build-module [--stub] [--outDir] [rootDir]', + description: 'Build a nuxt module for development & production' + }, + async invoke (args) { + const rootDir = resolve(args._[0] || '.') + const outDir = args.outDir || 'dist' + + const { build } = await loadUnbuild(rootDir) + + await build(rootDir, false, { + declaration: true, + stub: args.stub, + entries: [ + 'src/module', + { input: 'src/runtime/', outDir: `${outDir}/runtime`, ext: 'mjs' } + ], + rollup: { + emitCJS: false, + cjsBridge: true + }, + externals: [ + '@nuxt/schema', + '@nuxt/schema-edge', + '@nuxt/kit', + '@nuxt/kit-edge', + 'nuxt', + 'nuxt-edge', + 'nuxt3', + 'vue' + ], + hooks: { + async 'rollup:done' (ctx) { + // Generate CommonJS setup + await writeModuleCJSStub(ctx.options.outDir) + + // Load module meta + const moduleEntryPath = resolve(ctx.options.outDir, 'module.mjs') + const moduleFn: NuxtModule = await import( + pathToFileURL(moduleEntryPath).toString() + ).then(r => r.default || r).catch((err) => { + consola.error(err) + consola.error('Cannot load module. Please check dist:', moduleEntryPath) + return null + }) + + // If module is not a function, return error + if (!moduleFn) return consola.error('It seems that `export default defineNuxtModule()` is not used in module.ts. Please make sure to define it as a default export.') + + const moduleMeta = await moduleFn.getMeta() + + // Enhance meta using package.json + if (ctx.pkg) { + moduleMeta.name = moduleMeta?.name ?? ctx.pkg.name + moduleMeta.version = moduleMeta?.version ?? ctx.pkg.version + } + + // Write meta + const metaFile = resolve(ctx.options.outDir, 'module.json') + await fsp.writeFile(metaFile, JSON.stringify(moduleMeta, null, 2), 'utf8') + + // Generate types + await writeModuleTypes(ctx.options.outDir, { + meta: moduleMeta + }) + } + } + }) + } +}) \ No newline at end of file diff --git a/packages/nuxi/src/commands/index.ts b/packages/nuxi/src/commands/index.ts index 4771e7c4da0..27f1c22d394 100644 --- a/packages/nuxi/src/commands/index.ts +++ b/packages/nuxi/src/commands/index.ts @@ -5,6 +5,7 @@ const _rDefault = (r: any) => r.default || r export const commands = { dev: () => import('./dev').then(_rDefault), build: () => import('./build').then(_rDefault), + ['build-module']: () => import('./build-module').then(_rDefault), cleanup: () => import('./cleanup').then(_rDefault), clean: () => import('./cleanup').then(_rDefault), preview: () => import('./preview').then(_rDefault), diff --git a/packages/nuxi/src/utils/build-module.ts b/packages/nuxi/src/utils/build-module.ts new file mode 100644 index 00000000000..711de220083 --- /dev/null +++ b/packages/nuxi/src/utils/build-module.ts @@ -0,0 +1,83 @@ +import { existsSync, promises as fsp } from 'node:fs' +import { resolve } from 'pathe' +import { findExports } from 'mlly' +import { importModule } from './cjs' + +import type { ModuleMeta } from '@nuxt/schema' + +export interface IWriteTypesOptions { + meta: ModuleMeta + // TODO: Use nuxt options to generate types + options?: any +} + +export const loadUnbuild = async (rootDir: string): Promise => { + try { + return await importModule('unbuild', rootDir) as typeof import('unbuild') + } catch (e: any) { + if (e.toString().includes("Cannot find module 'unbuild'")) { + throw new Error('nuxi build-module requires `unbuild` to be installed in your module project to build project. Try installing `unbuild` first.') + } + throw e + } +} + +export async function writeModuleTypes (distDir: string, { meta }: IWriteTypesOptions) { + const dtsFile = resolve(distDir, 'types.d.ts') + + // Read generated module types + const moduleTypesFile = resolve(distDir, 'module.d.ts') + const moduleTypes = await fsp.readFile(moduleTypesFile, 'utf8').catch(() => '') + const typeExports = findExports(moduleTypes) + const isStub = moduleTypes.includes('export *') + + const hasExportOption = (name: string) => isStub || typeExports.find(exp => exp.names.includes(name)) + + const schemaShims = [] + const moduleImports = [] + const moduleImportKeys = [ + { key: 'ModuleOptions', interfaces: ['NuxtConfig', 'NuxtOptions'] }, + { key: 'ModuleHooks', interfaces: ['ModuleHooks'] }, + { key: 'ModulePublicRuntimeConfig', interfaces: ['PublicRuntimeConfig'] }, + { key: 'ModulePrivateRuntimeConfig', interfaces: ['PrivateRuntimeConfig'] } + ] + + // Generate schema shims + for (const { key, interfaces } of moduleImportKeys) { + if (hasExportOption(key)) { + moduleImports.push(key) + for (const iface of interfaces) { + if (iface === 'NuxtConfig' && meta.configKey) { + schemaShims.push(` interface ${iface} { ['${meta.configKey}']?: Partial<${key}> }`) + } else if (iface === 'NuxtOptions' && meta.optionsKey) { + schemaShims.push(` interface ${iface} { ['${meta.configKey}']?: ${key} }`) + } else { + schemaShims.push(` interface ${iface} extends ${key} {}`) + } + } + } + } + + const dtsContents = ` +import { ${moduleImports.join(', ')} } from './module' +${schemaShims.length ? `declare module '@nuxt/schema' {\n${schemaShims.join('\n')}\n}\n` : ''} +export { ${typeExports[0].names.join(', ')} } from './module' +` + + await fsp.writeFile(dtsFile, dtsContents, 'utf8') +} + +export async function writeModuleCJSStub (distDir: string) { + const cjsStubFile = resolve(distDir, 'module.cjs') + + // If CJS stub already exists, skip + if (existsSync(cjsStubFile)) return + + const cjsStub = `module.exports = function(...args) { + return import('./module.mjs').then(m => m.default.call(this, ...args)) +} +const _meta = module.exports.meta = require('./module.json') +module.exports.getMeta = () => Promise.resolve(_meta) +` + await fsp.writeFile(cjsStubFile, cjsStub, 'utf8') +} \ No newline at end of file