import { parse } from 'csv-parse/sync'; import { parseSchema, createValidator, parseValue } from '../index.js'; import type { Schema, ReferenceSchema } from '../types.js'; import * as fs from 'fs'; 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; /** Write .d.ts files to disk (useful for dev server) */ writeToDisk?: boolean; /** Base directory for resolving referenced CSV files (default: directory of current file) */ refBaseDir?: string; /** Primary key field name for referenced tables (default: 'id') */ defaultPrimaryKey?: string; /** Current file path (used to resolve relative references) */ currentFilePath?: string; } 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[]; /** Referenced table names */ references: Set; } interface PropertyConfig { name: string; schema: any; validator: (value: unknown) => boolean; parser: (valueString: string) => unknown; /** Whether this property is a reference to another table */ isReference?: boolean; /** Referenced table name (if isReference is true) */ referenceTableName?: string; /** Whether it's an array reference */ referenceIsArray?: boolean; } /** Cache for loaded referenced tables */ const referenceTableCache = new Map[]>(); /** * Parse and resolve a reference value. * Loads the referenced table and replaces IDs with actual objects. */ function parseReferenceValue( schema: ReferenceSchema, valueString: string, refBaseDir: string | undefined, defaultPrimaryKey: string, currentFilePath: string | undefined ): unknown { // Determine the directory to search for referenced files const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd()); // Build the referenced file path const fileName = `${schema.tableName}.csv`; const refFilePath = path.isAbsolute(fileName) ? fileName : path.join(baseDir, fileName); // Load the referenced table (use cache if already loaded) let refTable: Record[]; if (referenceTableCache.has(refFilePath)) { refTable = referenceTableCache.get(refFilePath)!; } else { try { const refContent = fs.readFileSync(refFilePath, 'utf-8'); const refResult = parseCsv(refContent, { currentFilePath: refFilePath, emitTypes: false, }); refTable = refResult.data; referenceTableCache.set(refFilePath, refTable); } catch (error) { throw new Error( `Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}` ); } } // Build a lookup map by primary key const primaryKeyMap = new Map>(); refTable.forEach(row => { const pkValue = row[defaultPrimaryKey]; if (pkValue !== undefined) { primaryKeyMap.set(String(pkValue), row); } }); // Parse the value string to extract IDs const valueParser = new ReferenceValueParser(valueString.trim()); const ids = valueParser.parseIds(schema.isArray); // Resolve IDs to actual objects if (schema.isArray) { return ids.map(id => { const obj = primaryKeyMap.get(id); if (!obj) { throw new Error( `Reference to "${schema.tableName}" with ${defaultPrimaryKey}="${id}" not found` ); } return obj; }); } else { // Single reference (first ID if array provided) const id = ids[0]; const obj = primaryKeyMap.get(id); if (!obj) { throw new Error( `Reference to "${schema.tableName}" with ${defaultPrimaryKey}="${id}" not found` ); } return obj; } } /** * Parser for reference values (extracts IDs from value string) */ class ReferenceValueParser { private input: string; private pos: number = 0; constructor(input: string) { this.input = input; } private peek(): string { return this.input[this.pos] || ''; } private consume(): string { return this.input[this.pos++] || ''; } private skipWhitespace(): void { while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) { this.pos++; } } private consumeStr(str: string): boolean { if (this.input.slice(this.pos, this.pos + str.length) === str) { this.pos += str.length; return true; } return false; } parseIds(isArray: boolean): string[] { this.skipWhitespace(); if (isArray) { // Parse array format: [id1; id2; id3] if (this.peek() === '[') { this.consume(); } this.skipWhitespace(); if (this.peek() === ']') { this.consume(); return []; } const ids: string[] = []; while (true) { this.skipWhitespace(); let id = ''; while (this.pos < this.input.length && this.peek() !== ';' && this.peek() !== ']') { id += this.consume(); } const trimmedId = id.trim(); if (trimmedId) { ids.push(trimmedId); } this.skipWhitespace(); if (!this.consumeStr(';')) { break; } } this.skipWhitespace(); if (this.peek() === ']') { this.consume(); } return ids; } else { // Parse single ID let id = ''; while (this.pos < this.input.length) { const char = this.peek(); if (char === ';' || char === ']' || char === ',') { break; } id += this.consume(); } return [id.trim()]; } } } /** * Convert a schema to TypeScript type string */ function schemaToTypeString(schema: Schema, resourceNames?: Map): string { switch (schema.type) { case 'string': return 'string'; case 'number': case 'int': case 'float': return 'number'; case 'boolean': return 'boolean'; case 'stringLiteral': return `"${schema.value}"`; case 'union': return schema.members.map(m => schemaToTypeString(m, resourceNames)).join(' | '); case 'reference': { // Use the resource name mapping if provided, otherwise capitalize table name const typeName = resourceNames?.get(schema.tableName) || schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1); return schema.isArray ? `readonly ${typeName}[]` : typeName; } case 'array': if (schema.element.type === 'tuple') { const tupleElements = schema.element.elements.map((el) => { const typeStr = schemaToTypeString(el.schema, resourceNames); return el.name ? `readonly ${el.name}: ${typeStr}` : typeStr; }); return `readonly [${tupleElements.join(', ')}]`; } // Wrap union types in parentheses to maintain correct precedence const elementType = schemaToTypeString(schema.element, resourceNames); if (schema.element.type === 'union') { return `readonly (${elementType})[]`; } return `readonly ${elementType}[]`; case 'tuple': const tupleElements = schema.elements.map((el) => { const typeStr = schemaToTypeString(el.schema, resourceNames); return el.name ? `readonly ${el.name}: ${typeStr}` : typeStr; }); return `readonly [${tupleElements.join(', ')}]`; default: return 'unknown'; } } /** * Generate TypeScript interface for the CSV data */ function generateTypeDefinition( resourceName: string, propertyConfigs: PropertyConfig[], references: Set, currentFilePath?: string ): string { const typeName = resourceName ? `${resourceName}Table` : 'Table'; // Generate import statements for referenced tables const imports: string[] = []; const resourceNames = new Map(); references.forEach(tableName => { // Convert table name to type name by capitalizing const typeBase = tableName.charAt(0).toUpperCase() + tableName.slice(1); resourceNames.set(tableName, typeBase); // Generate import path based on current file path let importPath: string; if (currentFilePath) { // Both files are in the same directory, use relative path importPath = `./${tableName}.csv`; } else { // Fallback for unknown path importPath = `../${tableName}.csv`; } imports.push(`import type { ${typeBase} } from '${importPath}';`); }); const importSection = imports.length > 0 ? imports.join('\n') + '\n\n' : ''; const properties = propertyConfigs .map((config) => ` readonly ${config.name}: ${schemaToTypeString(config.schema, resourceNames)};`) .join('\n'); // Generate both the table type and export a singular type alias for references // e.g., for "parts" table, export both "partsTable" and "Parts" (as alias) let exportAlias = ''; if (resourceName) { // Capitalize resource name for the singular type const singularType = resourceName.charAt(0).toUpperCase() + resourceName.slice(1); // Remove trailing 's' if it looks like a plural (simple heuristic) // Actually, let's just use the table name capitalized - users can adjust if needed exportAlias = `\nexport type ${singularType} = ${typeName}[number];`; } return `${importSection}type ${typeName} = readonly { ${properties} }[]; ${exportAlias} declare const data: ${typeName}; 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 & { resourceName?: string } = {} ): 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 refBaseDir = options.refBaseDir; const defaultPrimaryKey = options.defaultPrimaryKey ?? 'id'; const records = parse(content, { delimiter, quote, escape, bom, comment, trim, skip_empty_lines: true, 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); const config: PropertyConfig = { name: header, schema, validator: createValidator(schema), parser: (valueString: string) => parseValue(schema, valueString), }; // Check if it's a reference type if (schema.type === 'reference') { config.isReference = true; config.referenceTableName = schema.tableName; config.referenceIsArray = schema.isArray; // Override parser for reference fields config.parser = (valueString: string) => { return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath); }; } return config; }); // Collect all referenced tables (including nested references in tuples/arrays) const references = new Set(); function collectReferences(schema: Schema): void { if (schema.type === 'reference') { references.add(schema.tableName); } else if (schema.type === 'tuple') { schema.elements.forEach(el => collectReferences(el.schema)); } else if (schema.type === 'array') { collectReferences(schema.element); } else if (schema.type === 'union') { schema.members.forEach(m => collectReferences(m)); } } propertyConfigs.forEach(config => { if (config.isReference && config.referenceTableName) { references.add(config.referenceTableName); } collectReferences(config.schema); }); 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); // Skip validation for reference fields (validation happens during reference resolution) if (!config.isReference && !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, references, }; if (emitTypes) { result.typeDefinition = generateTypeDefinition( options.resourceName || '', propertyConfigs, references, options.currentFilePath ); } 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 & { resourceName?: string } = {} ): { 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, }; }