Add schema generation and validation infrastructure

Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-03 15:02:49 +00:00
parent d59969cbe1
commit 4bee998d9b
5 changed files with 563 additions and 1 deletions

View File

@@ -7,7 +7,7 @@ import {
SETTING_SEO_TITLE_LENGTH
} from './../constants';
import * as vscode from 'vscode';
import { ArticleHelper, Notifications, SeoHelper, Settings } from '../helpers';
import { ArticleHelper, Notifications, SeoHelper, Settings, FrontMatterValidator } from '../helpers';
import { PanelProvider } from '../panelWebView/PanelProvider';
import { ContentType } from '../helpers/ContentType';
import { DataListener } from '../listeners/panel';
@@ -20,6 +20,7 @@ import { i18n } from './i18n';
import { getDescriptionField, getTitleField } from '../utils';
export class StatusListener {
private static validator: FrontMatterValidator = new FrontMatterValidator();
/**
* Update the text of the status bar
*
@@ -70,6 +71,9 @@ export class StatusListener {
// Check the required fields
if (editor) {
StatusListener.verifyRequiredFields(editor, article, collection);
// Schema validation
await StatusListener.verifySchemaValidation(editor, article, collection);
}
}
@@ -173,6 +177,71 @@ export class StatusListener {
}
}
/**
* Verify schema validation
* @param editor Text editor
* @param article Parsed front matter
* @param collection Diagnostic collection
*/
private static async verifySchemaValidation(
editor: vscode.TextEditor,
article: ParsedFrontMatter,
collection: vscode.DiagnosticCollection
) {
try {
const contentType = await ArticleHelper.getContentType(article);
if (!contentType || !contentType.fields || contentType.fields.length === 0) {
return;
}
// Validate against schema
const errors = StatusListener.validator.validate(article.data, contentType);
if (errors.length === 0) {
return;
}
const text = editor.document.getText();
const schemaDiagnostics: vscode.Diagnostic[] = [];
for (const error of errors) {
// Find the field in the document
const fieldPath = error.field.split('.');
const fieldName = fieldPath[fieldPath.length - 1];
// Try to find the field location in the document
const fieldIdx = text.indexOf(fieldName);
if (fieldIdx !== -1) {
const posStart = editor.document.positionAt(fieldIdx);
const posEnd = editor.document.positionAt(fieldIdx + fieldName.length);
const diagnostic: vscode.Diagnostic = {
code: '',
message: error.message,
range: new vscode.Range(posStart, posEnd),
severity: vscode.DiagnosticSeverity.Warning,
source: EXTENSION_NAME
};
schemaDiagnostics.push(diagnostic);
}
}
if (schemaDiagnostics.length > 0) {
if (collection.has(editor.document.uri)) {
const otherDiag = collection.get(editor.document.uri) || [];
collection.set(editor.document.uri, [...otherDiag, ...schemaDiagnostics]);
} else {
collection.set(editor.document.uri, [...schemaDiagnostics]);
}
}
} catch (error) {
// Silently fail validation errors to not disrupt the user experience
console.error('Schema validation error:', error);
}
}
/**
* Find the line of the field
* @param text

View File

@@ -0,0 +1,294 @@
import { ContentType, Field, FieldType } from '../models';
import { Settings } from '../helpers/SettingsHelper';
import { SETTING_TAXONOMY_FIELD_GROUPS } from '../constants';
/**
* JSON Schema type definition
*/
export interface JSONSchema {
$schema?: string;
type?: string | string[];
properties?: { [key: string]: JSONSchema };
required?: string[];
items?: JSONSchema;
enum?: any[];
format?: string;
anyOf?: JSONSchema[];
oneOf?: JSONSchema[];
allOf?: JSONSchema[];
description?: string;
default?: any;
minimum?: number;
maximum?: number;
}
/**
* Generates JSON Schema from Front Matter Content Type definitions
*/
export class ContentTypeSchemaGenerator {
/**
* Generate JSON Schema from a content type
* @param contentType The content type to generate schema from
* @returns JSON Schema object
*/
public static generateSchema(contentType: ContentType): JSONSchema {
const schema: JSONSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {},
required: []
};
if (!contentType.fields || contentType.fields.length === 0) {
return schema;
}
// Process each field in the content type
for (const field of contentType.fields) {
const fieldSchema = this.generateFieldSchema(field);
if (fieldSchema && schema.properties) {
schema.properties[field.name] = fieldSchema;
// Add to required array if field is required
if (field.required && schema.required) {
schema.required.push(field.name);
}
}
}
// Remove required array if empty
if (schema.required && schema.required.length === 0) {
delete schema.required;
}
return schema;
}
/**
* Generate JSON Schema for a single field
* @param field The field to generate schema from
* @returns JSON Schema object for the field
*/
private static generateFieldSchema(field: Field): JSONSchema | null {
// Skip divider and heading fields as they are UI-only
if (field.type === 'divider' || field.type === 'heading') {
return null;
}
const schema: JSONSchema = {};
// Add description if available
if (field.description) {
schema.description = field.description;
}
// Add default value if specified
if (field.default !== undefined && field.default !== null) {
schema.default = field.default;
}
// Map field type to JSON Schema type
switch (field.type) {
case 'string':
case 'slug':
case 'image':
case 'file':
case 'customField':
schema.type = 'string';
break;
case 'number':
schema.type = 'number';
if (field.numberOptions) {
if (field.numberOptions.min !== undefined) {
schema.minimum = field.numberOptions.min;
}
if (field.numberOptions.max !== undefined) {
schema.maximum = field.numberOptions.max;
}
}
break;
case 'boolean':
case 'draft':
schema.type = 'boolean';
break;
case 'datetime':
schema.type = 'string';
schema.format = 'date-time';
break;
case 'choice':
if (field.multiple) {
schema.type = 'array';
schema.items = {
type: 'string'
};
if (field.choices && field.choices.length > 0) {
schema.items.enum = this.extractChoiceValues(field.choices);
}
} else {
schema.type = 'string';
if (field.choices && field.choices.length > 0) {
schema.enum = this.extractChoiceValues(field.choices);
}
}
break;
case 'tags':
case 'categories':
case 'taxonomy':
case 'list':
schema.type = 'array';
schema.items = {
type: 'string'
};
break;
case 'fields':
schema.type = 'object';
schema.properties = {};
schema.required = [];
if (field.fields && field.fields.length > 0) {
for (const subField of field.fields) {
const subFieldSchema = this.generateFieldSchema(subField);
if (subFieldSchema && schema.properties) {
schema.properties[subField.name] = subFieldSchema;
if (subField.required && schema.required) {
schema.required.push(subField.name);
}
}
}
}
// Remove required array if empty
if (schema.required && schema.required.length === 0) {
delete schema.required;
}
break;
case 'block': {
// Block fields can contain different field groups
schema.type = 'array';
schema.items = {
type: 'object'
};
// Try to get the field group schemas
const blockSchemas = this.getBlockFieldGroupSchemas(field);
if (blockSchemas.length > 0) {
schema.items = {
oneOf: blockSchemas
};
}
break;
}
case 'json':
// JSON fields can be any valid JSON
schema.type = ['object', 'array', 'string', 'number', 'boolean', 'null'];
break;
case 'dataFile':
// Data file references are typically strings (IDs or keys)
schema.type = 'string';
break;
case 'contentRelationship':
// Content relationships can be a string (slug/path) or array of strings
if (field.multiple) {
schema.type = 'array';
schema.items = {
type: 'string'
};
} else {
schema.type = 'string';
}
break;
case 'fieldCollection':
// Field collections reference field groups, handle similarly to blocks
schema.type = 'array';
schema.items = {
type: 'object'
};
break;
default:
// Unknown field type, default to string
schema.type = 'string';
break;
}
return schema;
}
/**
* Extract choice values from field choices
* @param choices Array of choice strings or objects
* @returns Array of choice values
*/
private static extractChoiceValues(choices: (string | { id?: string | null; title: string })[]): string[] {
return choices.map((choice) => {
if (typeof choice === 'string') {
return choice;
} else {
return choice.id || choice.title;
}
});
}
/**
* Get schemas for block field groups
* @param field The block field
* @returns Array of JSON Schemas for each field group
*/
private static getBlockFieldGroupSchemas(field: Field): JSONSchema[] {
const schemas: JSONSchema[] = [];
if (!field.fieldGroup) {
return schemas;
}
const fieldGroupIds = Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
const fieldGroups = Settings.get(SETTING_TAXONOMY_FIELD_GROUPS) as any[];
if (!fieldGroups || fieldGroups.length === 0) {
return schemas;
}
for (const groupId of fieldGroupIds) {
const fieldGroup = fieldGroups.find((fg) => fg.id === groupId);
if (fieldGroup && fieldGroup.fields) {
const groupSchema: JSONSchema = {
type: 'object',
properties: {},
required: []
};
for (const groupField of fieldGroup.fields) {
const fieldSchema = this.generateFieldSchema(groupField);
if (fieldSchema && groupSchema.properties) {
groupSchema.properties[groupField.name] = fieldSchema;
if (groupField.required && groupSchema.required) {
groupSchema.required.push(groupField.name);
}
}
}
// Remove required array if empty
if (groupSchema.required && groupSchema.required.length === 0) {
delete groupSchema.required;
}
schemas.push(groupSchema);
}
}
return schemas;
}
}

