208 lines
5.1 KiB
TypeScript
208 lines
5.1 KiB
TypeScript
import { parse } from "@babel/parser";
|
|
import * as t from "@babel/types";
|
|
|
|
import {
|
|
compileTemplateSchema,
|
|
type CompiledTemplateSchema,
|
|
} from "@/lib/compile-template-schema";
|
|
|
|
export type LayoutCodeValidationOptions = {
|
|
requireDynamicSlideLayout?: boolean;
|
|
requireMetadata?: boolean;
|
|
};
|
|
|
|
export type ValidatedLayoutCode = CompiledTemplateSchema & {
|
|
layout_code: string;
|
|
};
|
|
|
|
export class LayoutCodeValidationError extends Error {
|
|
line?: number;
|
|
column?: number;
|
|
|
|
constructor(message: string, location?: { line?: number; column?: number }) {
|
|
super(message);
|
|
this.name = "LayoutCodeValidationError";
|
|
this.line = location?.line;
|
|
this.column = location?.column;
|
|
}
|
|
}
|
|
|
|
const REQUIRED_METADATA = [
|
|
"layoutId",
|
|
"layoutName",
|
|
"layoutDescription",
|
|
] as const;
|
|
|
|
function stripCodeFences(value: string): string {
|
|
return value
|
|
.replace(/^```(?:tsx|typescript|ts)?\s*/i, "")
|
|
.replace(/\s*```$/i, "")
|
|
.trim();
|
|
}
|
|
|
|
function unwrapExpression(node: t.Expression): t.Expression {
|
|
if (
|
|
t.isParenthesizedExpression(node) ||
|
|
t.isTSAsExpression(node) ||
|
|
t.isTSTypeAssertion(node) ||
|
|
t.isTSNonNullExpression(node)
|
|
) {
|
|
return unwrapExpression(node.expression as t.Expression);
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
function getStaticStringValue(node: t.Expression | null | undefined): string | null {
|
|
if (!node) {
|
|
return null;
|
|
}
|
|
|
|
const expression = unwrapExpression(node);
|
|
|
|
if (t.isStringLiteral(expression)) {
|
|
return expression.value;
|
|
}
|
|
|
|
if (t.isTemplateLiteral(expression) && expression.expressions.length === 0) {
|
|
return expression.quasis
|
|
.map((quasi) => quasi.value.cooked ?? quasi.value.raw ?? "")
|
|
.join("");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseLayoutCode(layoutCode: string) {
|
|
try {
|
|
return parse(layoutCode, {
|
|
plugins: ["jsx", "typescript"],
|
|
sourceType: "module",
|
|
});
|
|
} catch (error) {
|
|
const loc = (error as { loc?: { line?: number; column?: number } }).loc;
|
|
const message =
|
|
error instanceof Error ? error.message : "Layout code contains invalid TSX";
|
|
throw new LayoutCodeValidationError(message, {
|
|
line: loc?.line,
|
|
column: loc?.column === undefined ? undefined : loc.column + 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
function addDeclarationName(
|
|
declarations: Set<string>,
|
|
staticStrings: Map<string, string | null>,
|
|
declaration: t.Node | null | undefined
|
|
): void {
|
|
if (!declaration) {
|
|
return;
|
|
}
|
|
|
|
if (t.isVariableDeclaration(declaration)) {
|
|
for (const declarator of declaration.declarations) {
|
|
if (!t.isIdentifier(declarator.id)) {
|
|
continue;
|
|
}
|
|
declarations.add(declarator.id.name);
|
|
staticStrings.set(
|
|
declarator.id.name,
|
|
t.isExpression(declarator.init)
|
|
? getStaticStringValue(declarator.init)
|
|
: null
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(t.isFunctionDeclaration(declaration) || t.isClassDeclaration(declaration)) &&
|
|
declaration.id
|
|
) {
|
|
declarations.add(declaration.id.name);
|
|
}
|
|
}
|
|
|
|
function getTopLevelContract(ast: ReturnType<typeof parse>) {
|
|
const declarations = new Set<string>();
|
|
const staticStrings = new Map<string, string | null>();
|
|
|
|
for (const statement of ast.program.body) {
|
|
if (t.isExportNamedDeclaration(statement)) {
|
|
addDeclarationName(declarations, staticStrings, statement.declaration);
|
|
continue;
|
|
}
|
|
|
|
if (t.isExportDefaultDeclaration(statement)) {
|
|
const declaration = statement.declaration;
|
|
if (
|
|
(t.isFunctionDeclaration(declaration) ||
|
|
t.isClassDeclaration(declaration)) &&
|
|
declaration.id
|
|
) {
|
|
declarations.add(declaration.id.name);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
addDeclarationName(declarations, staticStrings, statement);
|
|
}
|
|
|
|
return { declarations, staticStrings };
|
|
}
|
|
|
|
export function validateLayoutCode(
|
|
layoutCode: string,
|
|
options: LayoutCodeValidationOptions = {}
|
|
): ValidatedLayoutCode {
|
|
const {
|
|
requireDynamicSlideLayout = true,
|
|
requireMetadata = true,
|
|
} = options;
|
|
const normalizedCode = stripCodeFences(layoutCode);
|
|
|
|
if (!normalizedCode) {
|
|
throw new LayoutCodeValidationError("Layout code is required");
|
|
}
|
|
|
|
const ast = parseLayoutCode(normalizedCode);
|
|
const { declarations, staticStrings } = getTopLevelContract(ast);
|
|
|
|
if (!declarations.has("Schema")) {
|
|
throw new LayoutCodeValidationError("Layout code must declare Schema");
|
|
}
|
|
|
|
if (requireDynamicSlideLayout && !declarations.has("dynamicSlideLayout")) {
|
|
throw new LayoutCodeValidationError(
|
|
"Layout code must declare dynamicSlideLayout"
|
|
);
|
|
}
|
|
|
|
if (requireMetadata) {
|
|
for (const name of REQUIRED_METADATA) {
|
|
if (!declarations.has(name)) {
|
|
throw new LayoutCodeValidationError(`Layout code must declare ${name}`);
|
|
}
|
|
|
|
const value = staticStrings.get(name);
|
|
if (!value?.trim()) {
|
|
throw new LayoutCodeValidationError(
|
|
`Layout metadata ${name} must be a non-empty string literal`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const compiled = compileTemplateSchema(normalizedCode);
|
|
if (!compiled) {
|
|
throw new LayoutCodeValidationError(
|
|
"Layout code must contain a valid zod Schema"
|
|
);
|
|
}
|
|
|
|
return {
|
|
...compiled,
|
|
layout_code: normalizedCode,
|
|
};
|
|
}
|