inline-schema/src/csv-loader/loader.ts

244 lines
7.1 KiB
TypeScript
Raw Normal View History

2026-03-31 13:02:29 +08:00
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';
2026-03-31 13:02:29 +08:00
export interface CsvLoaderOptions {
delimiter?: string;
quote?: string;
escape?: string;
2026-03-31 14:45:02 +08:00
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;
2026-03-31 13:02:29 +08:00
}
export interface CsvParseResult {
/** Parsed CSV data as array of objects */
data: Record<string, unknown>[];
/** Generated TypeScript type definition string (if emitTypes is true) */
typeDefinition?: string;
/** Property configurations for the CSV columns */
propertyConfigs: PropertyConfig[];
}
2026-03-31 13:02:29 +08:00
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;
2026-03-31 13:02:29 +08:00
const records = parse(content, {
delimiter,
quote,
escape,
2026-03-31 14:45:02 +08:00
bom,
comment,
trim,
2026-03-31 13:02:29 +08:00
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<string, unknown> = {};
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<CsvLoaderOptions>,
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)};`;
2026-03-31 13:02:29 +08:00
}