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

204 lines
5.7 KiB
TypeScript
Raw Normal View History

2026-03-31 13:02:29 +08:00
import { parse } from 'csv-parse/sync';
import { parseSchema, createValidator, parseValue } from '../index.js';
import type { Schema } from '../types.js';
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':
2026-04-04 17:08:29 +08:00
case 'int':
case 'float':
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 ? `readonly ${el.name}: ${typeStr}` : typeStr;
});
return `readonly [${tupleElements.join(', ')}]`;
}
return `readonly ${schemaToTypeString(schema.element)}[]`;
case 'tuple':
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema);
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[]
): string {
const typeName = resourceName ? `${resourceName}Table` : 'Table';
const properties = propertyConfigs
.map((config) => ` readonly ${config.name}: ${schemaToTypeString(config.schema)};`)
.join('\n');
return `type ${typeName} = readonly {
${properties}
}[];
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;
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-04-07 12:11:01 +08:00
skip_empty_lines: true,
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(options.resourceName || '', 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 & { 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,
};
}