diff --git a/gallery/Product/invalid_type.json b/gallery/Product/invalid_type.json new file mode 100644 index 0000000..c116925 --- /dev/null +++ b/gallery/Product/invalid_type.json @@ -0,0 +1,5 @@ +{ + "@context": "https://schema.org", + "@type": "BananaPhone", + "name": "Invalid Type Test" +} diff --git a/src/types/__tests__/schemaOrg.test.js b/src/types/__tests__/schemaOrg.test.js index 211a9be..1482d3b 100644 --- a/src/types/__tests__/schemaOrg.test.js +++ b/src/types/__tests__/schemaOrg.test.js @@ -45,6 +45,12 @@ describe('Schema.org Validator', () => { Brand: [MockValidator], Organization: [MockValidator], Offer: [MockValidator], + VideoObject: [MockValidator], + SeekToAction: [MockValidator], + Clip: [MockValidator], + BroadcastEvent: [MockValidator], + // Invalid type for testing - no type-specific handler, only global schemaOrg handler runs + BananaPhone: [MockValidator], }; }); @@ -156,6 +162,22 @@ describe('Schema.org Validator', () => { errorType: 'schemaOrg', }); }); + + it('should return an error if an invalid type was detected', async () => { + const data = await loadTestData('Product/invalid_type.json', 'jsonld'); + + const issues = await validator.validate(data); + + expect(issues).to.have.lengthOf(1); + expect(issues[0]).to.deep.include({ + rootType: 'BananaPhone', + issueMessage: 'Type "BananaPhone" is not a valid schema.org type', + severity: 'ERROR', + path: [{ type: 'BananaPhone', index: 0 }], + errorType: 'schemaOrg', + fieldName: '@type', + }); + }); }); describe('Microdata', () => { diff --git a/src/types/schemaOrg.js b/src/types/schemaOrg.js index 88579b7..844792c 100644 --- a/src/types/schemaOrg.js +++ b/src/types/schemaOrg.js @@ -178,17 +178,29 @@ export default class SchemaOrgValidator { return false; } + // Strip -input or -output suffix if present (schema.org Actions extension) + // See: https://schema.org/docs/actions.html#part-4 + let propertyToCheck = property; + if (property.endsWith('-input') || property.endsWith('-output')) { + propertyToCheck = property.replace(/-(input|output)$/, ''); + } + // Check if property is directly supported - if (schema[type].properties.includes(property)) { + if (schema[type].properties.includes(propertyToCheck)) { return true; } // Check if property is supported through inheritance return Object.keys(schema[type].propertiesFromParent).some((parent) => { - return schema[type].propertiesFromParent[parent].includes(property); + return schema[type].propertiesFromParent[parent].includes(propertyToCheck); }); } + async validateType(type) { + const schema = await this.#loadSchema(); + return !!schema[type]; + } + async validate(data) { const issues = []; @@ -197,6 +209,22 @@ export default class SchemaOrgValidator { return []; } + const typeId = this.#stripSchema(this.type); + + // Check if type exists in schema.org + const typeExists = await this.validateType(typeId); + if (!typeExists) { + issues.push({ + issueMessage: `Type "${typeId}" is not a valid schema.org type`, + severity: 'ERROR', + path: this.path, + errorType: 'schemaOrg', + fieldName: '@type', + }); + // Skip property validation since type is invalid + return issues; + } + // Get list of properties, any other keys which do not start with @ const properties = Object.keys(data).filter( (key) => !key.startsWith('@'), @@ -206,7 +234,6 @@ export default class SchemaOrgValidator { await Promise.all( properties.map(async (property) => { const propertyId = this.#stripSchema(property); - const typeId = this.#stripSchema(this.type); const isValid = await this.validateProperty(typeId, propertyId); if (!isValid) { @@ -215,6 +242,7 @@ export default class SchemaOrgValidator { severity: 'WARNING', path: this.path, errorType: 'schemaOrg', + fieldName: propertyId, }); } }), diff --git a/src/validator.js b/src/validator.js index 4e37914..81d85ef 100644 --- a/src/validator.js +++ b/src/validator.js @@ -105,14 +105,17 @@ export class Validator { // Find supported handlers const handlers = [...(this.registeredHandlers[type] || [])]; - if (!handlers || handlers.length === 0) { + if (handlers.length === 0) { this.debug && console.warn( `${spacing} WARN: No handlers registered for type: ${type}`, ); - return []; } + // Always run global handlers (e.g., schemaOrg) even if no type-specific handler exists handlers.push(...(this.globalHandlers || [])); + if (handlers.length === 0) { + return []; + } const handlerPromises = handlers.map(async (handler) => { const handlerClass = (await handler()).default;