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