diff --git a/packages/configs/eslint.config.ts b/packages/configs/eslint.config.ts index ba116b20..d5ea6cca 100644 --- a/packages/configs/eslint.config.ts +++ b/packages/configs/eslint.config.ts @@ -4,5 +4,4 @@ import { createEslintConfig } from './dist/eslint.config.tpl.js' export default createEslintConfig({ packageDir: import.meta.dirname, workspaceDir: path.join(import.meta.dirname, '..', '..'), - ignores: ['turbo/**'], }) diff --git a/packages/configs/package.json b/packages/configs/package.json index ce8f161c..fa070d71 100644 --- a/packages/configs/package.json +++ b/packages/configs/package.json @@ -136,7 +136,9 @@ "devDependencies": { "@types/node": "22.15.32", "@vue/test-utils": "2.4.6", + "handlebars": "4.7.8", "tsup": "8.5.0", + "type-fest": "4.41.0", "typescript": "5.8.3", "vite": "6.3.5", "vitest": "3.2.2" diff --git a/packages/configs/tsconfig.generators.json b/packages/configs/tsconfig.generators.json new file mode 100644 index 00000000..bdc3ce9b --- /dev/null +++ b/packages/configs/tsconfig.generators.json @@ -0,0 +1,13 @@ +{ + "extends": ["./tsconfig.node.tpl.json"], + "include": ["./turbo/generators/*.ts"], + "compilerOptions": { + "declaration": false, + "module": "commonjs", + "moduleResolution": "node10", + "verbatimModuleSyntax": false, + "resolveJsonModule": false, + "resolvePackageJsonExports": false, + "resolvePackageJsonImports": false + } +} diff --git a/packages/configs/tsconfig.json b/packages/configs/tsconfig.json index a920a725..3c085adb 100644 --- a/packages/configs/tsconfig.json +++ b/packages/configs/tsconfig.json @@ -6,6 +6,9 @@ }, { "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.generators.json" } ] } diff --git a/packages/configs/turbo/generators/config.ts b/packages/configs/turbo/generators/config.ts index b6de67ca..8e3f9983 100644 --- a/packages/configs/turbo/generators/config.ts +++ b/packages/configs/turbo/generators/config.ts @@ -1,163 +1,206 @@ -import type { PlopTypes } from '@turbo/gen' +/* eslint-disable unicorn/prefer-module */ import { execSync } from 'node:child_process' - -type MyAnswers = { - packageName: string - packageDescription: string - npmScope: '@repo' - packageType: 'app' | 'lib' - runtime: 'node' | 'bundler-web' | 'bundler-node' | 'neutral' - turbo: { - paths: { - cwd: string - root: string - workspace: string - } - } -} - -type MyOptions = { - data: { - root: MyAnswers - } - fn: (helperThis: unknown) => void - inverse: (helperThis: unknown) => void -} +import { readFileSync, writeFileSync } from 'node:fs' +import type { PlopTypes } from '@turbo/gen' +import type { HelperOptions } from 'handlebars' +import { format, resolveConfig } from 'prettier' +import { createPrompts } from './helpers' +import type { AnswersForPrompQuestions } from './helpers' export default function generator(plop: PlopTypes.NodePlopAPI): void { - // create a generator + const { prompts, answerSchema, setHelperOptionsSchema } = createPrompts([ + { + type: 'input', + name: 'packageName', + message: 'What is the name of the package?', + }, + { + type: 'input', + name: 'packageDescription', + message: 'What is the description of the package?', + }, + { + name: 'npmScope', + type: 'list', + message: 'What is the npm scope of the package?', + choices: [ + { + name: 'Private | @repo', + value: '@repo', + }, + // Insert npmScope marker + ], + }, + { + name: 'packageType', + type: 'list', + message: 'What is the type of the package?', + choices: [ + { + name: 'Application', + value: 'app', + }, + { + name: 'Library', + value: 'lib', + }, + ], + }, + { + type: 'list', + name: 'runtime', + message: 'What is the runtime target?', + default: 'web', + choices: [ + { + name: 'web', + value: 'bundler-web', + }, + { + name: 'node', + value: 'node', + }, + { + name: 'node (bundled)', + value: 'bundler-node', + }, + { + name: 'neutral', + value: 'neutral', + }, + ], + }, + ]) + plop.setGenerator('package', { description: 'Create a new package with a default config', - // gather information from the user - prompts: [ - { - type: 'input', - name: 'packageName', - message: 'What is the name of the package?', - }, - { - type: 'input', - name: 'packageDescription', - message: 'What is the description of the package?', - }, - { - name: 'npmScope', - type: 'list', - message: - 'What is the npm scope of the package?', - choices: [ - { - name: 'Private | @repo', - value: '@repo', - }, - ], - }, - { - name: 'packageType', - type: 'list', - message: 'What is the type of the package?', - choices: [ - { - name: 'Application', - value: 'app', - }, - { - name: 'Library', - value: 'lib', - }, - ], - }, - { - type: 'list', - name: 'runtime', - message: 'What is the runtime target?', - default: 'web', - choices: [ - { - name: 'web', - value: 'bundler-web', - }, - { - name: 'node', - value: 'node', - }, - { - name: 'node (bundled)', - value: 'bundler-node', + prompts: [...prompts], + actions: [ + function addHelpers(rawAnswers, config, plopfileApi) { + const answers = answerSchema.parse(rawAnswers) + if (plopfileApi == undefined) { + throw new Error('plopfileApi was null') + } + + plopfileApi.setHelper( + 'isPrivate', + function (this: unknown, options: HelperOptions) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (answers.npmScope === '@repo') { + options.fn(this) + } else { + options.inverse(this) + } }, - { - name: 'neutral', - value: 'neutral', + ) + + plopfileApi.setHelper( + 'shouldPublish', + function (this: unknown, options: HelperOptions) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (answers.npmScope === '@repo') { + options.inverse(this) + } else { + options.fn(this) + } }, - ], - }, - ], - // perform actions based on the prompts - actions: [ - function addHelpers(answers: MyAnswers, config, plopfileApi) { - plopfileApi.setHelper('isPrivate', function (options: MyOptions) { - return options.data.root.npmScope == '@repo' ? options.fn(this) : options.inverse(this) - }) - plopfileApi.setHelper('shouldPublish', function (options: MyOptions) { - return options.data.root.npmScope != '@repo' ? options.fn(this) : options.inverse(this) - }) + ) - plopfileApi.setHelper('runtimeIsWeb', function (options: MyOptions) { - return options.data.root.runtime === 'bundler-web' - ? options.fn(this) - : options.inverse(this) - }) + plopfileApi.setHelper( + 'runtimeIsWeb', + function (this: unknown, options: HelperOptions) { + if (answers.runtime === 'bundler-web') { + options.fn(this) + } else { + options.inverse(this) + } + }, + ) - plopfileApi.setHelper('runtimeIsNode', function (options: MyOptions) { - return options.data.root.runtime === 'node' ? options.fn(this) : options.inverse(this) - }) + plopfileApi.setHelper( + 'runtimeIsNode', + function (this: unknown, options: HelperOptions) { + if (answers.runtime === 'node') { + options.fn(this) + } else { + options.inverse(this) + } + }, + ) - plopfileApi.setHelper('runtimeIsNodeBundled', function (options: MyOptions) { - return options.data.root.runtime === 'bundler-node' - ? options.fn(this) - : options.inverse(this) - }) + plopfileApi.setHelper( + 'runtimeIsNodeBundled', + function (this: unknown, options: HelperOptions) { + if (answers.runtime === 'bundler-node') { + options.fn(this) + } else { + options.inverse(this) + } + }, + ) - plopfileApi.setHelper('runtimeIsNeutral', function (options: MyOptions) { - return options.data.root.runtime === 'neutral' ? options.fn(this) : options.inverse(this) - }) + plopfileApi.setHelper( + 'runtimeIsNeutral', + function (this: unknown, options: HelperOptions) { + if (answers.runtime === 'neutral') { + options.fn(this) + } else { + options.inverse(this) + } + }, + ) - plopfileApi.setHelper('platform', function (options: MyOptions) { - switch (options.data.root.runtime) { + plopfileApi.setHelper('platform', function () { + switch (answers.runtime) { case 'node': - case 'bundler-node': + case 'bundler-node': { return 'node' - case 'bundler-web': + } + case 'bundler-web': { return 'web' - case 'neutral': + } + case 'neutral': { return 'neutral' + } } }) - plopfileApi.setHelper('isLib', function (options: MyOptions) { - return options.data.root.packageType === 'lib' ? options.fn(this) : options.inverse(this) - }) + plopfileApi.setHelper( + 'isLib', + function (this: unknown, options: HelperOptions) { + if (answers.packageType === 'lib') { + options.fn(this) + } else { + options.inverse(this) + } + }, + ) - plopfileApi.setHelper('viteDevScript', function (options: MyOptions) { - return options.data.root.packageType === 'app' ? 'dev' : 'build --watch' + plopfileApi.setHelper('viteDevScript', function () { + return answers.packageType === 'app' ? 'dev' : 'build --watch' }) - function getPackageDir(options: MyOptions) { - const { packageName, npmScope } = options.data.root - let path = `packages` + function getPackageDirectory() { + const { packageName } = answers + const path = `packages` return `${path}/${packageName}` } - plopfileApi.setHelper('packageDir', getPackageDir) + plopfileApi.setHelper('packageDir', getPackageDirectory) - plopfileApi.setHelper('packageAbsolutePath', function (options: MyOptions) { - const packagePath = getPackageDir(options) - return `${options.data.root.turbo.paths.root}/${packagePath}` - }) + plopfileApi.setHelper( + 'packageAbsolutePath', + function (this: unknown, options: HelperOptions) { + const { turbo } = setHelperOptionsSchema.parse(options.data).root + + const packagePath = getPackageDirectory() + return `${turbo.paths.root}/${packagePath}` + }, + ) - plopfileApi.setHelper('voltaPath', function (options: MyOptions) { - const { npmScope } = options.data.root + plopfileApi.setHelper('voltaPath', function () { + const { npmScope } = answers switch (npmScope) { default: { return '../../package.json' @@ -183,25 +226,28 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { type: 'add', templateFile: 'resources/package/vite.config.ts.hbs', path: '{{packageAbsolutePath}}/vite.config.ts', - skip: ({ runtime }: MyAnswers) => { + skip: ({ runtime }: AnswersForPrompQuestions) => { if (runtime !== 'bundler-web') { return 'Skipping vite.config.ts' } + return }, }, { type: 'add', templateFile: 'resources/package/tsup.config.ts.hbs', path: '{{packageAbsolutePath}}/tsup.config.ts', - skip: ({ runtime }: MyAnswers) => { + skip: ({ runtime }: AnswersForPrompQuestions) => { if (!['neutral', 'bundler-node'].includes(runtime)) { return 'Skipping tsup.config.ts' } + return }, }, - function installDeps(answers: MyAnswers) { - const devPackages = [ + function installDeps(rawAnswers) { + const answers = answerSchema.parse(rawAnswers) + const developmentPackages = [ 'typescript', '@types/node', 'vitest', @@ -211,14 +257,14 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { const packages = [] if (answers.runtime === 'bundler-web') { - devPackages.push('vite', 'vite-plugin-dts', 'vue-tsc', 'sass') + developmentPackages.push('vite', 'vite-plugin-dts', 'vue-tsc', 'sass') packages.push('@vueuse/core') } else if (answers.runtime !== 'node') { - devPackages.push('tsup') + developmentPackages.push('tsup') } if (answers.packageType === 'app') { - devPackages.push( + developmentPackages.push( 'vite-plugin-vue-devtools', '@vitejs/plugin-vue', ) @@ -226,14 +272,20 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { console.log('Installing depencies...') - execSync(`pnpm -F ${answers.npmScope}/${answers.packageName} add ${devPackages.join(' ')} -D`, { - stdio: 'inherit', - }) + execSync( + `pnpm -F ${answers.npmScope}/${answers.packageName} add ${developmentPackages.join(' ')} -D`, + { + stdio: 'inherit', + }, + ) if (packages.length > 0) { - execSync(`pnpm -F ${answers.npmScope}/${answers.packageName} add ${packages.join(' ')}`, { - stdio: 'inherit', - }) + execSync( + `pnpm -F ${answers.npmScope}/${answers.packageName} add ${packages.join(' ')}`, + { + stdio: 'inherit', + }, + ) } execSync(`pnpm -F ${answers.npmScope}/${answers.packageName} i`, { stdio: 'inherit', @@ -241,13 +293,73 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { return 'Installed dependencies' }, - function lint(answers: MyAnswers) { + function lint(rawAnswers) { + const answers = answerSchema.parse(rawAnswers) console.log('Linting package') - execSync(`pnpm -F ${answers.npmScope}/${answers.packageName} lint:fix`, { - stdio: 'inherit', - }) + execSync( + `pnpm -F ${answers.npmScope}/${answers.packageName} lint:fix`, + { + stdio: 'inherit', + }, + ) return 'Linted package' }, ], }) + + const addScopePrompts = createPrompts([ + { + type: 'input', + name: 'scopeName', + message: 'What is the name of the scope?', + }, + { + type: 'input', + name: 'scopeValue', + message: 'What is the value of the scope?', + }, + ]) + + plop.setGenerator('addScope', { + description: + 'Add an npm scope to the list of available scopes for the next generated package.', + prompts: [...addScopePrompts.prompts], + actions: [ + async function modifyFile(rawAnswers) { + const answers = addScopePrompts.answerSchema.parse(rawAnswers) + + const value = answers.scopeValue.startsWith('@') + ? answers.scopeValue + : `@${answers.scopeValue}` + + const name = `${answers.scopeName} | ${value}` + + const configTs = readFileSync(__filename).toString() + + const modified = configTs.replace( + '// Insert npmScope marker', + JSON.stringify({ + name, + value, + }) + '\n// Insert npmScope marker', + ) + + const prettierConfig = await resolveConfig(__filename) + console.log(prettierConfig) + + if (prettierConfig == undefined) { + throw new Error('Unable to resolve prettier config') + } + + const formatted = await format(modified, { + ...prettierConfig, + parser: 'typescript', + }) + + writeFileSync(__filename, formatted) + + return `Updated ${__filename}` + }, + ], + }) } diff --git a/packages/configs/turbo/generators/helpers.ts b/packages/configs/turbo/generators/helpers.ts new file mode 100644 index 00000000..517ea90d --- /dev/null +++ b/packages/configs/turbo/generators/helpers.ts @@ -0,0 +1,124 @@ +import type { SimplifyDeep, UnknownRecord } from 'type-fest' +import { z } from 'zod/v4' +import type { ZodLiteral, ZodString } from 'zod/v4' + +export type Prompt = { + name: string + message: string + prefix?: string + suffix?: string +} + +export type InputPrompt = Prompt & { + type: 'input' +} + +export type ListPrompt = Prompt & { + type: 'list' + choices: readonly { name: string; value: string }[] +} + +export type AllowedPrompt = InputPrompt | ListPrompt + +export type AllowedPrompQuestions = readonly AllowedPrompt[] + +export type ValueForPrompt = + TPrompt extends InputPrompt + ? string + : TPrompt extends ListPrompt + ? TPrompt['choices'][number]['value'] + : never + +export type AnswersForPrompQuestions = + SimplifyDeep<{ + [Entry in TPrompts[number]['name']]: ValueForPrompt< + Extract + > + }> + +/** + * Zod + */ + +export type SchemaForPrompt = + TPrompt extends InputPrompt + ? ZodString + : TPrompt extends ListPrompt + ? ZodLiteral + : never + +export type AnswerSchemaForPrompQuestions< + TPrompts extends AllowedPrompQuestions, +> = SimplifyDeep<{ + [Entry in TPrompts[number]['name']]: SchemaForPrompt< + Extract + > +}> + +export function createPrompts( + prompts: TPrompt, +) { + const unfinishedAnswers: UnknownRecord = {} + + for (const singlePrompt of prompts) { + switch (singlePrompt.type) { + case 'input': { + unfinishedAnswers[singlePrompt.name] = z.string() + break + } + case 'list': { + unfinishedAnswers[singlePrompt.name] = z.literal( + singlePrompt.choices.map((choice) => choice.value), + ) + } + } + } + + const answers = unfinishedAnswers as AnswerSchemaForPrompQuestions + + const answerSchema = z.looseObject(answers) + + const turboOptionsSchema = z.object({ + turbo: z.object({ + paths: z.object({ + root: z.string(), + cwd: z.string(), + workspace: z.string(), + }), + configs: z.array( + z.object({ + turboConfigPath: z.string(), + workspacePath: z.string(), + isRootConfig: z.boolean(), + config: z.object({ + ui: z.string().optional(), + globalEnv: z.array(z.string()).optional(), + globalPassThroughEnv: z.array(z.string()).optional(), + tasks: z.record( + z.string(), + z + .object({ + dependsOn: z.array(z.string()).optional(), + outputs: z.array(z.string()).optional(), + persitent: z.boolean().optional(), + cache: z.boolean().optional(), + interruptible: z.boolean().optional(), + }) + .optional(), + ), + }), + }), + ), + }), + }) + + const setHelperOptionsSchema = z.looseObject({ + root: turboOptionsSchema.extend(answerSchema.shape), + }) + + return { + answerSchema, + setHelperOptionsSchema, + prompts, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de391b83..04fd968d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,9 +144,15 @@ importers: '@vue/test-utils': specifier: 2.4.6 version: 2.4.6 + handlebars: + specifier: 4.7.8 + version: 4.7.8 tsup: specifier: 8.5.0 version: 8.5.0(@microsoft/api-extractor@7.52.8(@types/node@22.15.32))(jiti@2.4.2)(postcss@8.5.4)(typescript@5.8.3)(yaml@2.8.0) + type-fest: + specifier: 4.41.0 + version: 4.41.0 typescript: specifier: 5.8.3 version: 5.8.3 diff --git a/turbo.json b/turbo.json index 9b9f48f7..ed8adc19 100644 --- a/turbo.json +++ b/turbo.json @@ -1,23 +1,45 @@ { "$schema": "https://turbo.build/schema.json", "ui": "stream", - "globalEnv": ["DEBUG", "NO_COLOR", "DO_NOT_TRACK"], - "globalPassThroughEnv": ["PLAYWRIGHT_BROWSERS_PATH"], + "globalEnv": [ + "DEBUG", + "NO_COLOR", + "DO_NOT_TRACK" + ], + "globalPassThroughEnv": [ + "PLAYWRIGHT_BROWSERS_PATH" + ], "tasks": { "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**" + ] }, "generate": { - "dependsOn": ["^build", "^generate"], - "outputs": ["generated/**"] + "dependsOn": [ + "^build", + "^generate" + ], + "outputs": [ + "generated/**" + ] }, "test": { - "dependsOn": ["^build"], - "outputs": ["playwright-report/**", "test-results/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + "playwright-report/**", + "test-results/**" + ] }, "dev": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "persistent": true, "cache": false, "interruptible": true @@ -33,32 +55,46 @@ "interruptible": true }, "preview": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "persistent": true, "cache": false }, "lint": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": true }, "lint:fix": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": true }, "typecheck:app": { - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "typecheck:lib": { - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "typecheck:vitest": { - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "docs:dev": { "cache": false }, "docs:build": { - "outputs": [".vitepress/dist/**"] + "outputs": [ + ".vitepress/dist/**" + ] } } }