feat(content): validate versioned guide blocks
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { formulaEngine, type FormulaError } from "@/lib/formula";
|
||||
|
||||
import type {
|
||||
DataSourceField,
|
||||
DataSourceRow,
|
||||
DataSourceSnapshot,
|
||||
} from "./types";
|
||||
|
||||
const fieldKeySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(
|
||||
/^[\p{L}_][\p{L}\p{M}\p{N}_]*$/u,
|
||||
"Field keys may contain letters, marks, numbers, and underscores.",
|
||||
);
|
||||
|
||||
export const dataSourceFieldSchema = z.strictObject({
|
||||
key: fieldKeySchema,
|
||||
label: z.string().trim().min(1).max(120),
|
||||
type: z.enum(["text", "number", "badge", "media"]),
|
||||
formula: z.string().trim().max(500).nullable().optional(),
|
||||
displayPrecision: z.number().int().min(0).max(20).optional(),
|
||||
});
|
||||
|
||||
export const dataSourceRowSchema = z.strictObject({
|
||||
id: z.string().uuid(),
|
||||
values: z.record(z.string(), z.string().nullable()),
|
||||
hidden: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const dataSourceSnapshotSchema = z.strictObject({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
version: z.number().int().positive(),
|
||||
fields: z.array(dataSourceFieldSchema).min(1).max(80),
|
||||
rows: z.array(dataSourceRowSchema).max(2_000),
|
||||
});
|
||||
|
||||
export interface EvaluatedDataSourceCell {
|
||||
raw: string | null;
|
||||
canonical: string | null;
|
||||
display: string | null;
|
||||
error?: FormulaError;
|
||||
}
|
||||
|
||||
export interface EvaluatedDataSourceRow {
|
||||
id: string;
|
||||
hidden: boolean;
|
||||
cells: Record<string, EvaluatedDataSourceCell>;
|
||||
}
|
||||
|
||||
export interface EvaluatedDataSource {
|
||||
source: DataSourceSnapshot;
|
||||
rows: EvaluatedDataSourceRow[];
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export type { DataSourceField, DataSourceRow, DataSourceSnapshot };
|
||||
|
||||
function assertUniqueFieldKeys(fields: readonly DataSourceField[]): void {
|
||||
const keys = new Set<string>();
|
||||
for (const field of fields) {
|
||||
if (keys.has(field.key)) {
|
||||
throw new Error(`Duplicate data-source field key: ${field.key}`);
|
||||
}
|
||||
keys.add(field.key);
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateDataSource(
|
||||
input: DataSourceSnapshot,
|
||||
): EvaluatedDataSource {
|
||||
const source = dataSourceSnapshotSchema.parse(input);
|
||||
assertUniqueFieldKeys(source.fields);
|
||||
|
||||
const rows = source.rows.map((row: DataSourceRow): EvaluatedDataSourceRow => {
|
||||
const numericFields = source.fields.filter(
|
||||
(field) => field.type === "number" || field.formula,
|
||||
);
|
||||
const evaluation = formulaEngine.evaluate(
|
||||
numericFields.map((field) => ({
|
||||
key: field.key,
|
||||
value: row.values[field.key],
|
||||
formula: field.formula,
|
||||
displayPrecision: field.displayPrecision,
|
||||
})),
|
||||
);
|
||||
|
||||
const cells = Object.fromEntries(
|
||||
source.fields.map((field) => {
|
||||
const raw = row.values[field.key] ?? null;
|
||||
if (field.type !== "number" && !field.formula) {
|
||||
return [
|
||||
field.key,
|
||||
{ raw, canonical: raw, display: raw } satisfies EvaluatedDataSourceCell,
|
||||
];
|
||||
}
|
||||
|
||||
const calculated = evaluation.fields[field.key];
|
||||
return [
|
||||
field.key,
|
||||
{
|
||||
raw,
|
||||
canonical: calculated?.canonical ?? null,
|
||||
display: calculated?.display ?? null,
|
||||
error: calculated?.error,
|
||||
} satisfies EvaluatedDataSourceCell,
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return { id: row.id, hidden: row.hidden ?? false, cells };
|
||||
});
|
||||
|
||||
return {
|
||||
source,
|
||||
rows,
|
||||
valid: rows.every((row) =>
|
||||
Object.values(row.cells).every((cell) => !cell.error),
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user