diff --git a/gallery/Article/invalid_missing_headline.json b/gallery/Article/invalid_missing_headline.json new file mode 100644 index 0000000..d432eb8 --- /dev/null +++ b/gallery/Article/invalid_missing_headline.json @@ -0,0 +1,9 @@ +{ + "@context": "https://schema.org", + "@type": "Article", + "author": { + "@type": "Person", + "name": "Jane Doe" + }, + "datePublished": "2025-01-07" +} diff --git a/gallery/Article/valid1.json b/gallery/Article/valid1.json new file mode 100644 index 0000000..41400bd --- /dev/null +++ b/gallery/Article/valid1.json @@ -0,0 +1,20 @@ +{ + "@context": "https://schema.org", + "@type": "Article", + "headline": "How to Write Great Headlines", + "author": { + "@type": "Person", + "name": "Jane Doe" + }, + "publisher": { + "@type": "Organization", + "name": "Example News", + "logo": { + "@type": "ImageObject", + "url": "https://example.com/logo.png" + } + }, + "datePublished": "2025-01-07", + "dateModified": "2025-01-07", + "image": "https://example.com/article-image.jpg" +} diff --git a/gallery/Event/invalid_missing_location.json b/gallery/Event/invalid_missing_location.json new file mode 100644 index 0000000..3748842 --- /dev/null +++ b/gallery/Event/invalid_missing_location.json @@ -0,0 +1,6 @@ +{ + "@context": "https://schema.org", + "@type": "Event", + "name": "The Adventures of Kira and Morrison", + "startDate": "2025-07-21T19:00-05:00" +} diff --git a/gallery/Event/invalid_missing_name.json b/gallery/Event/invalid_missing_name.json new file mode 100644 index 0000000..6c5f3b7 --- /dev/null +++ b/gallery/Event/invalid_missing_name.json @@ -0,0 +1,9 @@ +{ + "@context": "https://schema.org", + "@type": "Event", + "startDate": "2025-07-21T19:00-05:00", + "location": { + "@type": "Place", + "name": "Snickerpark Stadium" + } +} diff --git a/gallery/Event/valid1.json b/gallery/Event/valid1.json new file mode 100644 index 0000000..3a2ae01 --- /dev/null +++ b/gallery/Event/valid1.json @@ -0,0 +1,36 @@ +{ + "@context": "https://schema.org", + "@type": "Event", + "name": "The Adventures of Kira and Morrison", + "startDate": "2025-07-21T19:00-05:00", + "endDate": "2025-07-21T23:00-05:00", + "location": { + "@type": "Place", + "name": "Snickerpark Stadium", + "address": { + "@type": "PostalAddress", + "streetAddress": "100 West Snickerpark Dr", + "addressLocality": "Snickertown", + "postalCode": "19019", + "addressRegion": "PA", + "addressCountry": "US" + } + }, + "image": "https://example.com/event-image.jpg", + "description": "An amazing concert event", + "offers": { + "@type": "Offer", + "url": "https://example.com/tickets", + "price": "30", + "priceCurrency": "USD", + "availability": "https://schema.org/InStock" + }, + "performer": { + "@type": "Person", + "name": "Kira Morrison" + }, + "organizer": { + "@type": "Organization", + "name": "Concert Events Inc" + } +} diff --git a/gallery/Event/valid_online.json b/gallery/Event/valid_online.json new file mode 100644 index 0000000..d3821aa --- /dev/null +++ b/gallery/Event/valid_online.json @@ -0,0 +1,9 @@ +{ + "@context": "https://schema.org", + "@type": "Event", + "name": "Online Webinar: Introduction to Schema.org", + "startDate": "2025-07-21T19:00-05:00", + "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode", + "eventStatus": "https://schema.org/EventScheduled", + "description": "Learn about structured data" +} diff --git a/gallery/FAQPage/invalid_missing_mainEntity.json b/gallery/FAQPage/invalid_missing_mainEntity.json new file mode 100644 index 0000000..d79f5a0 --- /dev/null +++ b/gallery/FAQPage/invalid_missing_mainEntity.json @@ -0,0 +1,5 @@ +{ + "@context": "https://schema.org", + "@type": "FAQPage", + "name": "Frequently Asked Questions" +} diff --git a/gallery/FAQPage/valid1.json b/gallery/FAQPage/valid1.json new file mode 100644 index 0000000..85520b6 --- /dev/null +++ b/gallery/FAQPage/valid1.json @@ -0,0 +1,22 @@ +{ + "@context": "https://schema.org", + "@type": "FAQPage", + "mainEntity": [ + { + "@type": "Question", + "name": "What is structured data?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Structured data is a standardized format for providing information about a page and classifying the page content." + } + }, + { + "@type": "Question", + "name": "Why is structured data important?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Structured data helps search engines understand your content better and can enable rich results in search." + } + } + ] +} diff --git a/gallery/HowTo/invalid_missing_name.json b/gallery/HowTo/invalid_missing_name.json new file mode 100644 index 0000000..0d331c8 --- /dev/null +++ b/gallery/HowTo/invalid_missing_name.json @@ -0,0 +1,10 @@ +{ + "@context": "https://schema.org", + "@type": "HowTo", + "step": [ + { + "@type": "HowToStep", + "text": "Do something" + } + ] +} diff --git a/gallery/HowTo/invalid_missing_step.json b/gallery/HowTo/invalid_missing_step.json new file mode 100644 index 0000000..cb8231c --- /dev/null +++ b/gallery/HowTo/invalid_missing_step.json @@ -0,0 +1,5 @@ +{ + "@context": "https://schema.org", + "@type": "HowTo", + "name": "How to Do Something" +} diff --git a/gallery/HowTo/valid1.json b/gallery/HowTo/valid1.json new file mode 100644 index 0000000..1c89581 --- /dev/null +++ b/gallery/HowTo/valid1.json @@ -0,0 +1,46 @@ +{ + "@context": "https://schema.org", + "@type": "HowTo", + "name": "How to Change a Tire", + "description": "A step-by-step guide to changing a flat tire.", + "totalTime": "PT30M", + "estimatedCost": { + "@type": "MonetaryAmount", + "currency": "USD", + "value": "0" + }, + "supply": [ + { + "@type": "HowToSupply", + "name": "Spare tire" + }, + { + "@type": "HowToSupply", + "name": "Lug wrench" + } + ], + "tool": [ + { + "@type": "HowToTool", + "name": "Jack" + } + ], + "step": [ + { + "@type": "HowToStep", + "name": "Loosen the lug nuts", + "text": "Use the lug wrench to loosen the lug nuts on the flat tire." + }, + { + "@type": "HowToStep", + "name": "Jack up the car", + "text": "Place the jack under the car frame and raise the car." + }, + { + "@type": "HowToStep", + "name": "Remove the flat tire", + "text": "Remove the lug nuts and pull off the flat tire." + } + ], + "image": "https://example.com/tire-change.jpg" +} diff --git a/gallery/LocalBusiness/invalid_missing_address.json b/gallery/LocalBusiness/invalid_missing_address.json new file mode 100644 index 0000000..70c3651 --- /dev/null +++ b/gallery/LocalBusiness/invalid_missing_address.json @@ -0,0 +1,5 @@ +{ + "@context": "https://schema.org", + "@type": "LocalBusiness", + "name": "Dave's Steak House" +} diff --git a/gallery/LocalBusiness/invalid_missing_name.json b/gallery/LocalBusiness/invalid_missing_name.json new file mode 100644 index 0000000..13869c3 --- /dev/null +++ b/gallery/LocalBusiness/invalid_missing_name.json @@ -0,0 +1,12 @@ +{ + "@context": "https://schema.org", + "@type": "LocalBusiness", + "address": { + "@type": "PostalAddress", + "streetAddress": "123 Main St", + "addressLocality": "New York", + "addressRegion": "NY", + "postalCode": "10001", + "addressCountry": "US" + } +} diff --git a/gallery/LocalBusiness/valid1.json b/gallery/LocalBusiness/valid1.json new file mode 100644 index 0000000..d0c5466 --- /dev/null +++ b/gallery/LocalBusiness/valid1.json @@ -0,0 +1,36 @@ +{ + "@context": "https://schema.org", + "@type": "LocalBusiness", + "name": "Dave's Steak House", + "address": { + "@type": "PostalAddress", + "streetAddress": "123 Main St", + "addressLocality": "New York", + "addressRegion": "NY", + "postalCode": "10001", + "addressCountry": "US" + }, + "telephone": "+1-212-555-1234", + "url": "https://www.davessteakhouse.example.com", + "image": "https://www.davessteakhouse.example.com/image.jpg", + "priceRange": "$$", + "geo": { + "@type": "GeoCoordinates", + "latitude": "40.7128", + "longitude": "-74.0060" + }, + "openingHoursSpecification": [ + { + "@type": "OpeningHoursSpecification", + "dayOfWeek": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday" + ], + "opens": "11:00", + "closes": "22:00" + } + ] +} diff --git a/gallery/WebSite/invalid_missing_name.json b/gallery/WebSite/invalid_missing_name.json new file mode 100644 index 0000000..e8caa2b --- /dev/null +++ b/gallery/WebSite/invalid_missing_name.json @@ -0,0 +1,5 @@ +{ + "@context": "https://schema.org", + "@type": "WebSite", + "url": "https://www.example.com" +} diff --git a/gallery/WebSite/invalid_missing_url.json b/gallery/WebSite/invalid_missing_url.json new file mode 100644 index 0000000..838d456 --- /dev/null +++ b/gallery/WebSite/invalid_missing_url.json @@ -0,0 +1,5 @@ +{ + "@context": "https://schema.org", + "@type": "WebSite", + "name": "Example Website" +} diff --git a/gallery/WebSite/valid1.json b/gallery/WebSite/valid1.json new file mode 100644 index 0000000..3409c04 --- /dev/null +++ b/gallery/WebSite/valid1.json @@ -0,0 +1,14 @@ +{ + "@context": "https://schema.org", + "@type": "WebSite", + "name": "Example Website", + "url": "https://www.example.com", + "potentialAction": { + "@type": "SearchAction", + "target": { + "@type": "EntryPoint", + "urlTemplate": "https://www.example.com/search?q={search_term_string}" + }, + "query-input": "required name=search_term_string" + } +} diff --git a/src/types/Answer.js b/src/types/Answer.js new file mode 100644 index 0000000..93be1df --- /dev/null +++ b/src/types/Answer.js @@ -0,0 +1,18 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class AnswerValidator extends BaseValidator { + getConditions() { + return [this.required('text')].map((c) => c.bind(this)); + } +} diff --git a/src/types/Article.js b/src/types/Article.js new file mode 100644 index 0000000..912bd02 --- /dev/null +++ b/src/types/Article.js @@ -0,0 +1,26 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class ArticleValidator extends BaseValidator { + getConditions() { + return [ + this.required('headline'), + + this.recommended('author', 'arrayOrObject'), + this.recommended('dateModified', 'date'), + this.recommended('datePublished', 'date'), + this.recommended('image', 'arrayOrObject'), + this.recommended('publisher', 'object'), + ].map((c) => c.bind(this)); + } +} diff --git a/src/types/Event.js b/src/types/Event.js new file mode 100644 index 0000000..e07e141 --- /dev/null +++ b/src/types/Event.js @@ -0,0 +1,51 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class EventValidator extends BaseValidator { + getConditions() { + return [ + this.required('name'), + this.required('startDate', 'date'), + this.locationOrAttendanceMode, + + this.recommended('description'), + this.recommended('endDate', 'date'), + this.recommended('eventAttendanceMode'), + this.recommended('eventStatus'), + this.recommended('image', 'arrayOrObject'), + this.recommended('offers', 'arrayOrObject'), + this.recommended('organizer', 'object'), + this.recommended('performer', 'arrayOrObject'), + ].map((c) => c.bind(this)); + } + + locationOrAttendanceMode(data) { + const hasLocation = data.location !== undefined && data.location !== null; + const hasOnlineAttendanceMode = + data.eventAttendanceMode && + (data.eventAttendanceMode.includes('OnlineEventAttendanceMode') || + data.eventAttendanceMode.includes('MixedEventAttendanceMode')); + + if (!hasLocation && !hasOnlineAttendanceMode) { + return { + issueMessage: + 'Either "location" or online "eventAttendanceMode" is required', + severity: 'ERROR', + path: this.path, + fieldName: 'location', + fieldNames: ['location', 'eventAttendanceMode'], + }; + } + return null; + } +} diff --git a/src/types/FAQPage.js b/src/types/FAQPage.js new file mode 100644 index 0000000..ef1fb02 --- /dev/null +++ b/src/types/FAQPage.js @@ -0,0 +1,18 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class FAQPageValidator extends BaseValidator { + getConditions() { + return [this.required('mainEntity', 'arrayOrObject')].map((c) => c.bind(this)); + } +} diff --git a/src/types/HowTo.js b/src/types/HowTo.js new file mode 100644 index 0000000..a62dd12 --- /dev/null +++ b/src/types/HowTo.js @@ -0,0 +1,27 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class HowToValidator extends BaseValidator { + getConditions() { + return [ + this.required('name'), + this.required('step', 'arrayOrObject'), + + this.recommended('estimatedCost', 'object'), + this.recommended('image', 'arrayOrObject'), + this.recommended('supply', 'arrayOrObject'), + this.recommended('tool', 'arrayOrObject'), + this.recommended('totalTime', 'duration'), + ].map((c) => c.bind(this)); + } +} diff --git a/src/types/LocalBusiness.js b/src/types/LocalBusiness.js new file mode 100644 index 0000000..69f8802 --- /dev/null +++ b/src/types/LocalBusiness.js @@ -0,0 +1,30 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class LocalBusinessValidator extends BaseValidator { + getConditions() { + return [ + this.required('name'), + this.required('address', 'object'), + + this.recommended('aggregateRating', 'object'), + this.recommended('geo', 'object'), + this.recommended('image', 'url'), + this.recommended('openingHoursSpecification', 'arrayOrObject'), + this.recommended('priceRange'), + this.recommended('review', 'arrayOrObject'), + this.recommended('telephone'), + this.recommended('url', 'url'), + ].map((c) => c.bind(this)); + } +} diff --git a/src/types/Question.js b/src/types/Question.js new file mode 100644 index 0000000..cea1923 --- /dev/null +++ b/src/types/Question.js @@ -0,0 +1,21 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class QuestionValidator extends BaseValidator { + getConditions() { + return [ + this.required('name'), + this.required('acceptedAnswer', 'object'), + ].map((c) => c.bind(this)); + } +} diff --git a/src/types/WebSite.js b/src/types/WebSite.js new file mode 100644 index 0000000..cb5c5d1 --- /dev/null +++ b/src/types/WebSite.js @@ -0,0 +1,23 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import BaseValidator from './base.js'; + +export default class WebSiteValidator extends BaseValidator { + getConditions() { + return [ + this.required('name'), + this.required('url', 'url'), + + this.recommended('potentialAction', 'arrayOrObject'), + ].map((c) => c.bind(this)); + } +} diff --git a/src/types/__tests__/Article.test.js b/src/types/__tests__/Article.test.js new file mode 100644 index 0000000..a62c513 --- /dev/null +++ b/src/types/__tests__/Article.test.js @@ -0,0 +1,47 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from "chai"; + +import { loadTestData } from "./utils.js"; +import { Validator } from "../../validator.js"; + +describe("ArticleValidator", () => { + describe("JSON-LD", () => { + let validator; + + beforeEach(() => { + validator = new Validator(); + validator.globalHandlers = []; + }); + + it("should validate a correct Article structure in valid1.json", async () => { + const data = await loadTestData("Article/valid1.json", "jsonld"); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(0); + }); + + it("should fail when headline is missing", async () => { + const data = await loadTestData( + "Article/invalid_missing_headline.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "headline" is missing', + }); + }); + }); +}); diff --git a/src/types/__tests__/Article/invalid_missing_headline.json b/src/types/__tests__/Article/invalid_missing_headline.json new file mode 100644 index 0000000..a039617 --- /dev/null +++ b/src/types/__tests__/Article/invalid_missing_headline.json @@ -0,0 +1,8 @@ +{ + "@type": "Article", + "author": { + "@type": "Person", + "name": "Jane Doe" + }, + "datePublished": "2025-01-07" +} diff --git a/src/types/__tests__/Article/valid1.json b/src/types/__tests__/Article/valid1.json new file mode 100644 index 0000000..9ea5871 --- /dev/null +++ b/src/types/__tests__/Article/valid1.json @@ -0,0 +1,19 @@ +{ + "@type": "Article", + "headline": "How to Write Great Headlines", + "author": { + "@type": "Person", + "name": "Jane Doe" + }, + "publisher": { + "@type": "Organization", + "name": "Example News", + "logo": { + "@type": "ImageObject", + "url": "https://example.com/logo.png" + } + }, + "datePublished": "2025-01-07", + "dateModified": "2025-01-07", + "image": "https://example.com/article-image.jpg" +} diff --git a/src/types/__tests__/Event.test.js b/src/types/__tests__/Event.test.js new file mode 100644 index 0000000..20a1d96 --- /dev/null +++ b/src/types/__tests__/Event.test.js @@ -0,0 +1,69 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from 'chai'; + +import { loadTestData } from './utils.js'; +import { Validator } from '../../validator.js'; + +describe('EventValidator', () => { + describe('JSON-LD', () => { + let validator; + + beforeEach(() => { + validator = new Validator(); + validator.globalHandlers = []; + }); + + it('should validate a correct Event structure in valid1.json', async () => { + const data = await loadTestData('Event/valid1.json', 'jsonld'); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors).to.have.lengthOf(0); + }); + + it('should validate an online Event in valid_online.json', async () => { + const data = await loadTestData('Event/valid_online.json', 'jsonld'); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors).to.have.lengthOf(0); + }); + + it('should fail when name is missing', async () => { + const data = await loadTestData( + 'Event/invalid_missing_name.json', + 'jsonld', + ); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors.length).to.be.greaterThan(0); + expect(errors[0]).to.deep.include({ + severity: 'ERROR', + issueMessage: 'Required attribute "name" is missing', + }); + }); + + it('should fail when location is missing for physical event', async () => { + const data = await loadTestData( + 'Event/invalid_missing_location.json', + 'jsonld', + ); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: 'ERROR', + issueMessage: + 'Either "location" or online "eventAttendanceMode" is required', + }); + }); + }); +}); diff --git a/src/types/__tests__/Event/invalid_missing_location.json b/src/types/__tests__/Event/invalid_missing_location.json new file mode 100644 index 0000000..06540cd --- /dev/null +++ b/src/types/__tests__/Event/invalid_missing_location.json @@ -0,0 +1,5 @@ +{ + "@type": "Event", + "name": "The Adventures of Kira and Morrison", + "startDate": "2025-07-21T19:00-05:00" +} diff --git a/src/types/__tests__/Event/invalid_missing_name.json b/src/types/__tests__/Event/invalid_missing_name.json new file mode 100644 index 0000000..390d593 --- /dev/null +++ b/src/types/__tests__/Event/invalid_missing_name.json @@ -0,0 +1,8 @@ +{ + "@type": "Event", + "startDate": "2025-07-21T19:00-05:00", + "location": { + "@type": "Place", + "name": "Snickerpark Stadium" + } +} diff --git a/src/types/__tests__/Event/valid1.json b/src/types/__tests__/Event/valid1.json new file mode 100644 index 0000000..4cd73b3 --- /dev/null +++ b/src/types/__tests__/Event/valid1.json @@ -0,0 +1,35 @@ +{ + "@type": "Event", + "name": "The Adventures of Kira and Morrison", + "startDate": "2025-07-21T19:00-05:00", + "endDate": "2025-07-21T23:00-05:00", + "location": { + "@type": "Place", + "name": "Snickerpark Stadium", + "address": { + "@type": "PostalAddress", + "streetAddress": "100 West Snickerpark Dr", + "addressLocality": "Snickertown", + "postalCode": "19019", + "addressRegion": "PA", + "addressCountry": "US" + } + }, + "image": "https://example.com/event-image.jpg", + "description": "An amazing concert event", + "offers": { + "@type": "Offer", + "url": "https://example.com/tickets", + "price": "30", + "priceCurrency": "USD", + "availability": "https://schema.org/InStock" + }, + "performer": { + "@type": "Person", + "name": "Kira Morrison" + }, + "organizer": { + "@type": "Organization", + "name": "Concert Events Inc" + } +} diff --git a/src/types/__tests__/Event/valid_online.json b/src/types/__tests__/Event/valid_online.json new file mode 100644 index 0000000..0421fcd --- /dev/null +++ b/src/types/__tests__/Event/valid_online.json @@ -0,0 +1,8 @@ +{ + "@type": "Event", + "name": "Online Webinar: Introduction to Schema.org", + "startDate": "2025-07-21T19:00-05:00", + "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode", + "eventStatus": "https://schema.org/EventScheduled", + "description": "Learn about structured data" +} diff --git a/src/types/__tests__/FAQPage.test.js b/src/types/__tests__/FAQPage.test.js new file mode 100644 index 0000000..e299bf4 --- /dev/null +++ b/src/types/__tests__/FAQPage.test.js @@ -0,0 +1,47 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from "chai"; + +import { loadTestData } from "./utils.js"; +import { Validator } from "../../validator.js"; + +describe("FAQPageValidator", () => { + describe("JSON-LD", () => { + let validator; + + beforeEach(() => { + validator = new Validator(); + validator.globalHandlers = []; + }); + + it("should validate a correct FAQPage structure in valid1.json", async () => { + const data = await loadTestData("FAQPage/valid1.json", "jsonld"); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(0); + }); + + it("should fail when mainEntity is missing", async () => { + const data = await loadTestData( + "FAQPage/invalid_missing_mainEntity.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "mainEntity" is missing', + }); + }); + }); +}); diff --git a/src/types/__tests__/FAQPage/invalid_missing_mainEntity.json b/src/types/__tests__/FAQPage/invalid_missing_mainEntity.json new file mode 100644 index 0000000..a0e8ac8 --- /dev/null +++ b/src/types/__tests__/FAQPage/invalid_missing_mainEntity.json @@ -0,0 +1,4 @@ +{ + "@type": "FAQPage", + "name": "Frequently Asked Questions" +} diff --git a/src/types/__tests__/FAQPage/valid1.json b/src/types/__tests__/FAQPage/valid1.json new file mode 100644 index 0000000..41568ef --- /dev/null +++ b/src/types/__tests__/FAQPage/valid1.json @@ -0,0 +1,21 @@ +{ + "@type": "FAQPage", + "mainEntity": [ + { + "@type": "Question", + "name": "What is structured data?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Structured data is a standardized format for providing information about a page and classifying the page content." + } + }, + { + "@type": "Question", + "name": "Why is structured data important?", + "acceptedAnswer": { + "@type": "Answer", + "text": "Structured data helps search engines understand your content better and can enable rich results in search." + } + } + ] +} diff --git a/src/types/__tests__/HowTo.test.js b/src/types/__tests__/HowTo.test.js new file mode 100644 index 0000000..860ac71 --- /dev/null +++ b/src/types/__tests__/HowTo.test.js @@ -0,0 +1,61 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from 'chai'; + +import { loadTestData } from './utils.js'; +import { Validator } from '../../validator.js'; + +describe('HowToValidator', () => { + describe('JSON-LD', () => { + let validator; + + beforeEach(() => { + validator = new Validator(); + validator.globalHandlers = []; + }); + + it('should validate a correct HowTo structure in valid1.json', async () => { + const data = await loadTestData('HowTo/valid1.json', 'jsonld'); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors).to.have.lengthOf(0); + }); + + it('should fail when name is missing', async () => { + const data = await loadTestData( + 'HowTo/invalid_missing_name.json', + 'jsonld', + ); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors.length).to.be.greaterThan(0); + expect(errors[0]).to.deep.include({ + severity: 'ERROR', + issueMessage: 'Required attribute "name" is missing', + }); + }); + + it('should fail when step is missing', async () => { + const data = await loadTestData( + 'HowTo/invalid_missing_step.json', + 'jsonld', + ); + const issues = await validator.validate(data); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors.length).to.be.greaterThan(0); + expect(errors[0]).to.deep.include({ + severity: 'ERROR', + issueMessage: 'Required attribute "step" is missing', + }); + }); + }); +}); diff --git a/src/types/__tests__/HowTo/invalid_missing_name.json b/src/types/__tests__/HowTo/invalid_missing_name.json new file mode 100644 index 0000000..bbd49b3 --- /dev/null +++ b/src/types/__tests__/HowTo/invalid_missing_name.json @@ -0,0 +1,9 @@ +{ + "@type": "HowTo", + "step": [ + { + "@type": "HowToStep", + "text": "Do something" + } + ] +} diff --git a/src/types/__tests__/HowTo/invalid_missing_step.json b/src/types/__tests__/HowTo/invalid_missing_step.json new file mode 100644 index 0000000..94ed64b --- /dev/null +++ b/src/types/__tests__/HowTo/invalid_missing_step.json @@ -0,0 +1,4 @@ +{ + "@type": "HowTo", + "name": "How to Do Something" +} diff --git a/src/types/__tests__/HowTo/valid1.json b/src/types/__tests__/HowTo/valid1.json new file mode 100644 index 0000000..68c3415 --- /dev/null +++ b/src/types/__tests__/HowTo/valid1.json @@ -0,0 +1,45 @@ +{ + "@type": "HowTo", + "name": "How to Change a Tire", + "description": "A step-by-step guide to changing a flat tire.", + "totalTime": "PT30M", + "estimatedCost": { + "@type": "MonetaryAmount", + "currency": "USD", + "value": "0" + }, + "supply": [ + { + "@type": "HowToSupply", + "name": "Spare tire" + }, + { + "@type": "HowToSupply", + "name": "Lug wrench" + } + ], + "tool": [ + { + "@type": "HowToTool", + "name": "Jack" + } + ], + "step": [ + { + "@type": "HowToStep", + "name": "Loosen the lug nuts", + "text": "Use the lug wrench to loosen the lug nuts on the flat tire." + }, + { + "@type": "HowToStep", + "name": "Jack up the car", + "text": "Place the jack under the car frame and raise the car." + }, + { + "@type": "HowToStep", + "name": "Remove the flat tire", + "text": "Remove the lug nuts and pull off the flat tire." + } + ], + "image": "https://example.com/tire-change.jpg" +} diff --git a/src/types/__tests__/LocalBusiness.test.js b/src/types/__tests__/LocalBusiness.test.js new file mode 100644 index 0000000..4dc93cb --- /dev/null +++ b/src/types/__tests__/LocalBusiness.test.js @@ -0,0 +1,64 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from "chai"; + +import { loadTestData } from "./utils.js"; +import { Validator } from "../../validator.js"; + +describe("LocalBusinessValidator", () => { + describe("JSON-LD", () => { + let validator; + + beforeEach(() => { + validator = new Validator(); + validator.globalHandlers = []; + }); + + it("should validate a correct LocalBusiness structure in valid1.json", async () => { + const data = await loadTestData( + "LocalBusiness/valid1.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(0); + }); + + it("should fail when name is missing", async () => { + const data = await loadTestData( + "LocalBusiness/invalid_missing_name.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "name" is missing', + }); + }); + + it("should fail when address is missing", async () => { + const data = await loadTestData( + "LocalBusiness/invalid_missing_address.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "address" is missing', + }); + }); + }); +}); diff --git a/src/types/__tests__/LocalBusiness/invalid_missing_address.json b/src/types/__tests__/LocalBusiness/invalid_missing_address.json new file mode 100644 index 0000000..c539b11 --- /dev/null +++ b/src/types/__tests__/LocalBusiness/invalid_missing_address.json @@ -0,0 +1,4 @@ +{ + "@type": "LocalBusiness", + "name": "Dave's Steak House" +} diff --git a/src/types/__tests__/LocalBusiness/invalid_missing_name.json b/src/types/__tests__/LocalBusiness/invalid_missing_name.json new file mode 100644 index 0000000..df78b61 --- /dev/null +++ b/src/types/__tests__/LocalBusiness/invalid_missing_name.json @@ -0,0 +1,11 @@ +{ + "@type": "LocalBusiness", + "address": { + "@type": "PostalAddress", + "streetAddress": "123 Main St", + "addressLocality": "New York", + "addressRegion": "NY", + "postalCode": "10001", + "addressCountry": "US" + } +} diff --git a/src/types/__tests__/LocalBusiness/valid1.json b/src/types/__tests__/LocalBusiness/valid1.json new file mode 100644 index 0000000..7657192 --- /dev/null +++ b/src/types/__tests__/LocalBusiness/valid1.json @@ -0,0 +1,35 @@ +{ + "@type": "LocalBusiness", + "name": "Dave's Steak House", + "address": { + "@type": "PostalAddress", + "streetAddress": "123 Main St", + "addressLocality": "New York", + "addressRegion": "NY", + "postalCode": "10001", + "addressCountry": "US" + }, + "telephone": "+1-212-555-1234", + "url": "https://www.davessteakhouse.example.com", + "image": "https://www.davessteakhouse.example.com/image.jpg", + "priceRange": "$$", + "geo": { + "@type": "GeoCoordinates", + "latitude": "40.7128", + "longitude": "-74.0060" + }, + "openingHoursSpecification": [ + { + "@type": "OpeningHoursSpecification", + "dayOfWeek": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday" + ], + "opens": "11:00", + "closes": "22:00" + } + ] +} diff --git a/src/types/__tests__/Product.test.js b/src/types/__tests__/Product.test.js index 62b86e7..e731eef 100644 --- a/src/types/__tests__/Product.test.js +++ b/src/types/__tests__/Product.test.js @@ -281,7 +281,8 @@ describe('ProductValidator', () => { 'jsonld', ); const issues = await validator.validate(data); - expect(issues).to.have.lengthOf(0); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors).to.have.lengthOf(0); }); it('should ensure no errors for a stand alone Offer', async () => { @@ -290,7 +291,8 @@ describe('ProductValidator', () => { 'jsonld', ); const issues = await validator.validate(data); - expect(issues).to.have.lengthOf(0); + const errors = issues.filter((i) => i.severity === 'ERROR'); + expect(errors).to.have.lengthOf(0); }); }); }); diff --git a/src/types/__tests__/WebSite.test.js b/src/types/__tests__/WebSite.test.js new file mode 100644 index 0000000..1c9a6ca --- /dev/null +++ b/src/types/__tests__/WebSite.test.js @@ -0,0 +1,61 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from "chai"; + +import { loadTestData } from "./utils.js"; +import { Validator } from "../../validator.js"; + +describe("WebSiteValidator", () => { + describe("JSON-LD", () => { + let validator; + + beforeEach(() => { + validator = new Validator(); + validator.globalHandlers = []; + }); + + it("should validate a correct WebSite structure in valid1.json", async () => { + const data = await loadTestData("WebSite/valid1.json", "jsonld"); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(0); + }); + + it("should fail when name is missing", async () => { + const data = await loadTestData( + "WebSite/invalid_missing_name.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "name" is missing', + }); + }); + + it("should fail when url is missing", async () => { + const data = await loadTestData( + "WebSite/invalid_missing_url.json", + "jsonld" + ); + const issues = await validator.validate(data); + const errors = issues.filter(i => i.severity === "ERROR"); + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "url" is missing', + }); + }); + }); +}); diff --git a/src/types/__tests__/WebSite/invalid_missing_name.json b/src/types/__tests__/WebSite/invalid_missing_name.json new file mode 100644 index 0000000..5a68b2c --- /dev/null +++ b/src/types/__tests__/WebSite/invalid_missing_name.json @@ -0,0 +1,4 @@ +{ + "@type": "WebSite", + "url": "https://www.example.com" +} diff --git a/src/types/__tests__/WebSite/invalid_missing_url.json b/src/types/__tests__/WebSite/invalid_missing_url.json new file mode 100644 index 0000000..21782db --- /dev/null +++ b/src/types/__tests__/WebSite/invalid_missing_url.json @@ -0,0 +1,4 @@ +{ + "@type": "WebSite", + "name": "Example Website" +} diff --git a/src/types/__tests__/WebSite/valid1.json b/src/types/__tests__/WebSite/valid1.json new file mode 100644 index 0000000..bff77d5 --- /dev/null +++ b/src/types/__tests__/WebSite/valid1.json @@ -0,0 +1,13 @@ +{ + "@type": "WebSite", + "name": "Example Website", + "url": "https://www.example.com", + "potentialAction": { + "@type": "SearchAction", + "target": { + "@type": "EntryPoint", + "urlTemplate": "https://www.example.com/search?q={search_term_string}" + }, + "query-input": "required name=search_term_string" + } +} diff --git a/src/validator.js b/src/validator.js index 4e37914..7d93a14 100644 --- a/src/validator.js +++ b/src/validator.js @@ -23,17 +23,25 @@ export class Validator { '3DModel': [() => import('./types/3DModel.js')], AggregateOffer: [() => import('./types/AggregateOffer.js')], AggregateRating: [() => import('./types/AggregateRating.js')], + Answer: [() => import('./types/Answer.js')], + Article: [() => import('./types/Article.js')], + BlogPosting: [() => import('./types/Article.js')], Brand: [() => import('./types/Brand.js')], BreadcrumbList: [() => import('./types/BreadcrumbList.js')], Certification: [() => import('./types/Certification.js')], DefinedRegion: [() => import('./types/DefinedRegion.js')], + Event: [() => import('./types/Event.js')], + FAQPage: [() => import('./types/FAQPage.js')], + HowTo: [() => import('./types/HowTo.js')], ImageObject: [() => import('./types/ImageObject.js')], VideoObject: [() => import('./types/VideoObject.js')], Clip: [() => import('./types/Clip.js')], BroadcastEvent: [() => import('./types/BroadcastEvent.js')], SeekToAction: [() => import('./types/SeekToAction.js')], ListItem: [() => import('./types/ListItem.js')], + LocalBusiness: [() => import('./types/LocalBusiness.js')], MerchantReturnPolicy: [() => import('./types/MerchantReturnPolicy.js')], + NewsArticle: [() => import('./types/Article.js')], Offer: [() => import('./types/Offer.js')], OfferShippingDetails: [() => import('./types/OfferShippingDetails.js')], Organization: [() => import('./types/Organization.js')], @@ -45,6 +53,7 @@ export class Validator { () => import('./types/ProductMerchant.js'), ], QuantitativeValue: [() => import('./types/QuantitativeValue.js')], + Question: [() => import('./types/Question.js')], Rating: [() => import('./types/Rating.js')], Review: [() => import('./types/Review.js')], ShippingDeliveryTime: [() => import('./types/ShippingDeliveryTime.js')], @@ -56,9 +65,73 @@ export class Validator { HowToSection: [() => import('./types/HowToSection.js')], HowToDirection: [() => import('./types/HowToDirection.js')], HowToTip: [() => import('./types/HowToTip.js')], + WebSite: [() => import('./types/WebSite.js')], }; } + // Get parent types from schema.org JSON-LD + #getParentTypes(type) { + if (!this.schemaOrgJson) return []; + + const graph = this.schemaOrgJson['@graph']; + if (!graph) return []; + + const typeEntry = graph.find( + (e) => + e['@type'] === 'rdfs:Class' && + (e['@id'] === type || + e['@id'] === `schema:${type}` || + e['@id'] === `https://schema.org/${type}`), + ); + + if (!typeEntry || !typeEntry['rdfs:subClassOf']) return []; + + const parents = Array.isArray(typeEntry['rdfs:subClassOf']) + ? typeEntry['rdfs:subClassOf'] + : [typeEntry['rdfs:subClassOf']]; + + return parents.map((p) => { + const id = p['@id'] || p; + return id + .replace('schema:', '') + .replace('https://schema.org/', '') + .replace('http://schema.org/', ''); + }); + } + + // Get handlers for type, falling back to parent types if needed + async #getHandlersForType(type) { + // 1. Check direct mapping first (priority) + if (this.registeredHandlers[type]) { + return [...this.registeredHandlers[type]]; + } + + // 2. If schemaOrgJson available, check parent types + if (this.schemaOrgJson) { + const visited = new Set(); + const queue = [type]; + + while (queue.length > 0) { + const current = queue.shift(); + if (visited.has(current)) continue; + visited.add(current); + + // Check if parent has handlers + if (current !== type && this.registeredHandlers[current]) { + this.debug && + console.debug(` Using ${current} handler for subtype ${type}`); + return [...this.registeredHandlers[current]]; + } + + // Add parent types to queue + const parents = this.#getParentTypes(current); + queue.push(...parents); + } + } + + return []; + } + async #validateSubtree(data, rootData, dataFormat, path = []) { const spacing = ' ' + ' '.repeat(path.length); @@ -103,8 +176,8 @@ export class Validator { JSON.stringify(path), ); - // Find supported handlers - const handlers = [...(this.registeredHandlers[type] || [])]; + // Find supported handlers (check direct mapping first, then parent types) + const handlers = await this.#getHandlersForType(type); if (!handlers || handlers.length === 0) { this.debug && console.warn(