From 4bee998d9bee576a5614523b134c112fee9bcf1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:02:49 +0000 Subject: [PATCH] Add schema generation and validation infrastructure Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com> --- src/commands/StatusListener.ts | 71 +++++- src/helpers/ContentTypeSchemaGenerator.ts | 294 ++++++++++++++++++++++ src/helpers/FrontMatterValidator.ts | 196 +++++++++++++++ src/helpers/index.ts | 2 + src/utils/index.ts | 1 + 5 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 src/helpers/ContentTypeSchemaGenerator.ts create mode 100644 src/helpers/FrontMatterValidator.ts diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index 9c1a9766..c6cea19b 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -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 diff --git a/src/helpers/ContentTypeSchemaGenerator.ts b/src/helpers/ContentTypeSchemaGenerator.ts new file mode 100644 index 00000000..ccab28de --- /dev/null +++ b/src/helpers/ContentTypeSchemaGenerator.ts @@ -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; + } +} diff --git a/src/helpers/FrontMatterValidator.ts b/src/helpers/FrontMatterValidator.ts new file mode 100644 index 00000000..08b05789 --- /dev/null +++ b/src/helpers/FrontMatterValidator.ts @@ -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; +} + +/** + * Validates front matter data against content type schemas + */ +export class FrontMatterValidator { + private ajv: Ajv; + private schemaCache: Map; + + 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`; + } + } +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 381cb1b0..5e2a4f62 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -38,3 +38,5 @@ export * from './processFmPlaceholders'; export * from './processI18nPlaceholders'; export * from './processPathPlaceholders'; export * from './processTimePlaceholders'; +export * from './ContentTypeSchemaGenerator'; +export * from './FrontMatterValidator'; diff --git a/src/utils/index.ts b/src/utils/index.ts index b6d73cfc..a2e342a5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -28,3 +28,4 @@ export * from './sleep'; export * from './sortPages'; export * from './unlinkAsync'; export * from './writeFileAsync'; +export * from '../helpers/ContentTypeSchemaGenerator';