diff --git a/package.json b/package.json index a953d8d..4209e5c 100644 --- a/package.json +++ b/package.json @@ -22,18 +22,21 @@ "scripts": { "build": "node ./scripts/build.js", "start": "node ./index.js", - "lint": "eslint --fix 'src/**'", + "lint": "eslint --fix ./src/**", "prepublishOnly": "npm run build" }, "dependencies": { "kolorist": "^1.5.1", "minimist": "^1.2.6", + "magicast": "^0.3.4", + "package-manager-detector": "^0.2.2", "prompts": "^2.4.2", "validate-npm-package-name": "^4.0.0" }, "devDependencies": { "@release-it/conventional-changelog": "^8.0.1", "@types/minimist": "^1.2.2", + "@types/node": "^20.12.8", "@types/prompts": "^2.0.14", "@types/validate-npm-package-name": "^4.0.0", "@typescript-eslint/eslint-plugin": "^5.30.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a25815..8bade05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,15 @@ importers: kolorist: specifier: ^1.5.1 version: 1.8.0 + magicast: + specifier: ^0.3.4 + version: 0.3.5 minimist: specifier: ^1.2.6 version: 1.2.8 + package-manager-detector: + specifier: ^0.2.2 + version: 0.2.2 prompts: specifier: ^2.4.2 version: 2.4.2 @@ -27,6 +33,9 @@ importers: '@types/minimist': specifier: ^1.2.2 version: 1.2.5 + '@types/node': + specifier: ^20.12.8 + version: 20.16.13 '@types/prompts': specifier: ^2.0.14 version: 2.4.9 @@ -454,8 +463,8 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/node@22.7.7': - resolution: {integrity: sha512-SRxCrrg9CL/y54aiMCG3edPKdprgMVGDXjA3gB8UmmBW5TcXzRUYAh8EWzTnSJFAd1rgImPELza+A3bJ+qxz8Q==} + '@types/node@20.16.13': + resolution: {integrity: sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1967,6 +1976,9 @@ packages: resolution: {integrity: sha512-tPJQ1HeyiU2vRruNGhZ+VleWuMQRro8iFtJxYgnS4NQe+EukKF6aGiIT+7flZhISAt2iaXBCfFGvAyif7/f8nQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -2197,6 +2209,9 @@ packages: resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} engines: {node: '>=18'} + package-manager-detector@0.2.2: + resolution: {integrity: sha512-VgXbyrSNsml4eHWIvxxG/nTL4wgybMTXCV2Un/+yEc3aDKKU6nQBZjbeP3Pl3qm9Qg92X/1ng4ffvCeD/zwHgg==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2525,6 +2540,10 @@ packages: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-resolve@0.5.3: resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} deprecated: See https://github.com/lydell/source-map-resolve#deprecated @@ -3165,7 +3184,7 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.7.7 + '@types/node': 20.16.13 '@types/yargs': 15.0.19 chalk: 4.1.2 @@ -3312,7 +3331,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 22.7.7 + '@types/node': 20.16.13 '@types/istanbul-lib-coverage@2.0.6': {} @@ -3328,7 +3347,7 @@ snapshots: '@types/minimist@1.2.5': {} - '@types/node@22.7.7': + '@types/node@20.16.13': dependencies: undici-types: 6.19.8 @@ -3336,7 +3355,7 @@ snapshots: '@types/prompts@2.4.9': dependencies: - '@types/node': 22.7.7 + '@types/node': 20.16.13 kleur: 3.0.3 '@types/semver@7.5.8': {} @@ -4730,7 +4749,7 @@ snapshots: dependencies: '@jest/types': 26.6.2 '@types/graceful-fs': 4.1.9 - '@types/node': 22.7.7 + '@types/node': 20.16.13 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -4750,13 +4769,13 @@ snapshots: jest-serializer@26.6.2: dependencies: - '@types/node': 22.7.7 + '@types/node': 20.16.13 graceful-fs: 4.2.11 jest-util@26.6.2: dependencies: '@jest/types': 26.6.2 - '@types/node': 22.7.7 + '@types/node': 20.16.13 chalk: 4.1.2 graceful-fs: 4.2.11 is-ci: 2.0.0 @@ -4764,7 +4783,7 @@ snapshots: jest-worker@26.6.2: dependencies: - '@types/node': 22.7.7 + '@types/node': 20.16.13 merge-stream: 2.0.0 supports-color: 7.2.0 @@ -4919,6 +4938,12 @@ snapshots: macos-release@3.3.0: {} + magicast@0.3.5: + dependencies: + '@babel/parser': 7.25.8 + '@babel/types': 7.25.8 + source-map-js: 1.2.1 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -5184,6 +5209,8 @@ snapshots: registry-url: 6.0.1 semver: 7.6.3 + package-manager-detector@0.2.2: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5520,6 +5547,8 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 + source-map-js@1.2.1: {} + source-map-resolve@0.5.3: dependencies: atob: 2.1.2 diff --git a/scripts/build.js b/scripts/build.js index dd1066f..9c853ee 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -8,7 +8,7 @@ async function bundleMain () { format: 'esm', platform: 'node', target: 'node18', - external: ['validate-npm-package-name', 'kolorist', 'minimist', 'prompts'], + external: ['package-browser-detector', 'magicast', 'validate-npm-package-name', 'kolorist', 'minimist', 'prompts'], }) } diff --git a/src/index.ts b/src/index.ts index 7f94e6a..a03a582 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,14 @@ import { mkdirSync, rmSync, writeFileSync } from 'fs' // Types import type { ContextState } from './utils/prompts' +import type { NuxtPresetName } from './utils/presets' // Utils import { initPrompts } from './utils/prompts' import { red } from 'kolorist' import minimist from 'minimist' import { installDependencies, renderTemplate } from './utils' +import { renderNuxtTemplate } from './utils/nuxt/renderNuxtTemplate' const validPresets = ['base', 'custom', 'default', 'essentials'] @@ -48,6 +50,12 @@ async function run () { usePackageManager, installDependencies: installDeps, usePreset, + useStore, + useEslint, + useNuxtV4Compat, + useNuxtModule, + useNuxtSSR, + useNuxtSSRClientHints, } = await initPrompts(context) const projectRoot = join(cwd, projectName) @@ -57,30 +65,51 @@ async function run () { rmSync(projectRoot, { recursive: true }) } - // Create project directory - mkdirSync(projectRoot) + const preset = context.usePreset ?? usePreset + + if (preset.startsWith('nuxt-')) { + const templateRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../template/typescript') + const templatePath = resolve(dirname(fileURLToPath(import.meta.url)), '../template/typescript/nuxt') + // we are going to run Nuxi CLI that will handle the creation for us + await renderNuxtTemplate({ + cwd, + projectName, + projectRoot, + templateRoot, + templatePath, + nuxtPreset: preset as NuxtPresetName, + useNuxtV4Compat, + useNuxtModule, + useNuxtSSR, + useNuxtSSRClientHints, + }) + } + else { + // Create project directory + mkdirSync(projectRoot) - // Create base package.json - writeFileSync(resolve(projectRoot, 'package.json'), JSON.stringify({ name: projectName }, null, 2)) + // Create base package.json + writeFileSync(resolve(projectRoot, 'package.json'), JSON.stringify({ name: projectName }, null, 2)) - const jsOrTs = useTypeScript ? 'typescript' : 'javascript' - let templatePath = resolve(dirname(fileURLToPath(import.meta.url)), '../template', jsOrTs) + console.log('\n◌ Generating scaffold...') - console.log('\n◌ Generating scaffold...') + const jsOrTs = useTypeScript ? 'typescript' : 'javascript' + let templatePath = resolve(dirname(fileURLToPath(import.meta.url)), '../template', jsOrTs) - renderTemplate(resolve(templatePath, 'default'), projectRoot) + renderTemplate(resolve(templatePath, 'default'), projectRoot) - if (['base', 'essentials'].includes(usePreset)) { - renderTemplate(resolve(templatePath, 'base'), projectRoot) - } + if (['base', 'essentials'].includes(usePreset)) { + renderTemplate(resolve(templatePath, 'base'), projectRoot) + } - if (['essentials', 'recommended'].includes(usePreset)) { - renderTemplate(resolve(templatePath, 'essentials'), projectRoot) - } + if (['essentials', 'recommended'].includes(usePreset)) { + renderTemplate(resolve(templatePath, 'essentials'), projectRoot) + } - if (usePackageManager && installDeps) { - console.log(`◌ Installing dependencies with ${usePackageManager}...\n`) - installDependencies(projectRoot, usePackageManager) + if (usePackageManager && installDeps) { + console.log(`◌ Installing dependencies with ${usePackageManager}...\n`) + installDependencies(projectRoot, usePackageManager) + } } console.log(`\n${projectName} has been generated at ${projectRoot}\n`) @@ -91,6 +120,7 @@ run() console.log('Discord community: https://community.vuetifyjs.com') console.log('Github: https://github.com/vuetifyjs/vuetify') console.log('Support Vuetify: https://github.com/sponsors/johnleider') + process.exit(0) }) .catch((err) => { console.error(`\n${red('✖')} ${err}\n`) diff --git a/src/utils/nuxt/renderNuxtTemplate.ts b/src/utils/nuxt/renderNuxtTemplate.ts new file mode 100644 index 0000000..9ea2f52 --- /dev/null +++ b/src/utils/nuxt/renderNuxtTemplate.ts @@ -0,0 +1,377 @@ +// Node +import fs from 'fs' +import path from 'path' +import { spawnSync } from 'child_process' + +// Types +import type { NuxtContext, PackageJsonEntry } from './types' + +// Utils +import { addPackageObject, detectPkgInfo, editFile, getPaths, runCommand } from './utils' +import { versions } from './versions' +import { detect } from 'package-manager-detector' +import { generateCode, parseModule } from 'magicast' +import { addNuxtModule, getDefaultExportOptions } from 'magicast/helpers' + +export async function renderNuxtTemplate(ctx: NuxtContext) { + const { + cwd, + projectName, + projectRoot, + useNuxtV4Compat, + nuxtPreset, + } = ctx + + const pkgInfo = detectPkgInfo() + const pkgManager = pkgInfo ? pkgInfo.name : 'npm' + const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.') + + const customCommand = useNuxtV4Compat + ? `npx nuxi@latest init -t v4-compat ${projectName}` + : `npm exec nuxi init ${projectName}` + + const fullCustomCommand = customCommand + // Only Yarn 1.x doesn't support `@version` in the `create` command + .replace('@latest', () => (isYarn1 ? '' : '@latest')) + .replace(/^npm exec/, () => { + // Prefer `pnpm dlx`, `yarn dlx`, or `bun x` + if (pkgManager === 'pnpm') + return 'pnpm dlx' + + if (pkgManager === 'yarn' && !isYarn1) + return 'yarn dlx' + + if (pkgManager === 'bun') + return 'bun x' + + // Use `npm exec` in all other cases, + // including Yarn 1.x and other custom npm clients. + return 'npm exec' + }) + + let [command, ...args] = fullCustomCommand.split(' ') + + const nuxiCli = spawnSync(command, args, { + cwd, + stdio: ['inherit', 'inherit', 'pipe'], + shell: true, + }) + + if (nuxiCli.error) { + throw nuxiCli.error + } + + // configure package.json + configurePackageJson(ctx) + + const pmDetection = await detect({ cwd: projectRoot }) + + // install dependencies + runCommand(pmDetection, 'install', [], projectRoot) + + // copy/replace resources + prepareProject(ctx) + + // install nuxt eslint: https://eslint.nuxt.com/packages/module#quick-setup + if (nuxtPreset !== 'nuxt-default') { + // we need eslint before executing the prepare command: + // once prepare command run, the eslint.config.mjs file will be created + runCommand(pmDetection, 'execute', ['nuxi', 'module', 'add', 'eslint'], projectRoot) + } +} + +function configurePackageJson({ + projectName, + projectRoot, + useNuxtModule, + nuxtPreset, +}: NuxtContext) { + const packageJson = path.join(projectRoot, 'package.json') + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')) + pkg.name = projectName + + // prepare scripts + const scripts: PackageJsonEntry[] = [ + ['prepare', 'nuxt prepare'], + ['typecheck', 'nuxt typecheck'], + ] + if (nuxtPreset !== 'nuxt-default') { + scripts.push(['lint', 'eslint .']) + scripts.push(['lint:fix', 'eslint . --fix']) + } + + // prepare dependencies + const dependencies: PackageJsonEntry[] = [ + ['vuetify', versions.vuetify], + ] + if (dependencies.length) { + addPackageObject('dependencies', dependencies, pkg) + } + + // prepare devDependencies + const devDependencies: PackageJsonEntry[] = [ + ['@mdi/font', versions['@mdi/font']], + ['@nuxt/fonts', versions['@nuxt/fonts']], + ['sass-embedded', versions['sass-embedded']], + ['typescript', versions.typescript], + ['vue-tsc', versions["vue-tsc"]], + ] + if (useNuxtModule) { + devDependencies.push(['vuetify-nuxt-module', versions["vuetify-nuxt-module"]]) + } + else { + devDependencies.push(['upath', versions['upath']]) + devDependencies.push(['@vuetify/loader-shared', versions["@vuetify/loader-shared"]]) + devDependencies.push(['vite-plugin-vuetify', versions["vite-plugin-vuetify"]]) + } + if (devDependencies.length) { + addPackageObject('devDependencies', devDependencies, pkg) + } + + // add scripts + addPackageObject('scripts', scripts, pkg, false) + + // save package.json + fs.writeFileSync(packageJson, JSON.stringify(pkg, null, 2), 'utf-8') +} + +function configureVuetify(ctx: NuxtContext, nuxtConfig: ReturnType) { + const config = getDefaultExportOptions(nuxtConfig) + config.ssr = ctx.useNuxtSSR + config.features = { + inlineStyles: !ctx.useNuxtModule, + devLogs: !ctx.useNuxtModule, + } + config.build = { transpile: ['vuetify'] } + config.vite = { + ssr: { + noExternal: ['vuetify'], + }, + } + config.css = [] + // vuetify-nuxt-module: + // - will detect @mdi/font adding to the css array + // - will add vuetify/styles to the css array if not disabled + if (!ctx.useNuxtModule) { + config.css.push('@mdi/font/css/materialdesignicons.css') + config.css.push('vuetify/styles') + } + // todo: add only required fonts + addNuxtModule(nuxtConfig, '@nuxt/fonts') + return config +} + +function copyResources( + ctx: NuxtContext, + rootPath: string, + templateDir: string, +) { + const { + useNuxtSSR, + useNuxtV4Compat, + nuxtPreset, + useNuxtModule, + templateRoot, + } = ctx + + // assets folder + const assetsDir = path.join(rootPath, 'assets') + let templateAssetsDir = path.join(templateRoot, 'default/src/assets') + fs.mkdirSync(assetsDir) + fs.copyFileSync( + path.join(templateAssetsDir, 'logo.png'), + path.join(assetsDir, 'logo.png'), + ) + fs.copyFileSync( + path.join(templateAssetsDir, 'logo.svg'), + path.join(assetsDir, 'logo.svg'), + ) + if (nuxtPreset !== 'nuxt-default') { + templateAssetsDir = path.join(templateRoot, 'base/src/styles') + + fs.copyFileSync( + path.join(templateAssetsDir, 'settings.scss'), + path.join(assetsDir, 'settings.scss'), + ) + } + + // plugins folder + const pluginsDir = path.join(rootPath, 'plugins') + const templatePluginsDir = path.join(templateDir, 'plugins') + fs.mkdirSync(pluginsDir) + if (useNuxtModule) { + // vuetify configuration file + // v4 compat: modules at root => rootPath is `${rootPath}/app` + // https://nuxt.com/docs/getting-started/upgrade#migrating-to-nuxt-4 + fs.copyFileSync( + path.join(templateDir, 'vuetify.config.ts'), + path.join(rootPath, useNuxtV4Compat ? '../vuetify.config.ts' : 'vuetify.config.ts'), + ) + // vuetify plugin + fs.copyFileSync( + path.join(templatePluginsDir, 'vuetify-nuxt.ts'), + path.join(pluginsDir, 'vuetify.ts'), + ) + } else { + // custom vuetify nuxt module + // v4 compat: modules at root => rootPath is `${rootPath}/app` + // https://nuxt.com/docs/getting-started/upgrade#migrating-to-nuxt-4 + const modulesDir = path.join(rootPath, useNuxtV4Compat ? '../modules' : 'modules') + const templateModulesDir = path.join(templateDir, 'modules') + fs.mkdirSync(modulesDir) + fs.copyFileSync( + path.resolve(templateModulesDir, 'vuetify.ts'), + path.resolve(modulesDir, 'vuetify.ts'), + ) + // vuetify plugin + editFile( + path.join(templatePluginsDir, 'vuetify.ts'), + (content) => { + return useNuxtSSR ? content : content.replace('ssr: true,', 'ssr: false,') + }, + path.resolve(pluginsDir, 'vuetify.ts'), + ) + } + // components + fs.copyFileSync( + path.resolve(templateDir, nuxtPreset === 'nuxt-essentials' ? 'app-layout.vue' : 'app.vue'), + path.resolve(rootPath, 'app.vue'), + ) + + // layouts + if (nuxtPreset === 'nuxt-essentials') { + const layoutsDir = path.join(rootPath, 'layouts') + const templateLayoutsDir = path.join(templateDir, 'layouts') + fs.mkdirSync(layoutsDir) + fs.copyFileSync( + path.resolve(templateLayoutsDir, 'default.vue'), + path.resolve(layoutsDir, 'default.vue'), + ) + } + const componentsDir = path.join(rootPath, 'components') + const templateComponentsDir = path.join(templateDir, 'components') + fs.mkdirSync(componentsDir) + fs.copyFileSync( + path.resolve(templateComponentsDir, 'AppFooter.vue'), + path.resolve(componentsDir, 'AppFooter.vue'), + ) + fs.copyFileSync( + path.resolve(templateComponentsDir, 'HelloWorld.vue'), + path.resolve(componentsDir, 'HelloWorld.vue'), + ) + // pages + const pagesDir = path.join(rootPath, 'pages') + const templatePagesDir = path.join(templateDir, 'pages') + fs.mkdirSync(pagesDir) + fs.copyFileSync( + path.resolve(templatePagesDir, 'index.vue'), + path.resolve(pagesDir, 'index.vue'), + ) +} + +function prepareNuxtModule( + ctx: NuxtContext, + nuxtConfig: ReturnType, +) { + // prepare nuxt config + const moduleOptions = { + ssrClientHints: { + reloadOnFirstRequest: false, + viewportSize: ctx.useNuxtSSR && ctx.useNuxtSSRClientHints, + prefersColorScheme: false, + prefersColorSchemeOptions: { + useBrowserThemeOnly: false, + }, + }, + styles: ctx.nuxtPreset === 'nuxt-default' ? true : { + configFile: 'assets/settings.scss', + }, + } + configureVuetify(ctx, nuxtConfig) + addNuxtModule( + nuxtConfig, + 'vuetify-nuxt-module', + 'vuetify', + { moduleOptions }, + ) +} + +function prepareVuetifyModule( + ctx: NuxtContext, + nuxtConfig: ReturnType, +) { + // prepare nuxt config + const config = configureVuetify(ctx, nuxtConfig) + + // enable auto import and include styles + const styles = ctx.nuxtPreset !== 'nuxt-essentials' ? true : { + configFile: 'assets/settings.scss', + } + config.vuetify = { autoImport: true, styles } +} + +function prepareProject(ctx: NuxtContext) { + const { + projectRoot, + templatePath, + useNuxtV4Compat, + useNuxtModule, + } = ctx + const [rootPath, templateDir] = getPaths(projectRoot, templatePath, useNuxtV4Compat) + + // load nuxt config file + // v4 compat: rootPath is `${rootPath}/app` + // https://nuxt.com/docs/getting-started/upgrade#migrating-to-nuxt-4 + const nuxtConfigFile = path.join(rootPath, useNuxtV4Compat ? '../nuxt.config.ts' : 'nuxt.config.ts') + const nuxtConfig = parseModule(fs.readFileSync(nuxtConfigFile, 'utf-8')) + + // prepare nuxt config + if (useNuxtModule) { + prepareNuxtModule(ctx, nuxtConfig) + } + else { + prepareVuetifyModule(ctx, nuxtConfig) + } + + // prepare nuxt config + let code = generateCode(nuxtConfig, { + trailingComma: true, + quote: 'single', + arrayBracketSpacing: false, + objectCurlySpacing: true, + lineTerminator: '\n', + format: { + trailingComma: true, + quote: 'single', + arrayBracketSpacing: false, + objectCurlySpacing: true, + useSemi: false, + }, + }).code + + // add some hints to the nuxt config + // https://github.com/Pinegrow/pg-nuxt-vuetify-tailwindcss/blob/00fac86769bc43e034b90dacfd03becc92b93b53/nuxt.config.ts#L100-L104 + if (useNuxtModule) { + code = code.replace('ssrClientHints:', `// check https://nuxt.vuetifyjs.com/guide/server-side-rendering.html + ssrClientHints:`) + code = code.replace('styles:', `// /* If customizing sass global variables ($utilities, $reset, $color-pack, $body-font-family, etc) */ + // disableVuetifyStyles: true, + styles:`) + } else { + code = code.replace('ssr:', `// when enabling/disabling ssr option, remember to update ssr option in plugins/vuetify.ts + ssr:`) + } + code = code.replace('features:', `// when enabling ssr option you need to disable inlineStyles and maybe devLogs + features:`) + + // save nuxt config file + fs.writeFileSync( + nuxtConfigFile, + code, + 'utf-8', + ) + + // prepare resources + copyResources(ctx, rootPath, templateDir) +} + diff --git a/src/utils/nuxt/types.ts b/src/utils/nuxt/types.ts new file mode 100644 index 0000000..0d48dbd --- /dev/null +++ b/src/utils/nuxt/types.ts @@ -0,0 +1,15 @@ +export interface NuxtContext { + cwd: string + projectName: string + projectRoot: string + templateRoot: string + templatePath: string + nuxtPreset: 'nuxt-base' | 'nuxt-default' | 'nuxt-essentials' + useNuxtV4Compat: boolean + useNuxtModule: boolean + useNuxtSSR: boolean + useNuxtSSRClientHints?: boolean +} + +export type PackageJsonEntry = [name: string, value: string] + diff --git a/src/utils/nuxt/utils.ts b/src/utils/nuxt/utils.ts new file mode 100644 index 0000000..5f9aa0a --- /dev/null +++ b/src/utils/nuxt/utils.ts @@ -0,0 +1,92 @@ +// Node +import process from 'process' +import path from 'path' +import { spawnSync } from 'child_process' +import fs from 'fs' + +// Types +import type { PackageJsonEntry } from './types' +import type { AgentCommands, DetectResult } from 'package-manager-detector' + +// Utils +import { resolveCommand } from 'package-manager-detector/commands' + +export function detectPkgInfo() { + const userAgent = process.env.npm_config_user_agent + if (!userAgent) + return undefined + const pkgSpec = userAgent.split(' ')[0] + const pkgSpecArr = pkgSpec.split('/') + return { + name: pkgSpecArr[0], + version: pkgSpecArr[1], + } +} + +export function addPackageObject( + key: 'scripts' | 'dependencies' | 'devDependencies' | 'overrides' | 'resolutions', + entry: PackageJsonEntry[], + pkg: any, + sort = true, +) { + pkg[key] ??= {} + if (!sort) { + for (const [name, value] of entry) + pkg[key][name] = value + + return + } + + const entries = Object.entries(pkg[key]) + pkg[key] = {} + entry.forEach(([name, value]) => { + entries.push([name, value]) + }) + entries.sort(([a], [b]) => a.localeCompare(b)).forEach(([k, v]) => { + pkg[key][k] = v + }) +} + +export function runCommand( + pmDetection: DetectResult | null, + command: keyof AgentCommands, + args: string[], + cwd: string, +) { + let runCommand = 'npm' + let runArgs: string[] = [command] + + // run install and prepare + if (pmDetection) { + const prepare = resolveCommand(pmDetection.name, command, args)! + runCommand = prepare.command + runArgs = prepare.args + } + + const run = spawnSync( + runCommand, + runArgs.filter(Boolean), { + cwd, + stdio: ['inherit', 'inherit', 'pipe'], + shell: true, + }, + ) + if (run.error) { + throw run.error + } +} + +export function editFile(file: string, callback: (content: string) => string, destination?: string) { + const content = fs.readFileSync(file, 'utf-8') + fs.writeFileSync(destination ?? file, callback(content), 'utf-8') +} + +export function getPaths( + rootPath: string, + templateDir: string, + v4: boolean, +): [rootPath: string, templateDir: string] { + return v4 + ? [path.join(rootPath, 'app'), templateDir] + : [rootPath, templateDir] +} diff --git a/src/utils/nuxt/versions.ts b/src/utils/nuxt/versions.ts new file mode 100644 index 0000000..c793179 --- /dev/null +++ b/src/utils/nuxt/versions.ts @@ -0,0 +1,12 @@ +export const versions = { + 'vuetify': '^3.7.3', + 'typescript': '^5.6.3', + 'vue-tsc': '^2.1.6', + 'sass-embedded': '^1.80.3', + '@vuetify/loader-shared': '^2.0.3', + 'vite-plugin-vuetify': '^2.0.4', + 'vuetify-nuxt-module': '^0.18.3', + 'upath': '^2.0.1', + '@mdi/font': '^7.4.47', + '@nuxt/fonts': '^0.10.0', +} as const diff --git a/src/utils/presets.ts b/src/utils/presets.ts index 613e31a..c5791ab 100644 --- a/src/utils/presets.ts +++ b/src/utils/presets.ts @@ -1,24 +1,36 @@ -const defaultContext = { +export interface Preset { + useEslint: boolean + useRouter: boolean + useStore: boolean +} + +export type NuxtPresetName = 'nuxt-base' | 'nuxt-default' | 'nuxt-essentials' +export type PresetName = 'base' | 'default' | 'essentials' | NuxtPresetName + +const defaultContext: Preset = { useEslint: false, useRouter: false, useStore: false, } -const baseContext = { +const baseContext: Preset = { ...defaultContext, useEslint: true, useRouter: true, } -const essentialsContext = { +const essentialsContext: Preset = { ...baseContext, useStore: true, } -const presets = { - base: baseContext, - default: defaultContext, - essentials: essentialsContext, +const presets: Record = { + 'base': baseContext, + 'default': defaultContext, + 'essentials': essentialsContext, + 'nuxt-base': baseContext, + 'nuxt-default': defaultContext, + 'nuxt-essentials': essentialsContext, } export { presets } diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index 9d11fbf..9739a4d 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -3,7 +3,7 @@ import { resolve, join } from 'path' import { existsSync, readdirSync } from 'fs' // Types -import type { Options as PromptOptions, PromptObject } from 'prompts' +import type { Options as PromptOptions } from 'prompts' // Utils import { presets } from './presets' @@ -18,79 +18,16 @@ type ContextState = { useTypeScript?: boolean, usePackageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun', installDependencies?: boolean, - usePreset?: 'base' | 'default' | 'essentials' + usePreset?: 'base' | 'default' | 'essentials' | 'nuxt-base' | 'nuxt-default' | 'nuxt-essentials' + useEslint?: boolean, + useRouter?: boolean, + useStore?: boolean + useNuxtV4Compat?: boolean + useNuxtModule?: boolean + useNuxtSSR?: boolean + useNuxtSSRClientHints?: boolean } -// Array of prompt question objects -const promptQuestions = (context: ContextState): PromptObject[] => [ - { - name: 'projectName', - type: 'text', - message: 'Project name:', - initial: 'vuetify-project', - validate: (v: string) => { - const { errors } = validate(String(v).trim()) - - return !(errors && errors.length) || `Package ${errors[0]}` - }, - }, - { - name: 'canOverwrite', - active: 'Yes', - inactive: 'No', - initial: false, - type: (_: any, { projectName }) => { - const projectPath = join(context.cwd, projectName) - - return ( - !existsSync(projectPath) || - readdirSync(projectPath).length === 0 - ) ? null : 'toggle' - }, - message: (prev: string) => `The project path: ${resolve(context.cwd, prev)} already exists, would you like to overwrite this directory?`, - }, - { - name: 'usePreset', - type: context.usePreset ? null : 'select', - message: 'Which preset would you like to install?', - initial: 1, - choices: [ - { title: 'Barebones (Only Vue & Vuetify)', value: 'default' }, - { title: 'Default (Adds routing, ESLint & SASS variables)', value: 'base' }, - { title: 'Recommended (Everything from Default. Adds auto importing, layouts & pinia)', value: 'essentials' }, - ], - }, - { - name: 'useTypeScript', - type: context.useTypeScript ? null : 'toggle', - message: 'Use TypeScript?', - active: 'Yes', - inactive: 'No', - initial: false, - }, - { - name: 'usePackageManager', - type: 'select', - message: 'Would you like to install dependencies with yarn, npm, pnpm, or bun?', - initial: 0, - choices: [ - { title: 'yarn', value: 'yarn' }, - { title: 'npm', value: 'npm' }, - { title: 'pnpm', value: 'pnpm' }, - { title: 'bun', value: 'bun' }, - { title: 'none', value: null }, - ], - }, - { - name: 'installDependencies', - type: context.installDependencies ? null : 'toggle', - message: 'Install Dependencies?', - active: 'Yes', - inactive: 'No', - initial: 'Yes', - }, -] - const promptOptions: PromptOptions = { onCancel: () => { throw new Error(red('✖') + ' Operation cancelled') @@ -100,6 +37,11 @@ const promptOptions: PromptOptions = { type DefinedContextState = { [P in keyof ContextState]-?: ContextState[P] } const initPrompts = async (context: ContextState) => { + + let answers: prompts.Answers< + 'projectName' | 'canOverwrite' | 'usePreset' | 'useTypeScript' | 'usePackageManager' | 'installDependencies' | 'useNuxtV4Compat' | 'useNuxtModule' | 'useNuxtSSR' | 'useNuxtSSRClientHints' + > + if (context.usePreset) { context = { ...context, @@ -107,7 +49,133 @@ const initPrompts = async (context: ContextState) => { } } - const answers = await prompts(promptQuestions(context), promptOptions) + answers = await prompts([ + { + name: 'projectName', + type: 'text', + message: 'Project name:', + initial: 'vuetify-project', + validate: (v: string) => { + const { errors } = validate(String(v).trim()) + + return !(errors && errors.length) || `Package ${errors[0]}` + }, + }, + { + name: 'canOverwrite', + active: 'Yes', + inactive: 'No', + initial: false, + type: (_: any, { projectName }) => { + const projectPath = join(context.cwd, projectName) + + return ( + !existsSync(projectPath) || + readdirSync(projectPath).length === 0 + ) ? null : 'toggle' + }, + message: (prev: string) => `The project path: ${resolve(context.cwd, prev)} already exists, would you like to overwrite this directory?`, + }, + { + name: 'usePreset', + type: context.usePreset ? null : 'select', + message: 'Which preset would you like to install?', + initial: 1, + choices: [ + { title: 'Barebones (Only Vue & Vuetify)', value: 'default' }, + { title: 'Default (Adds routing, ESLint & SASS variables)', value: 'base' }, + { title: 'Recommended (Everything from Default. Adds auto importing, layouts & pinia)', value: 'essentials' }, + { title: 'Nuxt Barebones (Only Vuetify)', value: 'nuxt-default' }, + { title: 'Nuxt Default (Adds Nuxt ESLint & SASS variables)', value: 'nuxt-base' }, + { title: 'Nuxt Recommended (Everything from Default. Enables auto importing & layouts)', value: 'nuxt-essentials' }, + ], + }, + { + name: 'useTypeScript', + type: (usePreset) => { + const p = context.usePreset ?? usePreset + return p.startsWith('nuxt-') || context.useTypeScript ? null : 'toggle' + }, + message: 'Use TypeScript?', + active: 'Yes', + inactive: 'No', + initial: false, + }, + { + name: 'usePackageManager', + type: (_, { usePreset }) => { + const p = context.usePreset ?? usePreset + return p.startsWith('nuxt-') ? null : 'select' + }, + message: 'Would you like to install dependencies with yarn, npm, pnpm, or bun?', + initial: 0, + choices: [ + { title: 'yarn', value: 'yarn' }, + { title: 'npm', value: 'npm' }, + { title: 'pnpm', value: 'pnpm' }, + { title: 'bun', value: 'bun' }, + { title: 'none', value: null }, + ], + }, + { + name: 'installDependencies', + type: (_, { usePreset }) => { + const p = context.usePreset ?? usePreset + return p.startsWith('nuxt-') || context.installDependencies ? null : 'toggle' + }, + message: 'Install Dependencies?', + active: 'Yes', + inactive: 'No', + initial: 'Yes', + }, + { + name: 'useNuxtV4Compat', + type: (_, { usePreset }) => { + const p = context.usePreset ?? usePreset + return p.startsWith('nuxt-') ? 'toggle' : null + }, + message: 'Use Nuxt v4 compatibility?', + active: 'Yes', + inactive: 'No', + initial: 'Yes', + }, + { + name: 'useNuxtModule', + type: (_, { usePreset }) => { + const p = context.usePreset ?? usePreset + return p.startsWith('nuxt-') ? 'toggle' : null + }, + message: 'Use vuetify-nuxt-module?', + active: 'Yes', + inactive: 'No', + initial: 'Yes', + }, + { + name: 'useNuxtSSR', + type: (_, { usePreset }) => { + const p = context.usePreset ?? usePreset + return p.startsWith('nuxt-') ? 'toggle' : null + }, + message: 'Enable Nuxt SSR?', + active: 'Yes', + inactive: 'No', + initial: 'Yes', + }, + { + name: 'useNuxtSSRClientHints', + type: (useNuxtSSR, { usePreset, useNuxtModule }) => { + const p = context.usePreset ?? usePreset + if (!p.startsWith('nuxt-')) { + return null + } + return useNuxtModule && useNuxtSSR ? 'toggle' : null + }, + message: 'Enable Nuxt SSR Http Client Hints?', + active: 'Yes', + inactive: 'No', + initial: 'Yes', + }, + ], promptOptions) return { ...context, diff --git a/template/typescript/nuxt/app-layout.vue b/template/typescript/nuxt/app-layout.vue new file mode 100644 index 0000000..f8eacfa --- /dev/null +++ b/template/typescript/nuxt/app-layout.vue @@ -0,0 +1,5 @@ + diff --git a/template/typescript/nuxt/app.vue b/template/typescript/nuxt/app.vue new file mode 100644 index 0000000..bd9ec52 --- /dev/null +++ b/template/typescript/nuxt/app.vue @@ -0,0 +1,9 @@ + diff --git a/template/typescript/nuxt/components/AppFooter.vue b/template/typescript/nuxt/components/AppFooter.vue new file mode 100644 index 0000000..33d6ae6 --- /dev/null +++ b/template/typescript/nuxt/components/AppFooter.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/template/typescript/nuxt/components/HelloWorld.vue b/template/typescript/nuxt/components/HelloWorld.vue new file mode 100644 index 0000000..d244b68 --- /dev/null +++ b/template/typescript/nuxt/components/HelloWorld.vue @@ -0,0 +1,153 @@ + diff --git a/template/typescript/nuxt/layouts/default.vue b/template/typescript/nuxt/layouts/default.vue new file mode 100644 index 0000000..a64d00d --- /dev/null +++ b/template/typescript/nuxt/layouts/default.vue @@ -0,0 +1,9 @@ + diff --git a/template/typescript/nuxt/modules/vuetify.ts b/template/typescript/nuxt/modules/vuetify.ts new file mode 100644 index 0000000..6ed35cc --- /dev/null +++ b/template/typescript/nuxt/modules/vuetify.ts @@ -0,0 +1,125 @@ +import { defineNuxtModule } from '@nuxt/kit' +import type { Options as ModuleOptions } from '@vuetify/loader-shared' +import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' +import path from 'upath' +import { resolveVuetifyBase, isObject } from '@vuetify/loader-shared' +import { pathToFileURL } from 'node:url' + +export type { ModuleOptions } + +// WARNING: Remove the file from modules directory if you install vuetify-nuxt-module +export default defineNuxtModule({ + meta: { + name: 'vuetify-module', + configKey: 'vuetify', + }, + defaults: () => ({ styles: true }), + setup(options, nuxt) { + let configFile: string | undefined + const vuetifyBase = resolveVuetifyBase() + const noneFiles = new Set() + let isNone = false + let sassVariables = false + const PREFIX = 'vuetify-styles/' + const SSR_PREFIX = `/@${PREFIX}` + + nuxt.hook('vite:extendConfig', (viteInlineConfig) => { + // add vuetify transformAssetUrls + viteInlineConfig.vue ??= {} + viteInlineConfig.vue.template ??= {} + viteInlineConfig.vue.template.transformAssetUrls = transformAssetUrls + + viteInlineConfig.plugins = viteInlineConfig.plugins ?? [] + viteInlineConfig.plugins.push(vuetify({ + autoImport: options.autoImport, + styles: true, + })) + + viteInlineConfig.css ??= {} + viteInlineConfig.css.preprocessorOptions ??= {} + viteInlineConfig.css.preprocessorOptions.sass ??= {} + viteInlineConfig.css.preprocessorOptions.sass.api = 'modern-compiler' + + viteInlineConfig.plugins.push({ + name: 'vuetify:nuxt:styles', + enforce: 'pre', + async configResolved (config) { + if (isObject(options.styles)) { + sassVariables = true + if (path.isAbsolute(options.styles.configFile)) { + configFile = path.resolve(options.styles.configFile) + } else { + configFile = path.resolve(path.join(config.root || process.cwd(), options.styles.configFile)) + } + configFile = pathToFileURL(configFile).href + } + else { + isNone = options.styles === 'none' + } + }, + async resolveId (source, importer, { custom, ssr }) { + if (source.startsWith(PREFIX) || source.startsWith(SSR_PREFIX)) { + if (source.endsWith('.sass')) { + return source + } + + const idx = source.indexOf('?') + return idx > -1 ? source.slice(0, idx) : source + } + if ( + source === 'vuetify/styles' || ( + importer && + source.endsWith('.css') && + isSubdir(vuetifyBase, path.isAbsolute(source) ? source : importer) + ) + ) { + if (options.styles === 'sass') { + const target = source.replace(/\.css$/, '.sass') + return this.resolve(target, importer, { skipSelf: true, custom }) + } + + const resolution = await this.resolve(source, importer, { skipSelf: true, custom }) + + if (!resolution) + return undefined + + const target = resolution.id.replace(/\.css$/, '.sass') + if (isNone) { + noneFiles.add(target) + return target + } + + return `${ssr ? SSR_PREFIX: PREFIX}${path.relative(vuetifyBase, target)}` + } + + return undefined + }, + load(id){ + if (sassVariables) { + const target = id.startsWith(PREFIX) + ? path.resolve(vuetifyBase, id.slice(PREFIX.length)) + : id.startsWith(SSR_PREFIX) + ? path.resolve(vuetifyBase, id.slice(SSR_PREFIX.length)) + : undefined + + if (target) { + return { + code: `@use "${configFile}"\n@use "${pathToFileURL(target).href}"`, + map: { + mappings: '', + }, + } + } + } + + return isNone && noneFiles.has(id) ? '' : undefined + }, + }) + }) + } +}) + +function isSubdir (root: string, test: string) { + const relative = path.relative(root, test) + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) +} diff --git a/template/typescript/nuxt/pages/index.vue b/template/typescript/nuxt/pages/index.vue new file mode 100644 index 0000000..a9ca3ca --- /dev/null +++ b/template/typescript/nuxt/pages/index.vue @@ -0,0 +1,8 @@ + + + + diff --git a/template/typescript/nuxt/plugins/vuetify-nuxt.ts b/template/typescript/nuxt/plugins/vuetify-nuxt.ts new file mode 100644 index 0000000..62d49e4 --- /dev/null +++ b/template/typescript/nuxt/plugins/vuetify-nuxt.ts @@ -0,0 +1,8 @@ +export default defineNuxtPlugin((nuxtApp) => { + // check https://vuetify-nuxt-module.netlify.app/guide/nuxt-runtime-hooks.html + nuxtApp.hook('vuetify:before-create', (options) => { + if (import.meta.client) { + console.log('vuetify:before-create', options) + } + }) +}) diff --git a/template/typescript/nuxt/plugins/vuetify.ts b/template/typescript/nuxt/plugins/vuetify.ts new file mode 100644 index 0000000..dd282a6 --- /dev/null +++ b/template/typescript/nuxt/plugins/vuetify.ts @@ -0,0 +1,14 @@ +import { createVuetify } from 'vuetify' + +export default defineNuxtPlugin((nuxtApp) => { + const vuetify = createVuetify({ + // WARNING: when switching ssr option in nuxt.config.ts file you need to manually change it here + ssr: true, + // your Vuetify options here + theme: { + defaultTheme: 'dark', + }, + }) + + nuxtApp.vueApp.use(vuetify); +}) diff --git a/template/typescript/nuxt/vuetify.config.ts b/template/typescript/nuxt/vuetify.config.ts new file mode 100644 index 0000000..cda0f96 --- /dev/null +++ b/template/typescript/nuxt/vuetify.config.ts @@ -0,0 +1,8 @@ +import { defineVuetifyConfiguration } from 'vuetify-nuxt-module/custom-configuration' + +export default defineVuetifyConfiguration({ + // your Vuetify options here + theme: { + defaultTheme: 'dark', + }, +})