Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(robo/cli): Config Schema Definition and Type Generation for Plugins #314

Open
Pkmmte opened this issue Oct 13, 2024 · 0 comments
Open
Labels
enhancement New feature or request hacktoberfest

Comments

@Pkmmte
Copy link
Member

Pkmmte commented Oct 13, 2024

Description

This proposal introduces a method for plugin developers to define their configuration options using a config.ts (or config.js) file within the /src/robo directory. By exporting an object that describes the expected configuration options, we can automatically generate TypeScript interfaces for these configurations. This approach ensures that pure JavaScript plugins remain compatible with TypeScript projects, providing type safety and improved tooling support.

Goals

  • Standardize Configuration Definition: Allow plugin developers to define their configuration schemas in a consistent and structured manner.
  • Automatic Type Generation: Generate TypeScript interfaces (.d.ts files) from the configuration schemas to provide type safety.
  • Enhance Developer Experience: Improve code completion, validation, and documentation for users of plugins.
  • Maintain JavaScript Compatibility: Ensure that plugins written in pure JavaScript remain fully compatible with TypeScript projects.
  • Optional Zod Integration: Allow the use of Zod as an optional dependency for schema validation for those who prefer it.

Implementation

1. Defining the Configuration Schema

Plugin developers can create a config.ts or config.js file in the /src/robo directory. For example:

// /src/robo/config.ts

import { Config } from 'robo.js'

export default Config.define((c) => ({
	databaseUrl: c.string().description('The URL of the database'),
	port: c.number().default(3000).description('Port number to run the server on'),
	features: c.object({
		enableLogging: c.boolean().default(false).description('Enable logging feature'),
		enableCache: c.boolean().default(true).description('Enable caching feature')
	})
}))

Explanation

  • Config.define: A function that accepts a callback where c is a schema builder object containing type methods.
  • Type Methods: The c object provides methods like string(), number(), boolean(), and object().
  • Field Definitions: Each field is defined using the type methods, with optional methods for defaults and descriptions.
  • Avoiding Prototype Pollution: By moving type methods into the c object, we prevent pollution of the Config prototype.

The .define Result

The Config.define function processes the schema defined in the callback and returns a structured schema object. This object can be used for:

  • Runtime Validation: Optionally validate user configurations at runtime.
  • Type Generation: Generate TypeScript definitions for type safety.
  • CLI Prompts: Provide default values and descriptions for interactive prompts.

An example of the schema object structure:

{
  databaseUrl: { type: 'string', description: 'The URL of the database', required: true },
  port: { type: 'number', default: 3000, description: 'Port number to run the server on' },
  features: {
    type: 'object',
    properties: {
      enableLogging: { type: 'boolean', default: false, description: 'Enable logging feature' },
      enableCache: { type: 'boolean', default: true, description: 'Enable caching feature' },
    },
  },
}

2. Updating the Build Process

The robo build plugin command needs to be enhanced to handle the new configuration schema:

  • Check for Configuration File: After compiling the plugin, check if .robo/build/robo/config.js exists.
  • Import the Configuration Schema: Import the exported schema object from the configuration file.
  • Generate Type Definitions: Use the schema object to construct a TypeScript definition file config.d.ts.

Generating config.d.ts

An example of the generated config.d.ts:

export interface Config {
	databaseUrl: string
	port?: number
	features?: {
		enableLogging?: boolean
		enableCache?: boolean
	}
}

Detailed generateTypeDefinition Function

Here's a detailed implementation of the generateTypeDefinition function, including how the schema is processed:

import fs from 'node:fs'
import path from 'node:path'

function generateTypeDefinition(schema, indent = 0) {
	const indentation = '  '.repeat(indent)
	let typeDef = ''

	if (schema.type === 'object' && schema.properties) {
		typeDef += '{\n'
		for (const [key, value] of Object.entries(schema.properties)) {
			const optional = value.required === false || value.default !== undefined ? '?' : ''
			typeDef += `${indentation}  ${key}${optional}: ${generateTypeDefinition(value, indent + 1)};\n`
		}
		typeDef += `${indentation}}`
	} else {
		typeDef += schemaToTypeString(schema)
	}

	return typeDef
}

function schemaToTypeString(schema) {
	switch (schema.type) {
		case 'string':
			return 'string'
		case 'number':
			return 'number'
		case 'boolean':
			return 'boolean'
		case 'array':
			return `${schemaToTypeString(schema.items)}[]`
		default:
			return 'any'
	}
}

// Load the compiled config.js
const configPath = path.join('.robo', 'build', 'robo', 'config.js')
const configModule = await import(configPath)
const schema = configModule.default

// Generate the TypeScript definition
const typeDefinition = `export interface Config ${generateTypeDefinition(schema)}\n`

// Write to config.d.ts
const configDtsPath = path.join('.robo', 'build', 'config.d.ts')
fs.writeFileSync(configDtsPath, typeDefinition)

Be aware that the above is just a simplified example. The actual implementation may require additional error handling, validation, and type definitions.

3. Enhancing TypeScript Support for Users

Users can now get type safety when configuring the plugin:

// @ts-check

/**
 * @type {import('robojs-plugin-example/.robo/config.d.ts').Config}
 **/
export default {
	databaseUrl: 'mongodb://localhost:27017',
	port: 8080,
	features: {
		enableLogging: true
	}
}

4. Appending to index.d.ts

