import type { LoaderContext } from '@rspack/core'; import { parse } from 'csv-parse/sync'; import { parseSchema, createValidator, parseValue } from '../index.js'; import type { Schema } from '../types.js'; import * as path from 'path'; export interface CsvLoaderOptions { delimiter?: string; quote?: string; escape?: string; bom?: boolean; comment?: string | false; trim?: boolean; /** Generate TypeScript declaration file (.d.ts) */ emitTypes?: boolean; /** Output directory for generated type files (relative to output path) */ typesOutputDir?: string; } interface PropertyConfig { name: string; schema: any; validator: (value: unknown) => boolean; parser: (valueString: string) => unknown; } /** * Convert a schema to TypeScript type string */ function schemaToTypeString(schema: Schema): string { switch (schema.type) { case 'string': return 'string'; case 'number': return 'number'; case 'boolean': return 'boolean'; case 'array': if (schema.element.type === 'tuple') { const tupleElements = schema.element.elements.map(schemaToTypeString); return `[${tupleElements.join(', ')}]`; } return `${schemaToTypeString(schema.element)}[]`; case 'tuple': const tupleElements = schema.elements.map(schemaToTypeString); return `[${tupleElements.join(', ')}]`; default: return 'unknown'; } } /** * Generate TypeScript interface for the CSV data */ function generateTypeDefinition( resourceName: string, propertyConfigs: PropertyConfig[], relativePath: string ): string { const interfaceName = path.basename(resourceName, path.extname(resourceName)) .replace(/[^a-zA-Z0-9_$]/g, '_') .replace(/^(\d)/, '_$1'); const properties = propertyConfigs .map((config) => ` ${config.name}: ${schemaToTypeString(config.schema)};`) .join('\n'); return `declare module "${relativePath}" { export interface ${interfaceName} { ${properties} } export type RowType = ${interfaceName}; const data: ${interfaceName}[]; export default data; } `; } export default function csvLoader( this: LoaderContext, content: string ): string | Buffer { const options = this.getOptions() as CsvLoaderOptions | undefined; const delimiter = options?.delimiter ?? ','; const quote = options?.quote ?? '"'; const escape = options?.escape ?? '\\'; const bom = options?.bom ?? true; const comment = options?.comment === false ? undefined : (options?.comment ?? '#'); const trim = options?.trim ?? true; const emitTypes = options?.emitTypes ?? true; const typesOutputDir = options?.typesOutputDir ?? ''; const records = parse(content, { delimiter, quote, escape, bom, comment, trim, relax_column_count: true, }); if (records.length < 2) { throw new Error('CSV must have at least 2 rows: headers and schemas'); } const headers = records[0]; const schemas = records[1]; if (headers.length !== schemas.length) { throw new Error( `Header count (${headers.length}) does not match schema count (${schemas.length})` ); } const propertyConfigs: PropertyConfig[] = headers.map((header: string, index: number) => { const schemaString = schemas[index]; const schema = parseSchema(schemaString); return { name: header, schema, validator: createValidator(schema), parser: (valueString: string) => parseValue(schema, valueString), }; }); const dataRows = records.slice(2); const objects = dataRows.map((row: string[], rowIndex: number) => { const obj: Record = {}; propertyConfigs.forEach((config, colIndex) => { const rawValue = row[colIndex] ?? ''; try { const parsed = config.parser(rawValue); if (!config.validator(parsed)) { throw new Error( `Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}` ); } obj[config.name] = parsed; } catch (error) { if (error instanceof Error) { throw new Error( `Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}` ); } throw error; } }); return obj; }); const json = JSON.stringify(objects, null, 2); // Emit type definition file if enabled if (emitTypes) { const context = this.context || ''; // Get relative path from context, normalize to forward slashes let relativePath = this.resourcePath.replace(context, ''); if (relativePath.startsWith('\\') || relativePath.startsWith('/')) { relativePath = relativePath.substring(1); } relativePath = relativePath.replace(/\\/g, '/'); // Replace .csv with .d.ts for the output filename const dtsFileName = relativePath.replace(/\.csv$/, '.d.ts'); const outputPath = typesOutputDir ? path.join(typesOutputDir, dtsFileName) : dtsFileName; const dtsContent = generateTypeDefinition(this.resourcePath, propertyConfigs, `./${relativePath}`); this.emitFile?.(outputPath, dtsContent); } return `export default ${json};`; }