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'; import * as fs from 'fs'; 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; /** Write .d.ts files to disk (useful for dev server) */ writeToDisk?: boolean; } export interface CsvParseResult { /** Parsed CSV data as array of objects */ data: Record[]; /** Generated TypeScript type definition string (if emitTypes is true) */ typeDefinition?: string; /** Property configurations for the CSV columns */ propertyConfigs: PropertyConfig[]; } 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((el) => { const typeStr = schemaToTypeString(el.schema); return el.name ? `${el.name}: ${typeStr}` : typeStr; }); return `[${tupleElements.join(', ')}]`; } return `${schemaToTypeString(schema.element)}[]`; case 'tuple': const tupleElements = schema.elements.map((el) => { const typeStr = schemaToTypeString(el.schema); return el.name ? `${el.name}: ${typeStr}` : typeStr; }); return `[${tupleElements.join(', ')}]`; default: return 'unknown'; } } /** * Generate TypeScript interface for the CSV data */ function generateTypeDefinition( resourceName: string, propertyConfigs: PropertyConfig[] ): string { const properties = propertyConfigs .map((config) => ` ${config.name}: ${schemaToTypeString(config.schema)};`) .join('\n'); return `type Table = { ${properties} }[]; declare const data: Table; export default data; `; } /** * Parse CSV content string into structured data with schema validation. * This is a standalone function that doesn't depend on webpack/rspack LoaderContext. * * @param content - CSV content string (must have at least headers + schema row + 1 data row) * @param options - Parsing options * @returns CsvParseResult containing parsed data and optional type definitions */ export function parseCsv( content: string, options: CsvLoaderOptions = {} ): CsvParseResult { 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 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 result: CsvParseResult = { data: objects, propertyConfigs, }; if (emitTypes) { result.typeDefinition = generateTypeDefinition('', propertyConfigs); } return result; } /** * Generate JavaScript module code from CSV content. * Returns a string that can be used as a module export. * * @param content - CSV content string * @param options - Parsing options * @returns JavaScript module code string */ export function csvToModule( content: string, options: CsvLoaderOptions = {} ): { js: string; dts?: string } { const result = parseCsv(content, options); const json = JSON.stringify(result.data, null, 2); const js = `export default ${json};`; return { js, dts: result.typeDefinition, }; } export default function csvLoader( this: LoaderContext, content: string ): string | Buffer { const options = this.getOptions() as CsvLoaderOptions | undefined; const emitTypes = options?.emitTypes ?? true; const typesOutputDir = options?.typesOutputDir ?? ''; const writeToDisk = options?.writeToDisk ?? false; const result = parseCsv(content, options); // Emit type definition file if enabled if (emitTypes && result.typeDefinition) { 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 .csv.d.ts for the output filename const dtsFileName = `${relativePath}.d.ts`; const outputPath = typesOutputDir ? path.join(typesOutputDir, dtsFileName) : dtsFileName; if (writeToDisk) { // Write directly to disk (useful for dev server) const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || '', dtsFileName); fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); fs.writeFileSync(absolutePath, result.typeDefinition); } else { // Emit to in-memory filesystem (for production build) this.emitFile?.(outputPath, result.typeDefinition); } } return `export default ${JSON.stringify(result.data, null, 2)};`; }