For better usability, the build process should also:

  • Check for index.d.ts: If .robo/build/index.d.ts exists, append the following line:
export type { Config } from '../config'

Sample Implementation

const indexDtsPath = path.join('.robo', 'build', 'index.d.ts')
const exportStatement = `\nexport type { Config } from '../config';\n`

if (fs.existsSync(indexDtsPath)) {
	fs.appendFileSync(indexDtsPath, exportStatement)
} else {
	// If index.d.ts doesn't exist, create it
	fs.writeFileSync(indexDtsPath, exportStatement.trim())
}
  • Resulting Usage: Users can now import the Config type directly from the plugin:
// @ts-check

/**
 * @type {import('robojs-plugin-example').Config}
 **/
export default {
	databaseUrl: 'mongodb://localhost:27017',
	port: 8080,
	features: {
		enableLogging: true
	}
}

5. Optional Zod Integration

For developers familiar with Zod, we can allow its usage:

// /src/robo/config.ts

import { z } from 'zod'
import { Config } from 'robo.js'

const schema = z.object({
	databaseUrl: z.string(),
	port: z.number().optional().default(3000),
	features: z.object({
		enableLogging: z.boolean().optional().default(false),
		enableCache: z.boolean().optional().default(true)
	})
})

export default Config.define(schema)

6. Fluent API Design

Ensure that the Config API is fluent and intuitive:

// /src/robo/config.ts

import { Config } from 'robo.js'

export default Config.define((c) => ({
	apiKey: c.string().required().description('API key for authentication').prompt('Please enter your API key'),
	retries: c.number().default(3).description('Number of retry attempts')
}))

7. Detailed Schema and Type Generation

Schema Builder (c Object)

The c object provides methods to define each configuration field:

  • c.string(): Defines a string type.
  • c.number(): Defines a number type.
  • c.boolean(): Defines a boolean type.
  • c.object(properties): Defines an object with specified properties.
  • Common Methods:
    • .required(): Marks the field as required.
    • .default(value): Sets a default value.
    • .description(text): Adds a description.
    • .prompt(text): Sets a prompt message for CLI interactions.

Example of the Schema Object

{
  apiKey: {
    type: 'string',
    required: true,
    description: 'API key for authentication',
    prompt: 'Please enter your API key',
  },
  retries: {
    type: 'number',
    default: 3,
    description: 'Number of retry attempts',
  },
}

Improved generateTypeDefinition Function

The function now handles optional fields and nested objects accurately:

function generateTypeDefinition(schema, indent = 0) {
	const indentation = '  '.repeat(indent)
	let typeDef = ''

	if (schema.type === 'object' && schema.properties) {
		typeDef += '{\n'
		for (const [key, value] of Object.entries(schema.properties)) {
			const optional = !value.required && value.default === undefined ? '?' : ''
			typeDef += `${indentation}  ${key}${optional}: ${generateTypeDefinition(value, indent + 1)};\n`
		}
		typeDef += `${indentation}}`
	} else if (schema.type === 'array' && schema.items) {
		typeDef += `${generateTypeDefinition(schema.items, indent)}[]`
	} else {
		typeDef += schemaToTypeString(schema)
	}

	return typeDef
}

8. File System Operations

Include file system operations to read and write necessary files:

import fs 'node:fs'
import path from 'node:path'

// Paths
const buildDir = path.join(process.cwd(), '.robo', 'build')
const configJsPath = path.join(buildDir, 'robo', 'config.js')
const configDtsPath = path.join(buildDir, 'config.d.ts')
const indexDtsPath = path.join(buildDir, 'index.d.ts')

// Read the compiled config.js
if (fs.existsSync(configJsPath)) {
	const configModule = require(configJsPath)
	const schema = configModule.default

	// Generate TypeScript definition
	const typeDefinition = `export interface Config ${generateTypeDefinition(schema)}\n`

	// Write config.d.ts
	fs.writeFileSync(configDtsPath, typeDefinition)

	// Append to index.d.ts
	const exportStatement = `\nexport type { Config } from '../config';\n`
	if (fs.existsSync(indexDtsPath)) {
		fs.appendFileSync(indexDtsPath, exportStatement)
	} else {
		fs.writeFileSync(indexDtsPath, exportStatement.trim())
	}
}

9. Example Usage of the Config API

Plugin Developer's config.ts:

import { Config } from 'robo.js'

export default Config.define((c) => ({
	host: c.string().default('localhost').description('Database host'),
	port: c.number().default(5432).description('Database port'),
	credentials: c.object({
		username: c.string().required().description('Database username'),
		password: c.string().required().description('Database password')
	})
}))

Generated config.d.ts:

export interface Config {
	host?: string
	port?: number
	credentials: {
		username: string
		password: string
	}
}

User's Configuration File:

// @ts-check

/**
 * @type {import('robojs-plugin-database').Config}
 **/
export default {
	credentials: {
		username: 'admin',
		password: 'secret'
	}
}

Note: Remember, the above code examples are simplified for explanation purposes and to define the API design. The actual implementation may require additional error handling, validation, and type definitions.

@Pkmmte Pkmmte added enhancement New feature or request hacktoberfest labels Oct 13, 2024
@Pkmmte Pkmmte changed the title feat(robo/cli): standardized plugin options schema feat(robo/cli): Config Schema Definition and Type Generation for Plugins Oct 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request hacktoberfest
Projects
None yet
Development

No branches or pull requests

1 participant