Merge branch 'copilot/enhance-schema-validation-yaml' into beta

This commit is contained in:
Elio Struyf
2026-03-14 11:13:50 +01:00
9 changed files with 863 additions and 2 deletions
+1
View File
@@ -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
+1
View File
@@ -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",
+6
View File
@@ -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%"
+1
View File
@@ -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.",
+264 -2
View File
@@ -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<boolean>(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<string | number> {
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
+2
View File
@@ -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
*/
+370
View File
@@ -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<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 = 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<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': {
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<CustomTaxonomy[]>(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<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 { 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;
}
}
+216
View File
@@ -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<string, any>;
}
/**
* 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<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 async validate(data: any, contentType: ContentType): Promise<ValidationError[]> {
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<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 = 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`;
}
}
}
+2
View File
@@ -38,3 +38,5 @@ export * from './processFmPlaceholders';
export * from './processI18nPlaceholders';
export * from './processPathPlaceholders';
export * from './processTimePlaceholders';
export * from './ContentTypeSchemaGenerator';
export * from './FrontMatterValidator';