/** A field of a schema form: its dotted schema.org key and how to edit it. */ export interface SchemaField { key: string; label: string; type: 'text' | 'textarea' | 'url' | 'date' | 'datetime' | 'number' | 'select'; options?: string[]; /** A select whose choices the site provides, through the panel's env. */ optionsFrom?: 'businessTypes'; tip?: string; placeholder?: string; } export interface SchemaType { label: string; group: 'Content' | 'Business' | 'Media' | 'Other'; description: string; fields: SchemaField[]; } /** One entry saved on the post, as the meta field stores it. */ export interface SchemaEntry { type: string; fields: Record; } /** What every business on a page carries, wherever it sits. */ const business: SchemaField[] = [ { key: 'name', label: 'Name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'telephone', label: 'Phone', type: 'text' }, { key: 'email', label: 'Email', type: 'text' }, { key: 'priceRange', label: 'Price range', type: 'text', placeholder: '$$', tip: 'How dear it is, as symbols or a range: $$ or 50-150 RON.', }, { key: 'address.streetAddress', label: 'Street', type: 'text' }, { key: 'address.addressLocality', label: 'City', type: 'text' }, { key: 'address.addressRegion', label: 'Region', type: 'text' }, { key: 'address.postalCode', label: 'Postcode', type: 'text' }, { key: 'address.addressCountry', label: 'Country', type: 'text', placeholder: 'RO', tip: 'The two-letter country code.', }, { key: 'geo.latitude', label: 'Latitude', type: 'text' }, { key: 'geo.longitude', label: 'Longitude', type: 'text' }, { key: 'openingHours', label: 'Opening hours', type: 'textarea', placeholder: 'Mo-Fr 09:00-18:00', tip: 'One per line, as Mo-Fr 09:00-18:00.', }, { key: 'url', label: 'Website', type: 'url' }, { key: 'sameAs', label: 'Profiles', type: 'textarea', tip: 'One URL per line.', }, ]; const article: SchemaField[] = [ { key: 'headline', label: 'Headline', type: 'text', tip: 'Empty, the post title is used. Keep it under 110 characters.', }, { key: 'description', label: 'Description', type: 'textarea', tip: 'Empty, the meta description is used.', }, { key: 'articleSection', label: 'Section', type: 'text', tip: 'The topic or category, such as "Guides".', }, { key: 'keywords', label: 'Keywords', type: 'textarea', tip: 'One per line.', }, ]; /** * The schema.org types the editor offers, the way Rank Math lists them, * with the fields Google reads for each rich result. */ /** Defaults of a post type that the page describes on its own (Titles & meta). */ export const FROM_POST_TYPES: string[] = ['Service', 'Course', 'Person']; /** Defaults of a post type that print once the page has their details. */ export const DETAIL_TYPES: string[] = [ 'Recipe', 'Event', 'JobPosting', 'VideoObject', 'SoftwareApplication', 'Book', ]; export const SCHEMA_TYPES: Record = { Article: { label: 'Article', group: 'Content', description: 'A general article. Dates, author, publisher and image come from the post.', fields: article, }, BlogPosting: { label: 'Blog post', group: 'Content', description: 'A blog post; the default for posts.', fields: article, }, NewsArticle: { label: 'News article', group: 'Content', description: 'A news story, eligible for Top Stories. Needs a real publisher and a dateline.', fields: [ ...article, { key: 'dateline', label: 'Dateline', type: 'text', placeholder: 'Cluj-Napoca, 18 September 2026', }, ], }, Recipe: { label: 'Recipe', group: 'Content', description: 'Ingredients, steps and times for the recipe rich result.', fields: [ { key: 'name', label: 'Recipe name', type: 'text', tip: 'Empty, the post title is used.', }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'prepTime', label: 'Preparation time (minutes)', type: 'number' }, { key: 'cookTime', label: 'Cooking time (minutes)', type: 'number' }, { key: 'totalTime', label: 'Total time (minutes)', type: 'number' }, { key: 'recipeYield', label: 'Yield', type: 'text', placeholder: '4 servings', }, { key: 'recipeCategory', label: 'Category', type: 'text', placeholder: 'Dessert', }, { key: 'recipeCuisine', label: 'Cuisine', type: 'text', placeholder: 'Italian', }, { key: 'nutrition.calories', label: 'Calories', type: 'text', placeholder: '270 calories', }, { key: 'recipeIngredient', label: 'Ingredients', type: 'textarea', tip: 'One per line.', }, { key: 'recipeInstructions', label: 'Steps', type: 'textarea', tip: 'One step per line.', }, ], }, Event: { label: 'Event', group: 'Content', description: 'Something happening at a time and place; shows with dates in results.', fields: [ { key: 'name', label: 'Event name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'startDate', label: 'Starts', type: 'datetime' }, { key: 'endDate', label: 'Ends', type: 'datetime' }, { key: 'eventStatus', label: 'Status', type: 'select', options: [ 'https://schema.org/EventScheduled', 'https://schema.org/EventCancelled', 'https://schema.org/EventPostponed', 'https://schema.org/EventRescheduled', 'https://schema.org/EventMovedOnline', ], }, { key: 'eventAttendanceMode', label: 'Attendance', type: 'select', options: [ 'https://schema.org/OfflineEventAttendanceMode', 'https://schema.org/OnlineEventAttendanceMode', 'https://schema.org/MixedEventAttendanceMode', ], }, { key: 'location.name', label: 'Venue', type: 'text' }, { key: 'location.address', label: 'Venue address', type: 'text' }, { key: 'organizer.name', label: 'Organizer', type: 'text' }, { key: 'performer.name', label: 'Performer', type: 'text' }, { key: 'offers.price', label: 'Ticket price', type: 'number' }, { key: 'offers.priceCurrency', label: 'Currency', type: 'text', placeholder: 'EUR', }, { key: 'offers.url', label: 'Ticket URL', type: 'url' }, ], }, Course: { label: 'Course', group: 'Content', description: 'An educational course with its provider.', fields: [ { key: 'name', label: 'Course name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'provider.name', label: 'Provider', type: 'text' }, { key: 'provider.url', label: 'Provider URL', type: 'url' }, ], }, JobPosting: { label: 'Job posting', group: 'Business', description: 'A job opening, eligible for Google for Jobs.', fields: [ { key: 'title', label: 'Job title', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'datePosted', label: 'Posted on', type: 'date' }, { key: 'validThrough', label: 'Valid through', type: 'datetime' }, { key: 'employmentType', label: 'Employment type', type: 'select', options: [ 'FULL_TIME', 'PART_TIME', 'CONTRACTOR', 'TEMPORARY', 'INTERN', 'VOLUNTEER', 'PER_DIEM', 'OTHER', ], }, { key: 'hiringOrganization.name', label: 'Company', type: 'text' }, { key: 'hiringOrganization.sameAs', label: 'Company URL', type: 'url' }, { key: 'jobLocation.address', label: 'Location', type: 'text', placeholder: 'City, Country', }, { key: 'baseSalary.currency', label: 'Salary currency', type: 'text', placeholder: 'EUR', }, { key: 'baseSalary.value.value', label: 'Salary', type: 'number' }, { key: 'baseSalary.value.unitText', label: 'Salary per', type: 'select', options: ['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'], }, ], }, Book: { label: 'Book', group: 'Content', description: 'A book with its author and edition.', fields: [ { key: 'name', label: 'Title', type: 'text' }, { key: 'author.name', label: 'Author', type: 'text' }, { key: 'isbn', label: 'ISBN', type: 'text' }, { key: 'bookEdition', label: 'Edition', type: 'text' }, { key: 'bookFormat', label: 'Format', type: 'select', options: [ 'https://schema.org/Hardcover', 'https://schema.org/Paperback', 'https://schema.org/EBook', 'https://schema.org/AudiobookFormat', ], }, { key: 'datePublished', label: 'Published on', type: 'date' }, { key: 'publisher.name', label: 'Publisher', type: 'text' }, ], }, SoftwareApplication: { label: 'Software / app', group: 'Other', description: 'An app or program, with its platform and price.', fields: [ { key: 'name', label: 'Name', type: 'text' }, { key: 'operatingSystem', label: 'Operating system', type: 'text', placeholder: 'Windows, macOS, Android', }, { key: 'applicationCategory', label: 'Category', type: 'select', options: [ 'GameApplication', 'SocialNetworkingApplication', 'TravelApplication', 'ShoppingApplication', 'SportsApplication', 'LifestyleApplication', 'BusinessApplication', 'DesignApplication', 'DeveloperApplication', 'DriverApplication', 'EducationalApplication', 'HealthApplication', 'FinanceApplication', 'SecurityApplication', 'BrowserApplication', 'CommunicationApplication', 'DesktopEnhancementApplication', 'EntertainmentApplication', 'MultimediaApplication', 'HomeApplication', 'UtilitiesApplication', 'ReferenceApplication', ], }, { key: 'offers.price', label: 'Price', type: 'number', tip: '0 for free.', }, { key: 'offers.priceCurrency', label: 'Currency', type: 'text', placeholder: 'USD', }, { key: 'aggregateRating.ratingValue', label: 'Rating', type: 'number', placeholder: '4.5', }, { key: 'aggregateRating.ratingCount', label: 'Rating count', type: 'number', }, ], }, VideoObject: { label: 'Video', group: 'Media', description: 'A video on the page, for video results. Embedded YouTube and Vimeo videos are already in the video sitemap.', fields: [ { key: 'name', label: 'Title', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'thumbnailUrl', label: 'Thumbnail URL', type: 'url' }, { key: 'uploadDate', label: 'Uploaded on', type: 'date' }, { key: 'duration', label: 'Duration', type: 'text', placeholder: 'PT2M30S', tip: 'ISO 8601: PT2M30S is two minutes thirty seconds.', }, { key: 'contentUrl', label: 'Video file URL', type: 'url' }, { key: 'embedUrl', label: 'Embed URL', type: 'url' }, ], }, Service: { label: 'Service', group: 'Business', description: 'A service offered by the business.', fields: [ { key: 'name', label: 'Service name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'serviceType', label: 'Type', type: 'text', placeholder: 'Plumbing', }, { key: 'provider.name', label: 'Provider', type: 'text' }, { key: 'areaServed', label: 'Area served', type: 'text' }, { key: 'offers.price', label: 'Price', type: 'number' }, { key: 'offers.priceCurrency', label: 'Currency', type: 'text' }, ], }, Review: { label: 'Review', group: 'Content', description: 'Your review of a product, book, place or business, with a star rating.', fields: [ { key: 'itemReviewed.type', label: 'What is reviewed', type: 'select', options: [ 'Product', 'Book', 'Movie', 'LocalBusiness', 'Restaurant', 'SoftwareApplication', 'Course', 'Event', 'Organization', ], }, { key: 'itemReviewed.name', label: 'Its name', type: 'text' }, { key: 'reviewRating.ratingValue', label: 'Rating (1 to 5)', type: 'number', }, { key: 'reviewBody', label: 'Review text', type: 'textarea' }, ], }, Person: { label: 'Person', group: 'Other', description: 'A person the page is about.', fields: [ { key: 'name', label: 'Name', type: 'text' }, { key: 'jobTitle', label: 'Job title', type: 'text' }, { key: 'url', label: 'Website', type: 'url' }, { key: 'email', label: 'Email', type: 'text' }, { key: 'telephone', label: 'Phone', type: 'text' }, { key: 'sameAs', label: 'Profiles', type: 'textarea', tip: 'One URL per line: LinkedIn, X, Wikipedia…', }, ], }, Organization: { label: 'Organization', group: 'Business', description: 'An organization the page is about, other than the site owner.', fields: [ { key: 'name', label: 'Name', type: 'text' }, { key: 'url', label: 'Website', type: 'url' }, { key: 'logo', label: 'Logo URL', type: 'url' }, { key: 'telephone', label: 'Phone', type: 'text' }, { key: 'email', label: 'Email', type: 'text' }, { key: 'sameAs', label: 'Profiles', type: 'textarea', tip: 'One URL per line.', }, ], }, Dataset: { label: 'Dataset', group: 'Other', description: 'A downloadable dataset, for Google Dataset Search.', fields: [ { key: 'name', label: 'Name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea', tip: 'At least 50 characters.', }, { key: 'url', label: 'Landing page', type: 'url' }, { key: 'license', label: 'License URL', type: 'url' }, { key: 'keywords', label: 'Keywords', type: 'textarea', tip: 'One per line.', }, ], }, ClaimReview: { label: 'Fact check', group: 'Content', description: 'A fact check of a claim, with your verdict.', fields: [ { key: 'claimReviewed', label: 'The claim', type: 'text' }, { key: 'reviewRating.ratingValue', label: 'Rating (1 false to 5 true)', type: 'number', }, { key: 'reviewRating.alternateName', label: 'Verdict', type: 'text', placeholder: 'False', }, { key: 'author.name', label: 'Fact checker', type: 'text' }, { key: 'url', label: 'Fact check URL', type: 'url' }, ], }, PodcastEpisode: { label: 'Podcast episode', group: 'Media', description: 'An episode with its audio file and series.', fields: [ { key: 'name', label: 'Episode title', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'associatedMedia.contentUrl', label: 'Audio file URL', type: 'url', }, { key: 'timeRequired', label: 'Duration (minutes)', type: 'number' }, { key: 'datePublished', label: 'Published on', type: 'date' }, { key: 'partOfSeries.name', label: 'Series', type: 'text' }, { key: 'partOfSeries.url', label: 'Series URL', type: 'url' }, ], }, Product: { label: 'Product', group: 'Business', description: 'A product with its price and availability. On a WooCommerce product the catalogue writes one already, and this one takes its place.', fields: [ { key: 'name', label: 'Name', type: 'text', tip: 'Empty, the page title is used.', }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'brand.name', label: 'Brand', type: 'text' }, { key: 'sku', label: 'SKU', type: 'text' }, { key: 'gtin', label: 'GTIN', type: 'text', tip: 'The barcode, digits only: GTIN-8, 12, 13 or 14.', }, { key: 'mpn', label: 'MPN', type: 'text' }, { key: 'offers.price', label: 'Price', type: 'number', placeholder: '49.00', }, { key: 'offers.priceCurrency', label: 'Currency', type: 'text', placeholder: 'EUR', }, { key: 'offers.availability', label: 'Availability', type: 'select', options: [ 'https://schema.org/InStock', 'https://schema.org/OutOfStock', 'https://schema.org/PreOrder', 'https://schema.org/BackOrder', ], }, { key: 'offers.itemCondition', label: 'Condition', type: 'select', options: [ 'https://schema.org/NewCondition', 'https://schema.org/RefurbishedCondition', 'https://schema.org/UsedCondition', ], }, { key: 'offers.priceValidUntil', label: 'Price valid until', type: 'date', }, { key: 'offers.url', label: 'Buy URL', type: 'url' }, { key: 'aggregateRating.ratingValue', label: 'Rating', type: 'number' }, { key: 'aggregateRating.reviewCount', label: 'Reviews', type: 'number' }, ], }, LocalBusiness: { label: 'Local business', group: 'Business', description: 'A business with an address and opening hours. Pick the kind it is; the site-wide one lives in Local SEO.', fields: [ { key: 'businessType', label: 'Business type', type: 'select', optionsFrom: 'businessTypes', tip: 'The schema.org type printed for it. The more exact, the better: Dentist over LocalBusiness.', }, ...business, ], }, Restaurant: { label: 'Restaurant', group: 'Business', description: 'A place that serves food, with its cuisine and its menu.', fields: [ ...business, { key: 'servesCuisine', label: 'Cuisine', type: 'textarea', placeholder: 'Italian', tip: 'One per line.', }, { key: 'menu', label: 'Menu URL', type: 'url' }, { key: 'acceptsReservations', label: 'Reservations', type: 'select', options: ['True', 'False'], }, ], }, MusicAlbum: { label: 'Music album', group: 'Media', description: 'An album or a single, with the artist behind it.', fields: [ { key: 'name', label: 'Album', type: 'text' }, { key: 'byArtist.name', label: 'Artist', type: 'text' }, { key: 'numTracks', label: 'Tracks', type: 'number' }, { key: 'genre', label: 'Genre', type: 'text' }, { key: 'datePublished', label: 'Released on', type: 'date' }, { key: 'albumReleaseType', label: 'Release type', type: 'select', options: [ 'https://schema.org/AlbumRelease', 'https://schema.org/SingleRelease', 'https://schema.org/EPRelease', 'https://schema.org/BroadcastRelease', ], }, { key: 'url', label: 'Listen at', type: 'url' }, ], }, MusicGroup: { label: 'Band or musician', group: 'Media', description: 'The artist behind the music, with their profiles.', fields: [ { key: 'name', label: 'Name', type: 'text' }, { key: 'description', label: 'Description', type: 'textarea' }, { key: 'genre', label: 'Genre', type: 'text' }, { key: 'foundingDate', label: 'Active since', type: 'date' }, { key: 'url', label: 'Website', type: 'url' }, { key: 'sameAs', label: 'Profiles', type: 'textarea', tip: 'One URL per line.', }, ], }, ItemList: { label: 'Carousel', group: 'Other', description: 'A list of pages Google may show as a carousel, in the order given here.', fields: [ { key: 'name', label: 'List name', type: 'text' }, { key: 'itemListElement', label: 'Items', type: 'textarea', placeholder: 'Hoodies | https://example.com/hoodies/', tip: 'One per line: a name, a vertical bar, the URL. A URL on its own works too.', }, ], }, Speakable: { label: 'Speakable', group: 'Other', description: 'The parts a voice assistant may read aloud. It marks the page itself instead of adding an object of its own.', fields: [ { key: 'cssSelector', label: 'CSS selectors', type: 'textarea', placeholder: '.entry-title', tip: 'One per line.', }, { key: 'xpath', label: 'XPath', type: 'textarea', placeholder: '/html/head/title', tip: 'One per line, instead of the selectors above rather than next to them.', }, ], }, }; /** * What Google needs for the rich result of a type, past what the page fills * in itself (the name from the title, the image from the page). A list * inside is a choice: one of them is enough. */ export const REQUIRED_FIELDS: Record = { Event: ['startDate', ['location.name', 'location.address']], JobPosting: [ 'title', 'description', 'datePosted', 'hiringOrganization.name', 'jobLocation.address', ], VideoObject: ['thumbnailUrl', 'uploadDate'], SoftwareApplication: ['offers.price', 'aggregateRating.ratingValue'], Review: ['itemReviewed.name', 'reviewRating.ratingValue'], ClaimReview: ['claimReviewed', 'reviewRating.alternateName'], Course: ['description', 'provider.name'], Dataset: ['description'], LocalBusiness: ['address.streetAddress', 'address.addressLocality'], Restaurant: ['address.streetAddress', 'address.addressLocality'], }; /** * The required fields an entry leaves empty, by their labels: an Event with * only a name printed with no date and no place, and Search Console called * it invalid. */ export const missingRequired = (entry: SchemaEntry): string[] => { const type = SCHEMA_TYPES[entry.type]; const labelOf = (key: string): string => type?.fields.find(field => field.key === key)?.label ?? key; const filled = (key: string): boolean => (entry.fields[key] ?? '').trim() !== ''; return (REQUIRED_FIELDS[entry.type] ?? []) .filter(need => (Array.isArray(need) ? !need.some(filled) : !filled(need))) .map(need => Array.isArray(need) ? need.map(labelOf).join(' or ') : labelOf(need) ); }; /** Whether a field is one Google asks for, alone or as one of a choice. */ export const isRequired = (type: string, key: string): boolean => (REQUIRED_FIELDS[type] ?? []).some(need => Array.isArray(need) ? need.includes(key) : need === key ); export const parseEntries = (raw: unknown): SchemaEntry[] => { if (typeof raw !== 'string' || raw.trim() === '') { return []; } try { const decoded = JSON.parse(raw); return Array.isArray(decoded) ? decoded .filter(item => item && typeof item.type === 'string') .map(item => ({ type: String(item.type), fields: { ...(item.fields ?? {}) }, })) : []; } catch { return []; } }; export const serializeEntries = (entries: SchemaEntry[]): string => entries.length === 0 ? '' : JSON.stringify(entries);