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 2/8] 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'; From c179364f2bdd07aecf3b383061e2604dc2dbbf57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:07:43 +0000 Subject: [PATCH 3/8] Address code review feedback and improve validation Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com> --- src/commands/StatusListener.ts | 11 ++++++++--- src/helpers/ContentTypeSchemaGenerator.ts | 2 +- src/utils/index.ts | 1 - 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index c6cea19b..b2327d66 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -203,14 +203,19 @@ export class StatusListener { const text = editor.document.getText(); const schemaDiagnostics: vscode.Diagnostic[] = []; + + // Find the front matter section (between --- markers) + const frontMatterMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const frontMatterEnd = frontMatterMatch ? frontMatterMatch[0].length : text.length; 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); + // Try to find the field location in the front matter section only + const searchText = text.substring(0, frontMatterEnd); + const fieldIdx = searchText.indexOf(fieldName); if (fieldIdx !== -1) { const posStart = editor.document.positionAt(fieldIdx); @@ -238,7 +243,7 @@ export class StatusListener { } } catch (error) { // Silently fail validation errors to not disrupt the user experience - console.error('Schema validation error:', error); + // Logger can be used here if needed for debugging } } diff --git a/src/helpers/ContentTypeSchemaGenerator.ts b/src/helpers/ContentTypeSchemaGenerator.ts index ccab28de..8783d4cd 100644 --- a/src/helpers/ContentTypeSchemaGenerator.ts +++ b/src/helpers/ContentTypeSchemaGenerator.ts @@ -254,7 +254,7 @@ export class ContentTypeSchemaGenerator { } const fieldGroupIds = Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup]; - const fieldGroups = Settings.get(SETTING_TAXONOMY_FIELD_GROUPS) as any[]; + const fieldGroups = Settings.get(SETTING_TAXONOMY_FIELD_GROUPS) as { id: string; fields: Field[] }[] | undefined; if (!fieldGroups || fieldGroups.length === 0) { return schemas; diff --git a/src/utils/index.ts b/src/utils/index.ts index a2e342a5..b6d73cfc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -28,4 +28,3 @@ export * from './sleep'; export * from './sortPages'; export * from './unlinkAsync'; export * from './writeFileAsync'; -export * from '../helpers/ContentTypeSchemaGenerator'; From 2b7fd1d1e7ff59a2c50aed82d79e7923f4874efa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:12:58 +0000 Subject: [PATCH 4/8] Improve field location detection and add comprehensive documentation Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com> --- src/commands/StatusListener.ts | 20 ++++++++++++--- src/helpers/ContentTypeSchemaGenerator.ts | 30 +++++++++++++++++++++++ src/helpers/FrontMatterValidator.ts | 20 +++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index b2327d66..9e1400a4 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -209,13 +209,25 @@ export class StatusListener { const frontMatterEnd = frontMatterMatch ? frontMatterMatch[0].length : text.length; for (const error of errors) { - // Find the field in the document - const fieldPath = error.field.split('.'); - const fieldName = fieldPath[fieldPath.length - 1]; + // For required field errors, use the missing property name + let fieldName = ''; + if (error.keyword === 'required' && error.params?.missingProperty) { + fieldName = error.params.missingProperty; + } else { + // Find the field in the document + const fieldPath = error.field.split('.'); + fieldName = fieldPath[fieldPath.length - 1]; + } + + if (!fieldName || fieldName === 'root') { + continue; // Skip if we can't determine field name + } // Try to find the field location in the front matter section only + // Note: This is a simple implementation that may match partial strings + // Future improvement: Use YAML AST parsing for exact field locations const searchText = text.substring(0, frontMatterEnd); - const fieldIdx = searchText.indexOf(fieldName); + const fieldIdx = searchText.indexOf(`${fieldName}:`); if (fieldIdx !== -1) { const posStart = editor.document.positionAt(fieldIdx); diff --git a/src/helpers/ContentTypeSchemaGenerator.ts b/src/helpers/ContentTypeSchemaGenerator.ts index 8783d4cd..5f4984b9 100644 --- a/src/helpers/ContentTypeSchemaGenerator.ts +++ b/src/helpers/ContentTypeSchemaGenerator.ts @@ -24,6 +24,36 @@ export interface JSONSchema { /** * 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 { /** diff --git a/src/helpers/FrontMatterValidator.ts b/src/helpers/FrontMatterValidator.ts index 08b05789..505fa9db 100644 --- a/src/helpers/FrontMatterValidator.ts +++ b/src/helpers/FrontMatterValidator.ts @@ -14,6 +14,26 @@ export interface ValidationError { /** * 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; From 5de91cf6830d741b92703be69ad678841e3b141a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:02:40 +0000 Subject: [PATCH 5/8] Add validation for tags, categories, and custom taxonomy against known values Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com> --- package-lock.json | 1 + src/commands/StatusListener.ts | 2 +- src/helpers/ContentTypeSchemaGenerator.ts | 70 +++++++++++++++++++---- src/helpers/FrontMatterValidator.ts | 8 +-- 4 files changed, 64 insertions(+), 17 deletions(-) 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/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index 9e1400a4..5aded95e 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -195,7 +195,7 @@ export class StatusListener { } // Validate against schema - const errors = StatusListener.validator.validate(article.data, contentType); + const errors = await StatusListener.validator.validate(article.data, contentType); if (errors.length === 0) { return; diff --git a/src/helpers/ContentTypeSchemaGenerator.ts b/src/helpers/ContentTypeSchemaGenerator.ts index 5f4984b9..52bb717d 100644 --- a/src/helpers/ContentTypeSchemaGenerator.ts +++ b/src/helpers/ContentTypeSchemaGenerator.ts @@ -1,6 +1,8 @@ -import { ContentType, Field, FieldType } from '../models'; +import { ContentType, Field, FieldType, CustomTaxonomy } from '../models'; import { Settings } from '../helpers/SettingsHelper'; -import { SETTING_TAXONOMY_FIELD_GROUPS } from '../constants'; +import { SETTING_TAXONOMY_FIELD_GROUPS, SETTING_TAXONOMY_CUSTOM } from '../constants'; +import { TaxonomyHelper } from './TaxonomyHelper'; +import { TaxonomyType } from '../models/TaxonomyType'; /** * JSON Schema type definition @@ -61,7 +63,7 @@ export class ContentTypeSchemaGenerator { * @param contentType The content type to generate schema from * @returns JSON Schema object */ - public static generateSchema(contentType: ContentType): JSONSchema { + public static async generateSchema(contentType: ContentType): Promise { const schema: JSONSchema = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', @@ -75,7 +77,7 @@ export class ContentTypeSchemaGenerator { // Process each field in the content type for (const field of contentType.fields) { - const fieldSchema = this.generateFieldSchema(field); + const fieldSchema = await this.generateFieldSchema(field); if (fieldSchema && schema.properties) { schema.properties[field.name] = fieldSchema; @@ -99,7 +101,7 @@ export class ContentTypeSchemaGenerator { * @param field The field to generate schema from * @returns JSON Schema object for the field */ - private static generateFieldSchema(field: Field): JSONSchema | null { + 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; @@ -166,9 +168,53 @@ export class ContentTypeSchemaGenerator { } break; - case 'tags': - case 'categories': - case 'taxonomy': + 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 = { @@ -183,7 +229,7 @@ export class ContentTypeSchemaGenerator { if (field.fields && field.fields.length > 0) { for (const subField of field.fields) { - const subFieldSchema = this.generateFieldSchema(subField); + const subFieldSchema = await this.generateFieldSchema(subField); if (subFieldSchema && schema.properties) { schema.properties[subField.name] = subFieldSchema; @@ -208,7 +254,7 @@ export class ContentTypeSchemaGenerator { }; // Try to get the field group schemas - const blockSchemas = this.getBlockFieldGroupSchemas(field); + const blockSchemas = await this.getBlockFieldGroupSchemas(field); if (blockSchemas.length > 0) { schema.items = { oneOf: blockSchemas @@ -276,7 +322,7 @@ export class ContentTypeSchemaGenerator { * @param field The block field * @returns Array of JSON Schemas for each field group */ - private static getBlockFieldGroupSchemas(field: Field): JSONSchema[] { + private static async getBlockFieldGroupSchemas(field: Field): Promise { const schemas: JSONSchema[] = []; if (!field.fieldGroup) { @@ -300,7 +346,7 @@ export class ContentTypeSchemaGenerator { }; for (const groupField of fieldGroup.fields) { - const fieldSchema = this.generateFieldSchema(groupField); + const fieldSchema = await this.generateFieldSchema(groupField); if (fieldSchema && groupSchema.properties) { groupSchema.properties[groupField.name] = fieldSchema; diff --git a/src/helpers/FrontMatterValidator.ts b/src/helpers/FrontMatterValidator.ts index 505fa9db..e2553ebb 100644 --- a/src/helpers/FrontMatterValidator.ts +++ b/src/helpers/FrontMatterValidator.ts @@ -55,13 +55,13 @@ export class FrontMatterValidator { * @param contentType The content type to validate against * @returns Array of validation errors (empty if valid) */ - public validate(data: any, contentType: ContentType): ValidationError[] { + public async validate(data: any, contentType: ContentType): Promise { if (!contentType || !contentType.fields || contentType.fields.length === 0) { return []; } // Get or generate schema - const schema = this.getSchema(contentType); + const schema = await this.getSchema(contentType); if (!schema) { return []; } @@ -83,7 +83,7 @@ export class FrontMatterValidator { * @param contentType The content type * @returns JSON Schema */ - private getSchema(contentType: ContentType): JSONSchema | null { + private async getSchema(contentType: ContentType): Promise { // Check cache first const cacheKey = contentType.name; if (this.schemaCache.has(cacheKey)) { @@ -91,7 +91,7 @@ export class FrontMatterValidator { } // Generate new schema - const schema = ContentTypeSchemaGenerator.generateSchema(contentType); + const schema = await ContentTypeSchemaGenerator.generateSchema(contentType); this.schemaCache.set(cacheKey, schema); return schema; From 7d2ecc53afb4c3ab23c6baded2283defd0337e9c Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sat, 14 Mar 2026 11:07:36 +0100 Subject: [PATCH 6/8] Add front matter validation setting and update schema validation logic --- package.json | 6 +++ package.nls.json | 1 + src/commands/StatusListener.ts | 69 ++++++++++++++++++++++++++++++---- src/constants/settings.ts | 2 + 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index edee295a..94776ba8 100644 --- a/package.json +++ b/package.json @@ -2110,6 +2110,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 8110f097..0b07a3a6 100644 --- a/package.nls.json +++ b/package.nls.json @@ -277,6 +277,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 5aded95e..477aa904 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -4,7 +4,8 @@ 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, FrontMatterValidator } from '../helpers'; @@ -20,7 +21,13 @@ import { i18n } from './i18n'; import { getDescriptionField, getTitleField } from '../utils'; export class StatusListener { - private static validator: FrontMatterValidator = new FrontMatterValidator(); + 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 * @@ -73,7 +80,10 @@ export class StatusListener { StatusListener.verifyRequiredFields(editor, article, collection); // Schema validation - await StatusListener.verifySchemaValidation(editor, article, collection); + const validationEnabled = Settings.get(SETTING_VALIDATION_ENABLED, true); + if (validationEnabled) { + await StatusListener.verifySchemaValidation(editor, article, collection); + } } } @@ -211,12 +221,21 @@ export class StatusListener { for (const error of errors) { // For required field errors, use the missing property name let fieldName = ''; + let arrayIndex: number | undefined; if (error.keyword === 'required' && error.params?.missingProperty) { fieldName = error.params.missingProperty; } else { // Find the field in the document const fieldPath = error.field.split('.'); - fieldName = fieldPath[fieldPath.length - 1]; + // If the last segment is a numeric index (e.g. tags.0), use the parent + // field name and track which array item to highlight + const lastSegment = fieldPath[fieldPath.length - 1]; + if (/^\d+$/.test(lastSegment)) { + arrayIndex = parseInt(lastSegment, 10); + fieldName = fieldPath[fieldPath.length - 2] || ''; + } else { + fieldName = lastSegment; + } } if (!fieldName || fieldName === 'root') { @@ -224,14 +243,48 @@ export class StatusListener { } // Try to find the field location in the front matter section only - // Note: This is a simple implementation that may match partial strings - // Future improvement: Use YAML AST parsing for exact field locations const searchText = text.substring(0, frontMatterEnd); const fieldIdx = searchText.indexOf(`${fieldName}:`); if (fieldIdx !== -1) { - const posStart = editor.document.positionAt(fieldIdx); - const posEnd = editor.document.positionAt(fieldIdx + fieldName.length); + let posStart: vscode.Position; + let posEnd: vscode.Position; + + // Default range: the field name itself + posStart = editor.document.positionAt(fieldIdx); + posEnd = editor.document.positionAt(fieldIdx + fieldName.length); + + if (arrayIndex !== undefined) { + // Walk lines after the field to find the Nth array item (lines starting with ' - ') + 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) { + // Found the right item — highlight the value after '- ' + 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 = editor.document.positionAt(valueStartOffset); + posEnd = editor.document.positionAt(valueStartOffset + itemValue.length); + break; + } + remaining--; + } else if (line.trim() && !/^\s/.test(line)) { + // Hit a new top-level field — stop searching + break; + } + searchFrom = (lineEnd === -1 ? frontMatterEnd : lineEnd) + 1; + } + } const diagnostic: vscode.Diagnostic = { code: '', diff --git a/src/constants/settings.ts b/src/constants/settings.ts index 6c96af7e..be7a12f7 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'; + /** * Sponsors only settings */ From 1f52b02bf72746b6099134414383723a22d2fcbc Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sat, 14 Mar 2026 11:07:47 +0100 Subject: [PATCH 7/8] Refactor code formatting and improve schema validation logic in StatusListener --- package.nls.json | 2 +- src/commands/StatusListener.ts | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/package.nls.json b/package.nls.json index 0b07a3a6..d755dcb0 100644 --- a/package.nls.json +++ b/package.nls.json @@ -277,7 +277,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.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 477aa904..317cc9bc 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -8,7 +8,13 @@ import { SETTING_VALIDATION_ENABLED } from './../constants'; import * as vscode from 'vscode'; -import { ArticleHelper, Notifications, SeoHelper, Settings, FrontMatterValidator } 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'; @@ -78,7 +84,7 @@ 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) { @@ -213,7 +219,7 @@ export class StatusListener { const text = editor.document.getText(); const schemaDiagnostics: vscode.Diagnostic[] = []; - + // Find the front matter section (between --- markers) const frontMatterMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); const frontMatterEnd = frontMatterMatch ? frontMatterMatch[0].length : text.length; @@ -266,15 +272,15 @@ export class StatusListener { if (remaining === 0) { // Found the right item — highlight the value after '- ' 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 = editor.document.positionAt(valueStartOffset); - posEnd = editor.document.positionAt(valueStartOffset + itemValue.length); + 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 = editor.document.positionAt(valueStartOffset); + posEnd = editor.document.positionAt(valueStartOffset + itemValue.length); break; } remaining--; From 70c17d5de3e4c1c08d74f7771c9a084026f99f78 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Sat, 14 Mar 2026 11:12:59 +0100 Subject: [PATCH 8/8] Enhance schema validation for YAML front matter and improve error range detection --- CHANGELOG.md | 1 + src/commands/StatusListener.ts | 261 ++++++++++++++++++++++++--------- 2 files changed, 190 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ffe3b5..41c54ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [#937](https://github.com/estruyf/vscode-front-matter/issues/937): Dashboard "Structure" view for documentation sites - [#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. ### 🐞 Fixes diff --git a/src/commands/StatusListener.ts b/src/commands/StatusListener.ts index 317cc9bc..65ffc1eb 100644 --- a/src/commands/StatusListener.ts +++ b/src/commands/StatusListener.ts @@ -13,18 +13,21 @@ import { Notifications, SeoHelper, Settings, - FrontMatterValidator + 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; @@ -220,82 +223,14 @@ export class StatusListener { const text = editor.document.getText(); const schemaDiagnostics: vscode.Diagnostic[] = []; - // Find the front matter section (between --- markers) - const frontMatterMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); - const frontMatterEnd = frontMatterMatch ? frontMatterMatch[0].length : text.length; - for (const error of errors) { - // For required field errors, use the missing property name - let fieldName = ''; - let arrayIndex: number | undefined; - if (error.keyword === 'required' && error.params?.missingProperty) { - fieldName = error.params.missingProperty; - } else { - // Find the field in the document - const fieldPath = error.field.split('.'); - // If the last segment is a numeric index (e.g. tags.0), use the parent - // field name and track which array item to highlight - const lastSegment = fieldPath[fieldPath.length - 1]; - if (/^\d+$/.test(lastSegment)) { - arrayIndex = parseInt(lastSegment, 10); - fieldName = fieldPath[fieldPath.length - 2] || ''; - } else { - fieldName = lastSegment; - } - } - - if (!fieldName || fieldName === 'root') { - continue; // Skip if we can't determine field name - } - - // Try to find the field location in the front matter section only - const searchText = text.substring(0, frontMatterEnd); - const fieldIdx = searchText.indexOf(`${fieldName}:`); - - if (fieldIdx !== -1) { - let posStart: vscode.Position; - let posEnd: vscode.Position; - - // Default range: the field name itself - posStart = editor.document.positionAt(fieldIdx); - posEnd = editor.document.positionAt(fieldIdx + fieldName.length); - - if (arrayIndex !== undefined) { - // Walk lines after the field to find the Nth array item (lines starting with ' - ') - 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) { - // Found the right item — highlight the value after '- ' - 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 = editor.document.positionAt(valueStartOffset); - posEnd = editor.document.positionAt(valueStartOffset + itemValue.length); - break; - } - remaining--; - } else if (line.trim() && !/^\s/.test(line)) { - // Hit a new top-level field — stop searching - break; - } - searchFrom = (lineEnd === -1 ? frontMatterEnd : lineEnd) + 1; - } - } + const range = StatusListener.findSchemaErrorRange(editor.document, text, error); + if (range) { const diagnostic: vscode.Diagnostic = { code: '', message: error.message, - range: new vscode.Range(posStart, posEnd), + range, severity: vscode.DiagnosticSeverity.Warning, source: EXTENSION_NAME }; @@ -318,6 +253,188 @@ export class StatusListener { } } + 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