diff --git a/CHANGELOG.md b/CHANGELOG.md index 065d5454..fe663cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#937](https://github.com/estruyf/vscode-front-matter/issues/937): Dashboard "Structure" view for documentation sites *WIP* - [#965](https://github.com/estruyf/vscode-front-matter/issues/965): Added SEO support for the keyword in the first paragraph - [#973](https://github.com/estruyf/vscode-front-matter/issues/973): Support for number fields in the snippets +- [#990](https://github.com/estruyf/vscode-front-matter/issues/990): Schema and validation for front matter in markdown files. It can be turned off by the `frontMatter.validation.enabled` setting. - [#1005](https://github.com/estruyf/vscode-front-matter/issues/1005): Support the integrated VSCode browser for the preview command ### 🐞 Fixes diff --git a/package-lock.json b/package-lock.json index 9eb4159c..ba6b4c6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18041,6 +18041,7 @@ "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", "dev": true, + "license": "MIT", "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", diff --git a/package.json b/package.json index 722f46f4..6bc35600 100644 --- a/package.json +++ b/package.json @@ -2104,6 +2104,12 @@ "markdownDescription": "%setting.frontMatter.templates.prefix.markdownDescription%", "scope": "Templates" }, + "frontMatter.validation.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%setting.frontMatter.validation.enabled.markdownDescription%", + "scope": "Validation" + }, "frontMatter.website.host": { "type": "string", "markdownDescription": "%setting.frontMatter.website.host.markdownDescription%" diff --git a/package.nls.json b/package.nls.json index 23d0e77f..30905db4 100644 --- a/package.nls.json +++ b/package.nls.json @@ -276,6 +276,7 @@ "setting.frontMatter.taxonomy.tags.markdownDescription": "Specifies the tags which can be used in the Front Matter. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.taxonomy.tags) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.taxonomy.tags%22%5D)", "setting.frontMatter.telemetry.disable.markdownDescription": "Specify if you want to disable the telemetry. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.telemetry.disable) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.telemetry.disable%22%5D)", "setting.frontMatter.templates.enabled.markdownDescription": "Specify if you want to use templates. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.templates.enabled) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.templates.enabled%22%5D)", + "setting.frontMatter.validation.enabled.markdownDescription": "Specify if you want to enable front matter validation. When enabled, the extension will validate your front matter against the content type schema. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.validation.enabled) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.validation.enabled%22%5D)", "setting.frontMatter.templates.folder.markdownDescription": "Specify the folder to use for your article templates. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.templates.folder) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.templates.folder%22%5D)", "setting.frontMatter.templates.prefix.markdownDescription": "Specify the prefix you want to add for your new article filenames. [Docs](https://frontmatter.codes/docs/settings/overview#frontmatter.templates.prefix) - [View in VS Code](command:simpleBrowser.show?%5B%22https://frontmatter.codes/docs/settings/overview%23frontmatter.templates.prefix%22%5D)", "setting.frontMatter.dashboard.mediaSnippet.deprecationMessage": "This setting is deprecated and will be removed in the next major version. Please define your media snippet in the `frontMatter.content.snippet` setting.", diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index 9c1a9766..65ffc1eb 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -4,22 +4,39 @@ import { EXTENSION_NAME, NOTIFICATION_TYPE, SETTING_SEO_DESCRIPTION_LENGTH, - SETTING_SEO_TITLE_LENGTH + SETTING_SEO_TITLE_LENGTH, + SETTING_VALIDATION_ENABLED } from './../constants'; import * as vscode from 'vscode'; -import { ArticleHelper, Notifications, SeoHelper, Settings } from '../helpers'; +import { + ArticleHelper, + Notifications, + SeoHelper, + Settings, + FrontMatterValidator, + ValidationError +} from '../helpers'; import { PanelProvider } from '../panelWebView/PanelProvider'; import { ContentType } from '../helpers/ContentType'; import { DataListener } from '../listeners/panel'; import { commands } from 'vscode'; import { Field } from '../models'; +import { FrontMatterParser } from '../parsers'; import { Preview } from './Preview'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../localization'; import { i18n } from './i18n'; import { getDescriptionField, getTitleField } from '../utils'; +import * as yaml from 'yaml'; export class StatusListener { + private static _validator: FrontMatterValidator | undefined; + private static get validator(): FrontMatterValidator { + if (!StatusListener._validator) { + StatusListener._validator = new FrontMatterValidator(); + } + return StatusListener._validator; + } /** * Update the text of the status bar * @@ -70,6 +87,12 @@ export class StatusListener { // Check the required fields if (editor) { StatusListener.verifyRequiredFields(editor, article, collection); + + // Schema validation + const validationEnabled = Settings.get(SETTING_VALIDATION_ENABLED, true); + if (validationEnabled) { + await StatusListener.verifySchemaValidation(editor, article, collection); + } } } @@ -173,6 +196,245 @@ 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 = await 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) { + const range = StatusListener.findSchemaErrorRange(editor.document, text, error); + + if (range) { + const diagnostic: vscode.Diagnostic = { + code: '', + message: error.message, + range, + 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 + // Logger can be used here if needed for debugging + } + } + + private static findSchemaErrorRange( + document: vscode.TextDocument, + text: string, + error: ValidationError + ): vscode.Range | undefined { + const language = FrontMatterParser.getLanguageFromContent(text); + + if (language === 'yaml') { + const yamlRange = StatusListener.findYamlSchemaErrorRange(document, text, error); + if (yamlRange) { + return yamlRange; + } + } + + return StatusListener.findTextSchemaErrorRange(document, text, error); + } + + private static findYamlSchemaErrorRange( + document: vscode.TextDocument, + text: string, + error: ValidationError + ): vscode.Range | undefined { + const frontMatter = StatusListener.getYamlFrontMatter(text); + if (!frontMatter) { + return undefined; + } + + const path = StatusListener.getValidationPath(error); + if (path.length === 0) { + return undefined; + } + + const doc = yaml.parseDocument(frontMatter.content); + const node = doc.getIn(path, true) as { range?: [number, number, number] } | null; + + if (!node?.range || node.range.length < 2) { + return undefined; + } + + const normalizedRange = StatusListener.normalizeYamlNodeRange(frontMatter.content, node.range); + if (!normalizedRange) { + return undefined; + } + + return new vscode.Range( + document.positionAt(frontMatter.startOffset + normalizedRange.start), + document.positionAt(frontMatter.startOffset + normalizedRange.end) + ); + } + + private static findTextSchemaErrorRange( + document: vscode.TextDocument, + text: string, + error: ValidationError + ): vscode.Range | undefined { + const path = StatusListener.getValidationPath(error); + const fieldName = path.length > 0 ? String(path[path.length - 1]) : ''; + const arrayIndex = + typeof path[path.length - 1] === 'number' ? (path[path.length - 1] as number) : undefined; + const searchFieldName = + arrayIndex !== undefined ? String(path[path.length - 2] || '') : fieldName; + + if (!searchFieldName || searchFieldName === 'root') { + return undefined; + } + + const frontMatterMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const frontMatterEnd = frontMatterMatch ? frontMatterMatch[0].length : text.length; + const searchText = text.substring(0, frontMatterEnd); + const fieldIdx = searchText.indexOf(`${searchFieldName}:`); + + if (fieldIdx === -1) { + return undefined; + } + + let posStart = document.positionAt(fieldIdx); + let posEnd = document.positionAt(fieldIdx + searchFieldName.length); + + if (arrayIndex !== undefined) { + const afterField = text.indexOf('\n', fieldIdx) + 1; + let remaining = arrayIndex; + let searchFrom = afterField; + while (searchFrom < frontMatterEnd) { + const lineEnd = text.indexOf('\n', searchFrom); + const line = text.substring(searchFrom, lineEnd === -1 ? frontMatterEnd : lineEnd); + if (/^\s*-\s/.test(line)) { + if (remaining === 0) { + const valueOffset = line.indexOf('- ') + 2; + const rawItemValue = line.substring(valueOffset).trim(); + const isQuoted = + rawItemValue.length > 1 && + ((rawItemValue.startsWith('"') && rawItemValue.endsWith('"')) || + (rawItemValue.startsWith("'") && rawItemValue.endsWith("'"))); + const itemValue = isQuoted ? rawItemValue.slice(1, -1) : rawItemValue; + const valueStartOffset = searchFrom + valueOffset + (isQuoted ? 1 : 0); + posStart = document.positionAt(valueStartOffset); + posEnd = document.positionAt(valueStartOffset + itemValue.length); + break; + } + remaining--; + } else if (line.trim() && !/^\s/.test(line)) { + break; + } + searchFrom = (lineEnd === -1 ? frontMatterEnd : lineEnd) + 1; + } + } + + return new vscode.Range(posStart, posEnd); + } + + private static getValidationPath(error: ValidationError): Array { + const path = + error.field && error.field !== 'root' + ? error.field + .split('.') + .filter(Boolean) + .map((segment) => (/^\d+$/.test(segment) ? parseInt(segment, 10) : segment)) + : []; + + if (error.keyword === 'required' && typeof error.params?.missingProperty === 'string') { + return [...path, error.params.missingProperty]; + } + + if ( + error.keyword === 'additionalProperties' && + typeof error.params?.additionalProperty === 'string' + ) { + return [...path, error.params.additionalProperty]; + } + + return path; + } + + private static getYamlFrontMatter( + text: string + ): { content: string; startOffset: number } | undefined { + const openMatch = text.match(/^---\r?\n/); + if (!openMatch) { + return undefined; + } + + const startOffset = openMatch[0].length; + const closeMatch = /\r?\n---/.exec(text.slice(startOffset)); + const endOffset = closeMatch ? startOffset + closeMatch.index : text.length; + + return { + content: text.slice(startOffset, endOffset), + startOffset + }; + } + + private static normalizeYamlNodeRange( + source: string, + range: [number, number, number] + ): { start: number; end: number } | undefined { + let start = range[0]; + let end = range[1]; + + if (start >= end) { + return undefined; + } + + let value = source.slice(start, end); + const leadingWhitespace = value.match(/^\s*/)?.[0].length || 0; + const trailingWhitespace = value.match(/\s*$/)?.[0].length || 0; + + start += leadingWhitespace; + end -= trailingWhitespace; + value = source.slice(start, end); + + if ( + value.length > 1 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + start += 1; + end -= 1; + } + + return start < end ? { start, end } : undefined; + } + /** * Find the line of the field * @param text diff --git a/src/constants/settings.ts b/src/constants/settings.ts index ec68a1d3..dbbb3e66 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -120,6 +120,8 @@ export const SETTING_COPILOT_FAMILY = 'copilot.family'; export const SETTING_LOGGING = 'logging'; +export const SETTING_VALIDATION_ENABLED = 'validation.enabled'; + /** * Project override support */ diff --git a/src/helpers/ContentTypeSchemaGenerator.ts b/src/helpers/ContentTypeSchemaGenerator.ts new file mode 100644 index 00000000..52bb717d --- /dev/null +++ b/src/helpers/ContentTypeSchemaGenerator.ts @@ -0,0 +1,370 @@ +import { ContentType, Field, FieldType, CustomTaxonomy } from '../models'; +import { Settings } from '../helpers/SettingsHelper'; +import { SETTING_TAXONOMY_FIELD_GROUPS, SETTING_TAXONOMY_CUSTOM } from '../constants'; +import { TaxonomyHelper } from './TaxonomyHelper'; +import { TaxonomyType } from '../models/TaxonomyType'; + +/** + * 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 + * + * This utility converts Front Matter content type definitions into JSON Schema format + * which can then be used for validation. It handles all field types supported by + * Front Matter CMS including nested fields, blocks, and field groups. + * + * Field Type Mappings: + * - string, slug, image, file, customField → string + * - number → number (with optional min/max) + * - boolean, draft → boolean + * - datetime → string with date-time format + * - choice → string with enum (or array if multiple) + * - tags, categories, taxonomy, list → array of strings + * - fields → nested object with properties + * - block → array of objects with oneOf for field groups + * - json → any valid JSON type + * - dataFile, contentRelationship → string or array + * + * Features: + * - Required field validation + * - Type validation + * - Enum/choice validation + * - Number range validation (min/max) + * - Nested object support + * - Block field support with multiple field group options + * + * Usage: + * ```typescript + * const schema = ContentTypeSchemaGenerator.generateSchema(contentType); + * // Use schema for validation with AJV or other JSON Schema validators + * ``` + */ +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 async generateSchema(contentType: ContentType): Promise { + 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 = await 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 async generateFieldSchema(field: Field): Promise { + // 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': { + schema.type = 'array'; + schema.items = { + type: 'string' + }; + + // Get available tags and add as enum for validation + const availableTags = await TaxonomyHelper.get(TaxonomyType.Tag); + if (availableTags && availableTags.length > 0) { + schema.items.enum = availableTags; + } + break; + } + + case 'categories': { + schema.type = 'array'; + schema.items = { + type: 'string' + }; + + // Get available categories and add as enum for validation + const availableCategories = await TaxonomyHelper.get(TaxonomyType.Category); + if (availableCategories && availableCategories.length > 0) { + schema.items.enum = availableCategories; + } + break; + } + + case 'taxonomy': { + schema.type = 'array'; + schema.items = { + type: 'string' + }; + + // Get custom taxonomy options if taxonomyId is specified + if (field.taxonomyId) { + const customTaxonomies = Settings.get(SETTING_TAXONOMY_CUSTOM); + if (customTaxonomies && customTaxonomies.length > 0) { + const taxonomy = customTaxonomies.find((t) => t.id === field.taxonomyId); + if (taxonomy && taxonomy.options && taxonomy.options.length > 0) { + schema.items.enum = taxonomy.options; + } + } + } + break; + } + + 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 = await 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 = await 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 async getBlockFieldGroupSchemas(field: Field): Promise { + 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 { id: string; fields: Field[] }[] | undefined; + + 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 = await 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..e2553ebb --- /dev/null +++ b/src/helpers/FrontMatterValidator.ts @@ -0,0 +1,216 @@ +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 + * + * This validator uses JSON Schema validation (via AJV) to ensure that front matter + * in markdown files conforms to the structure defined in content types. + * + * Features: + * - Automatic schema generation from content type definitions + * - Type validation (string, number, boolean, datetime, arrays, etc.) + * - Required field validation + * - Enum/choice validation + * - Number range validation (min/max) + * - Nested object validation + * + * Usage: + * ```typescript + * const validator = new FrontMatterValidator(); + * const errors = validator.validate(frontMatterData, contentType); + * if (errors.length > 0) { + * // Handle validation errors + * } + * ``` + */ +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 async validate(data: any, contentType: ContentType): Promise { + if (!contentType || !contentType.fields || contentType.fields.length === 0) { + return []; + } + + // Get or generate schema + const schema = await 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 async getSchema(contentType: ContentType): Promise { + // Check cache first + const cacheKey = contentType.name; + if (this.schemaCache.has(cacheKey)) { + return this.schemaCache.get(cacheKey) || null; + } + + // Generate new schema + const schema = await 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';