View File

@@ -0,0 +1,196 @@
import Ajv, { ErrorObject } from 'ajv';
import { ContentType } from '../models';
import { ContentTypeSchemaGenerator, JSONSchema } from './ContentTypeSchemaGenerator';
/**
* Validation error with location information
*/
export interface ValidationError {
field: string;
message: string;
keyword?: string;
params?: Record<string, any>;
}
/**
* Validates front matter data against content type schemas
*/
export class FrontMatterValidator {
private ajv: Ajv;
private schemaCache: Map<string, JSONSchema>;
constructor() {
this.ajv = new Ajv({
allErrors: true,
verbose: true,
strict: false,
allowUnionTypes: true
});
this.schemaCache = new Map();
}
/**
* Validate front matter data against a content type
* @param data The front matter data to validate
* @param contentType The content type to validate against
* @returns Array of validation errors (empty if valid)
*/
public validate(data: any, contentType: ContentType): ValidationError[] {
if (!contentType || !contentType.fields || contentType.fields.length === 0) {
return [];
}
// Get or generate schema
const schema = this.getSchema(contentType);
if (!schema) {
return [];
}
// Compile and validate
const validate = this.ajv.compile(schema);
const valid = validate(data);
if (valid) {
return [];
}
// Convert AJV errors to our format
return this.convertAjvErrors(validate.errors || []);
}
/**
* Get or generate schema for a content type
* @param contentType The content type
* @returns JSON Schema
*/
private getSchema(contentType: ContentType): JSONSchema | null {
// Check cache first
const cacheKey = contentType.name;
if (this.schemaCache.has(cacheKey)) {
return this.schemaCache.get(cacheKey) || null;
}
// Generate new schema
const schema = ContentTypeSchemaGenerator.generateSchema(contentType);
this.schemaCache.set(cacheKey, schema);
return schema;
}
/**
* Clear the schema cache
*/
public clearCache(): void {
this.schemaCache.clear();
}
/**
* Convert AJV errors to validation errors
* @param ajvErrors AJV error objects
* @returns Array of validation errors
*/
private convertAjvErrors(ajvErrors: ErrorObject[]): ValidationError[] {
const errors: ValidationError[] = [];
for (const error of ajvErrors) {
const field = this.extractFieldName(error.instancePath);
const message = this.formatErrorMessage(error, field);
errors.push({
field,
message,
keyword: error.keyword,
params: error.params
});
}
return errors;
}
/**
* Extract field name from instance path
* @param instancePath The JSON pointer path
* @returns Field name
*/
private extractFieldName(instancePath: string): string {
if (!instancePath || instancePath === '') {
return 'root';
}
// Remove leading slash and convert to dot notation
return instancePath
.replace(/^\//, '')
.replace(/\//g, '.')
.replace(/~1/g, '/')
.replace(/~0/g, '~');
}
/**
* Format error message for display
* @param error AJV error object
* @param field Field name
* @returns Formatted error message
*/
private formatErrorMessage(error: ErrorObject, field: string): string {
const displayField = field === 'root' ? 'The document' : `Field '${field}'`;
switch (error.keyword) {
case 'required': {
const missingProperty = error.params?.missingProperty;
return `Missing required field '${missingProperty}'`;
}
case 'type': {
const expectedType = error.params?.type;
return `${displayField} must be of type ${expectedType}`;
}
case 'enum': {
const allowedValues = error.params?.allowedValues;
if (allowedValues && Array.isArray(allowedValues)) {
return `${displayField} must be one of: ${allowedValues.join(', ')}`;
}
return `${displayField} has an invalid value`;
}
case 'format': {
const format = error.params?.format;
return `${displayField} must be in ${format} format`;
}
case 'minimum': {
const minimum = error.params?.limit;
return `${displayField} must be greater than or equal to ${minimum}`;
}
case 'maximum': {
const maximum = error.params?.limit;
return `${displayField} must be less than or equal to ${maximum}`;
}
case 'minItems': {
const minItems = error.params?.limit;
return `${displayField} must have at least ${minItems} items`;
}
case 'maxItems': {
const maxItems = error.params?.limit;
return `${displayField} must have at most ${maxItems} items`;
}
case 'additionalProperties': {
const additionalProperty = error.params?.additionalProperty;
return `Unexpected field '${additionalProperty}' is not allowed`;
}
case 'oneOf':
return `${displayField} must match exactly one of the allowed schemas`;
case 'anyOf':
return `${displayField} must match at least one of the allowed schemas`;
default:
return error.message || `${displayField} is invalid`;
}
}
}

View File

@@ -38,3 +38,5 @@ export * from './processFmPlaceholders';
export * from './processI18nPlaceholders';
export * from './processPathPlaceholders';
export * from './processTimePlaceholders';
export * from './ContentTypeSchemaGenerator';
export * from './FrontMatterValidator';

View File

@@ -28,3 +28,4 @@ export * from './sleep';
export * from './sortPages';
export * from './unlinkAsync';
export * from './writeFileAsync';
export * from '../helpers/ContentTypeSchemaGenerator';