Describe your business, people, services and pages once. Get one connected JSON-LD graph where every entity has a stable @id and points to the others, so search engines and AI systems read a single clear story about who you are.
Add the code to the page head or a custom code block. Every value must match what visitors can see on the page.
Check schema you already have
Paste a page’s full HTML source (Ctrl+U or Cmd+Option+U in your browser) or a JSON-LD block. The checker finds JSON-LD, Microdata and RDFa, then flags errors, retired types and markup Google ignores.
/g,'>').replace(/"/g,'"'); }
/* ---------- Vocabulary ---------- */
var SUBTYPES = ['Organization','LocalBusiness','LegalService','Attorney','ProfessionalService','AccountingService','FinancialService','InsuranceAgency','RealEstateAgent','MedicalClinic','Dentist','HealthAndBeautyBusiness','DaySpa','HomeAndConstructionBusiness','Plumber','Electrician','HVACBusiness','RoofingContractor','GeneralContractor','HousePainter','Locksmith','MovingCompany','AutoRepair','Restaurant','Store'];
var LOCAL = SUBTYPES.filter(function(s){ return s !== 'Organization'; });
var DEPRECATED = {
'HowTo':'HowTo rich results were removed in September 2023.',
'SpecialAnnouncement':'SpecialAnnouncement was retired in 2025.',
'Course':null,
'EstimatedSalary':'Estimated salary rich results were retired in 2025.',
'LearningVideo':'Learning video rich results were retired in 2025.',
'ClaimReview':'ClaimReview rich results were retired in 2025.',
'Vehicle':null,
'Car':null
};
var DEF = {
business: {label:'Business', desc:'The firm or company itself. This is the hub everything else connects to.', fields:[
{k:'subtype',l:'Business type',t:'select',opts:SUBTYPES,help:'Pick the most specific type. LegalService suits most law firms.'},
{k:'name',l:'Business name',t:'text',req:1,ph:'Smith & Lee Law'},
{k:'alternateName',l:'Other names',t:'text',ph:'Smith Lee LLP'},
{k:'url',l:'Homepage URL',t:'url',req:1,ph:'https://smithlee.ca/'},
{k:'logo',l:'Logo URL',t:'url',rec:1,ph:'https://smithlee.ca/logo.png'},
{k:'image',l:'Photo URL',t:'url',ph:'https://smithlee.ca/office.jpg'},
{k:'description',l:'Description',t:'textarea',rec:1,ph:'One or two plain sentences that match your homepage.'},
{k:'telephone',l:'Phone',t:'tel',rec:1,ph:'+1-416-555-0123'},
{k:'email',l:'Email',t:'email',ph:'hello@smithlee.ca'},
{k:'priceRange',l:'Price range',t:'text',ph:'$$'},
{k:'street',l:'Street address',t:'text',grp:'Address and hours',ph:'100 King St W, Suite 500'},
{k:'city',l:'City',t:'text',ph:'Toronto',half:1},
{k:'region',l:'Province or state',t:'text',ph:'ON',half:1},
{k:'postal',l:'Postal or ZIP code',t:'text',ph:'M5X 1A9',half:1},
{k:'country',l:'Country code',t:'text',ph:'CA',half:1},
{k:'lat',l:'Latitude',t:'text',ph:'43.6487',half:1},
{k:'lng',l:'Longitude',t:'text',ph:'-79.3817',half:1},
{k:'hasMap',l:'Google Maps link',t:'url',ph:'https://maps.google.com/?cid=...'},
{k:'hours',l:'Opening hours',t:'hours',ph:'Mo-Fr 09:00-17:00\nSa 10:00-14:00',help:'One rule per line using Mo Tu We Th Fr Sa Su, or write 24/7.'},
{k:'areaServed',l:'Areas served',t:'pairs',grp:'Entity connections',ph:'Toronto | https://en.wikipedia.org/wiki/Toronto\nMississauga | https://en.wikipedia.org/wiki/Mississauga',help:'One place per line. Add a Wikipedia or Wikidata link after | to pin the exact place.'},
{k:'knowsAbout',l:'Topics of expertise',t:'pairs',ph:'Personal injury | https://en.wikipedia.org/wiki/Personal_injury',help:'Practice areas or specialties, each with an optional Wikipedia or Wikidata link.'},
{k:'sameAs',l:'Official profiles (sameAs)',t:'list',rec:1,ph:'https://www.linkedin.com/company/...\nhttps://www.facebook.com/...',help:'One URL per line: Google Business Profile, LinkedIn, social, Wikidata, directories you control.'},
{k:'founder',l:'Founder',t:'ref',to:['person']},
{k:'foundingDate',l:'Founding date',t:'date',ph:'2012-05-01'},
{k:'parent',l:'Parent organization',t:'ref',to:['business'],help:'For extra locations: point to the main firm.'}
]},
person: {label:'Person', desc:'A lawyer, practitioner, founder or author. Strong person entities build trust signals.', fields:[
{k:'name',l:'Full name',t:'text',req:1,ph:'Jane Smith'},
{k:'jobTitle',l:'Job title',t:'text',rec:1,ph:'Personal Injury Lawyer'},
{k:'url',l:'Bio page URL',t:'url',rec:1,ph:'https://smithlee.ca/team/jane-smith/'},
{k:'image',l:'Headshot URL',t:'url',ph:'https://smithlee.ca/jane.jpg'},
{k:'description',l:'Short bio',t:'textarea'},
{k:'worksFor',l:'Works for',t:'ref',to:['business'],rec:1},
{k:'sameAs',l:'Official profiles (sameAs)',t:'list',rec:1,grp:'Entity connections',ph:'https://www.linkedin.com/in/...',help:'LinkedIn, law society or regulator listing, speaker pages, Wikidata.'},
{k:'knowsAbout',l:'Areas of expertise',t:'pairs',ph:'Tort law | https://en.wikipedia.org/wiki/Tort'},
{k:'alumniOf',l:'Schools',t:'pairs',ph:'Osgoode Hall Law School | https://en.wikipedia.org/wiki/Osgoode_Hall_Law_School'},
{k:'memberOf',l:'Memberships',t:'pairs',ph:'Law Society of Ontario | https://lso.ca/'},
{k:'credentials',l:'Credentials',t:'list',ph:'Certified Specialist in Civil Litigation',help:'One per line.'}
]},
website: {label:'Website', desc:'The site as a whole. Add it once, sitewide.', fields:[
{k:'name',l:'Site name',t:'text',req:1,ph:'Smith & Lee Law'},
{k:'alternateName',l:'Alternate name',t:'text',ph:'SmithLee'},
{k:'url',l:'Site URL',t:'url',req:1,ph:'https://smithlee.ca/'},
{k:'publisher',l:'Publisher',t:'ref',to:['business','person'],rec:1},
{k:'inLanguage',l:'Language',t:'text',ph:'en-CA'}
]},
webpage: {label:'Web page', desc:'One specific page. Use it on the page the code goes on.', fields:[
{k:'subtype',l:'Page type',t:'select',opts:['WebPage','AboutPage','ContactPage','CollectionPage','ProfilePage','ItemPage']},
{k:'name',l:'Page title',t:'text',req:1,ph:'Toronto Personal Injury Lawyers'},
{k:'url',l:'Page URL',t:'url',req:1,ph:'https://smithlee.ca/personal-injury/'},
{k:'description',l:'Page description',t:'textarea',rec:1},
{k:'isPartOf',l:'Part of website',t:'ref',to:['website'],rec:1},
{k:'about',l:'Main subject',t:'ref',to:['business','service','person'],rec:1,help:'What the page is mainly about.'},
{k:'breadcrumb',l:'Breadcrumbs',t:'ref',to:['breadcrumb']},
{k:'primaryImage',l:'Main image URL',t:'url'},
{k:'datePublished',l:'Published',t:'date',half:1},
{k:'dateModified',l:'Last updated',t:'date',half:1},
{k:'mentions',l:'Entities mentioned',t:'pairs',grp:'Entity connections',ph:'Ontario Superior Court of Justice | https://en.wikipedia.org/wiki/Ontario_Superior_Court_of_Justice'},
{k:'inLanguage',l:'Language',t:'text',ph:'en-CA'}
]},
service: {label:'Service', desc:'A service or practice area the business provides.', fields:[
{k:'name',l:'Service name',t:'text',req:1,ph:'Personal injury representation'},
{k:'serviceType',l:'Service type',t:'text',rec:1,ph:'Personal injury law'},
{k:'description',l:'Description',t:'textarea',rec:1},
{k:'url',l:'Service page URL',t:'url',ph:'https://smithlee.ca/personal-injury/'},
{k:'provider',l:'Provided by',t:'ref',to:['business','person'],req:1},
{k:'additionalType',l:'Known entity link',t:'url',grp:'Entity connections',ph:'https://en.wikipedia.org/wiki/Personal_injury',help:'A Wikipedia or Wikidata URL for this kind of service.'},
{k:'areaServed',l:'Areas served',t:'pairs',ph:'Toronto | https://en.wikipedia.org/wiki/Toronto'},
{k:'price',l:'Starting price',t:'text',grp:'Pricing',half:1,ph:'0'},
{k:'currency',l:'Currency',t:'text',half:1,ph:'CAD'}
]},
article: {label:'Article', desc:'A blog post, guide or news item.', fields:[
{k:'subtype',l:'Article type',t:'select',opts:['BlogPosting','Article','NewsArticle']},
{k:'headline',l:'Headline',t:'text',req:1,ph:'What to do after a car accident in Ontario'},
{k:'url',l:'Article URL',t:'url',req:1},
{k:'image',l:'Featured image URL',t:'url',rec:1},
{k:'description',l:'Summary',t:'textarea'},
{k:'datePublished',l:'Published',t:'date',rec:1,half:1},
{k:'dateModified',l:'Last updated',t:'date',rec:1,half:1},
{k:'author',l:'Author',t:'ref',to:['person','business'],rec:1},
{k:'publisher',l:'Publisher',t:'ref',to:['business'],rec:1},
{k:'mainEntityOfPage',l:'Page it sits on',t:'ref',to:['webpage']},
{k:'about',l:'Main topics',t:'pairs',grp:'Entity connections',ph:'Traffic collision | https://en.wikipedia.org/wiki/Traffic_collision'},
{k:'mentions',l:'Entities mentioned',t:'pairs'}
]},
breadcrumb: {label:'Breadcrumbs', desc:'The trail from Home to the current page.', fields:[
{k:'items',l:'Trail',t:'pairs',req:1,ph:'Home | https://smithlee.ca/\nPractice areas | https://smithlee.ca/practice-areas/\nPersonal injury | https://smithlee.ca/personal-injury/',help:'In order, one step per line: Name | URL.'}
]},
video: {label:'Video', desc:'A video embedded on the page.', fields:[
{k:'name',l:'Video title',t:'text',req:1},
{k:'description',l:'Description',t:'textarea',req:1},
{k:'thumbnailUrl',l:'Thumbnail URL',t:'url',req:1},
{k:'uploadDate',l:'Upload date',t:'date',req:1,half:1},
{k:'duration',l:'Duration',t:'text',ph:'PT2M30S',half:1,help:'ISO 8601, for example PT2M30S.'},
{k:'embedUrl',l:'Embed URL',t:'url',rec:1,ph:'https://www.youtube.com/embed/...'},
{k:'contentUrl',l:'Video file URL',t:'url'}
]},
faq: {label:'FAQ', desc:'Genuine questions and answers shown on the page.', fields:[
{k:'items',l:'Questions and answers',t:'qa',req:1,ph:'How much does a consultation cost?\nYour first consultation is free.\n\nDo you work on contingency?\nYes, for most personal injury cases.',help:'Question on the first line, answer below it. Leave a blank line between pairs.'}
]}
};
var ORDER = ['business','person','website','webpage','service','article','breadcrumb','video','faq'];
var REFLABEL = {founder:'founder',parent:'parentOrganization',worksFor:'worksFor',publisher:'publisher',isPartOf:'isPartOf',about:'about',breadcrumb:'breadcrumb',provider:'provider',author:'author',mainEntityOfPage:'mainEntityOfPage'};
/* ---------- State ---------- */
var KEY = 'gmsg-state-v1';
var state = null, uidN = 1;
function uid(){ return 'e' + (uidN++); }
function mk(type, f){ return {uid:uid(), type:type, f:f || {}}; }
function link(a, k, b){ a.f[k] = b.uid; }
var PRESETS = {
law: {label:'Law firm', make:function(){
var b=mk('business',{subtype:'LegalService'}), p=mk('person'), w=mk('website'), pg=mk('webpage'), s=mk('service'), bc=mk('breadcrumb');
link(b,'founder',p); link(p,'worksFor',b); link(w,'publisher',b); link(pg,'isPartOf',w); link(pg,'about',s); link(pg,'breadcrumb',bc); link(s,'provider',b);
return [b,p,w,pg,s,bc]; }},
immigration: {label:'Immigration consultant', make:function(){
var b=mk('business',{subtype:'ProfessionalService'}), p=mk('person'), w=mk('website'), pg=mk('webpage'), s=mk('service'), bc=mk('breadcrumb');
link(b,'founder',p); link(p,'worksFor',b); link(w,'publisher',b); link(pg,'isPartOf',w); link(pg,'about',s); link(pg,'breadcrumb',bc); link(s,'provider',b);
return [b,p,w,pg,s,bc]; }},
clinic: {label:'Med spa or clinic', make:function(){
var b=mk('business',{subtype:'MedicalClinic'}), p=mk('person'), w=mk('website'), pg=mk('webpage'), s=mk('service');
link(p,'worksFor',b); link(w,'publisher',b); link(pg,'isPartOf',w); link(pg,'about',s); link(s,'provider',b);
return [b,p,w,pg,s]; }},
home: {label:'Home services', make:function(){
var b=mk('business',{subtype:'HomeAndConstructionBusiness'}), w=mk('website'), pg=mk('webpage'), s=mk('service'), bc=mk('breadcrumb');
link(w,'publisher',b); link(pg,'isPartOf',w); link(pg,'about',s); link(pg,'breadcrumb',bc); link(s,'provider',b);
return [b,w,pg,s,bc]; }},
local: {label:'Local business, homepage', make:function(){
var b=mk('business',{subtype:'LocalBusiness'}), w=mk('website'), pg=mk('webpage');
link(w,'publisher',b); link(pg,'isPartOf',w); link(pg,'about',b);
return [b,w,pg]; }},
article: {label:'Blog article', make:function(){
var b=mk('business',{subtype:'Organization'}), p=mk('person'), w=mk('website'), pg=mk('webpage'), a=mk('article',{subtype:'BlogPosting'}), bc=mk('breadcrumb');
link(p,'worksFor',b); link(w,'publisher',b); link(pg,'isPartOf',w); link(pg,'breadcrumb',bc); link(a,'author',p); link(a,'publisher',b); link(a,'mainEntityOfPage',pg);
return [b,p,w,pg,a,bc]; }},
blank: {label:'Start blank', make:function(){ return [mk('business',{subtype:'Organization'})]; }}
};
function save(){ try { localStorage.setItem(KEY, JSON.stringify(state)); } catch(e){} }
function load(){
try {
var raw = localStorage.getItem(KEY);
if (raw){
var s = JSON.parse(raw);
if (s && Array.isArray(s.entities)){
s.entities = s.entities.filter(function(e){ return e && DEF[e.type]; });
s.entities.forEach(function(e){ e.f = e.f || {}; var n = parseInt(String(e.uid).slice(1),10); if (n >= uidN) uidN = n + 1; });
return s;
}
}
} catch(e){}
var ents = PRESETS.law.make();
return {site:'', preset:'law', entities:ents, sel:ents[0].uid, wrap:true};
}
/* ---------- Helpers ---------- */
function v(e,k){ return (e.f[k] == null ? '' : String(e.f[k])).trim(); }
function lines(s){ return String(s||'').split('\n').map(function(x){ return x.trim(); }).filter(Boolean); }
function isAbs(u){ return /^https?:\/\/[^\s\/$.?#][^\s]*$/i.test(u); }
function isDate(d){ return /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(d); }
function slug(s){ return String(s||'').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/&/g,' and ').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,48) || 'item'; }
function pairs(s){
return lines(s).map(function(l){
var i = l.indexOf('|');
if (i === -1) return isAbs(l) ? {name:'', url:l} : {name:l, url:''};
return {name:l.slice(0,i).trim(), url:l.slice(i+1).trim()};
});
}
function thing(p, type, plainOk){
if (!p.url && plainOk) return p.name;
var o = {'@type':type};
if (p.name) o.name = p.name;
if (p.url) o.sameAs = p.url;
return o;
}
var DAYS = {mo:'Monday',tu:'Tuesday',we:'Wednesday',th:'Thursday',fr:'Friday',sa:'Saturday',su:'Sunday'};
var DORD = ['mo','tu','we','th','fr','sa','su'];
function pad(t){ var p = t.split(':'); return (p[0].length === 1 ? '0' + p[0] : p[0]) + ':' + p[1]; }
function hours(s){
var specs = [], bad = [];
lines(s).forEach(function(l){
if (/^24\s*\/\s*7$/i.test(l)){ specs.push({'@type':'OpeningHoursSpecification',dayOfWeek:DORD.map(function(d){return DAYS[d];}),opens:'00:00',closes:'23:59'}); return; }
var m = l.match(/^([A-Za-z ,\-]+?)\s+(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/);
if (!m){ bad.push(l); return; }
var days = [], ok = true;
m[1].split(',').forEach(function(tok){
tok = tok.trim().toLowerCase(); if (!tok) return;
var r = tok.split('-').map(function(x){ return x.trim().slice(0,2); });
if (r.length === 1){ if (DAYS[r[0]]) days.push(DAYS[r[0]]); else ok = false; }
else { var a = DORD.indexOf(r[0]), b = DORD.indexOf(r[1]); if (a < 0 || b < 0){ ok = false; return; } for (var i=a; ; i=(i+1)%7){ days.push(DAYS[DORD[i]]); if (i === b) break; } }
});
if (!ok || !days.length){ bad.push(l); return; }
specs.push({'@type':'OpeningHoursSpecification',dayOfWeek:days,opens:pad(m[2]),closes:pad(m[3])});
});
return {specs:specs, bad:bad};
}
function qa(s){
return String(s||'').split(/\n\s*\n/).map(function(b){
var ls = lines(b); if (!ls.length) return null;
return {q:ls[0].replace(/^q\s*[:.]\s*/i,''), a:ls.slice(1).join(' ').replace(/^a\s*[:.]\s*/i,'')};
}).filter(Boolean);
}
function clean(x){
if (Array.isArray(x)){ var a = x.map(clean).filter(function(y){ return y !== undefined; }); return a.length ? a : undefined; }
if (x && typeof x === 'object'){
var o = {}, n = 0;
Object.keys(x).forEach(function(k){ var c = clean(x[k]); if (c !== undefined){ o[k] = c; if (k !== '@type') n++; } });
return n ? o : undefined;
}
if (typeof x === 'string'){ var t = x.trim(); return t ? t : undefined; }
return x;
}
function byUid(id){ for (var i=0; i 1 ? ' ' + (state.entities.filter(function(x){ return x.type === e.type; }).indexOf(e) + 1) : '');
}
function schemaType(e){
if (e.type === 'business') return v(e,'subtype') || 'Organization';
if (e.type === 'webpage') return v(e,'subtype') || 'WebPage';
if (e.type === 'article') return v(e,'subtype') || 'BlogPosting';
return {person:'Person',website:'WebSite',service:'Service',breadcrumb:'BreadcrumbList',video:'VideoObject',faq:'FAQPage'}[e.type];
}
function normSite(){
var s = String(state.site||'').trim();
if (!s) return '';
if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
return s.replace(/\/+$/,'');
}
function siteHost(){ try { return new URL(normSite()).hostname.replace(/^www\./,''); } catch(e){ return ''; } }
/* ---------- IDs and graph ---------- */
function computeIds(){
var base = normSite() || 'https://example.com', ids = {}, used = {}, bizN = 0;
function pageOf(bc){
var pg = state.entities.filter(function(e){ return e.type === 'webpage' && e.f.breadcrumb === bc.uid && v(e,'url'); })[0]
|| state.entities.filter(function(e){ return e.type === 'webpage' && v(e,'url'); })[0];
return pg ? v(pg,'url') : base + '/';
}
state.entities.forEach(function(e){
var id, nm = slug(dname(e));
switch (e.type){
case 'business': id = (bizN++ === 0 && !e.f.parent) ? base + '/#organization' : base + '/#location-' + nm; break;
case 'website': id = base + '/#website'; break;
case 'webpage': id = (v(e,'url') || base + '/') + '#webpage'; break;
case 'person': id = base + '/#person-' + nm; break;
case 'service': id = base + '/#service-' + nm; break;
case 'article': id = (v(e,'url') || base + '/') + '#article'; break;
case 'breadcrumb': id = pageOf(e) + '#breadcrumb'; break;
case 'video': id = base + '/#video-' + nm; break;
case 'faq': id = pageOf(e) + '#faq'; break;
}
id = id.replace(/#.*#/, '#');
var final = id, n = 2;
while (used[final]) final = id + '-' + (n++);
used[final] = 1; ids[e.uid] = final;
});
return ids;
}
function build(e, ids){
function ref(k){ var t = byUid(e.f[k]); return t ? {'@id':ids[t.uid]} : undefined; }
var n = {'@type':schemaType(e), '@id':ids[e.uid]};
var f = function(k){ return v(e,k); };
if (e.type === 'business'){
var local = LOCAL.indexOf(n['@type']) > -1, hr = hours(f('hours'));
Object.assign(n, {
name:f('name'), alternateName:f('alternateName'), url:f('url'),
logo: f('logo') ? {'@type':'ImageObject','@id':(normSite()||'https://example.com') + '/#logo',url:f('logo'),contentUrl:f('logo')} : undefined,
image:f('image') || (local && f('logo') ? f('logo') : undefined), description:f('description'), telephone:f('telephone'), email:f('email'),
priceRange: local ? f('priceRange') : undefined,
address:{'@type':'PostalAddress',streetAddress:f('street'),addressLocality:f('city'),addressRegion:f('region'),postalCode:f('postal'),addressCountry:f('country').toUpperCase()},
geo: (f('lat') && f('lng') && !isNaN(+f('lat')) && !isNaN(+f('lng'))) ? {'@type':'GeoCoordinates',latitude:+f('lat'),longitude:+f('lng')} : undefined,
hasMap:f('hasMap'), openingHoursSpecification:hr.specs,
areaServed:pairs(f('areaServed')).map(function(p){ return thing(p,'AdministrativeArea'); }),
knowsAbout:pairs(f('knowsAbout')).map(function(p){ return thing(p,'Thing',true); }),
sameAs:lines(f('sameAs')), founder:ref('founder'), foundingDate:f('foundingDate'), parentOrganization:ref('parent')
});
} else if (e.type === 'person'){
Object.assign(n, {name:f('name'), jobTitle:f('jobTitle'), url:f('url'), image:f('image'), description:f('description'), worksFor:ref('worksFor'),
sameAs:lines(f('sameAs')),
knowsAbout:pairs(f('knowsAbout')).map(function(p){ return thing(p,'Thing',true); }),
alumniOf:pairs(f('alumniOf')).map(function(p){ return thing(p,'EducationalOrganization'); }),
memberOf:pairs(f('memberOf')).map(function(p){ return thing(p,'Organization'); }),
hasCredential:lines(f('credentials')).map(function(c){ return {'@type':'EducationalOccupationalCredential',name:c}; })
});
} else if (e.type === 'website'){
Object.assign(n, {url:f('url'), name:f('name'), alternateName:f('alternateName'), publisher:ref('publisher'), inLanguage:f('inLanguage')});
} else if (e.type === 'webpage'){
Object.assign(n, {url:f('url'), name:f('name'), description:f('description'), isPartOf:ref('isPartOf'), about:ref('about'), breadcrumb:ref('breadcrumb'),
primaryImageOfPage: f('primaryImage') ? {'@type':'ImageObject',url:f('primaryImage')} : undefined,
datePublished:f('datePublished'), dateModified:f('dateModified'),
mentions:pairs(f('mentions')).map(function(p){ return thing(p,'Thing'); }), inLanguage:f('inLanguage')});
} else if (e.type === 'service'){
Object.assign(n, {name:f('name'), serviceType:f('serviceType'), description:f('description'), url:f('url'), provider:ref('provider'), additionalType:f('additionalType'),
areaServed:pairs(f('areaServed')).map(function(p){ return thing(p,'AdministrativeArea'); }),
offers: f('price') ? {'@type':'Offer',price:f('price'),priceCurrency:f('currency').toUpperCase()} : undefined});
} else if (e.type === 'article'){
Object.assign(n, {headline:f('headline'), url:f('url'), image:f('image'), description:f('description'), datePublished:f('datePublished'), dateModified:f('dateModified'),
author:ref('author'), publisher:ref('publisher'), mainEntityOfPage:ref('mainEntityOfPage'),
about:pairs(f('about')).map(function(p){ return thing(p,'Thing'); }), mentions:pairs(f('mentions')).map(function(p){ return thing(p,'Thing'); })});
} else if (e.type === 'breadcrumb'){
n.itemListElement = pairs(f('items')).map(function(p,i){ return {'@type':'ListItem',position:i+1,name:p.name,item:p.url}; });
} else if (e.type === 'video'){
Object.assign(n, {name:f('name'), description:f('description'), thumbnailUrl:f('thumbnailUrl'), uploadDate:f('uploadDate'), duration:f('duration'), embedUrl:f('embedUrl'), contentUrl:f('contentUrl')});
} else if (e.type === 'faq'){
n.mainEntity = qa(f('items')).map(function(x){ return {'@type':'Question',name:x.q,acceptedAnswer:{'@type':'Answer',text:x.a}}; });
}
return clean(n) || {'@type':n['@type'],'@id':n['@id']};
}
function edges(){
var out = [];
state.entities.forEach(function(e){
DEF[e.type].fields.forEach(function(fd){
if (fd.t === 'ref' && e.f[fd.k] && byUid(e.f[fd.k])) out.push({from:e.uid, to:e.f[fd.k], label:REFLABEL[fd.k] || fd.k});
});
});
return out;
}
/* ---------- Validation ---------- */
var PH = /(lorem ipsum|example\.com|\byour (business|company|firm|name)\b|\bxxx+\b|\btbd\b|\btodo\b|\[[^\]]*\])/i;
function validate(){
var out = [];
function add(level, e, msg){ out.push({level:level, uid:e ? e.uid : null, who:e ? dname(e) : 'Setup', msg:msg}); }
var site = normSite(), host = siteHost();
if (!site) add('error', null, 'Add your website URL at the top. It anchors every @id.');
else if (!/^https?:\/\/[^\s\/]+\.[a-z]{2,}/i.test(site)) add('error', null, 'Your website URL does not look complete. Use something like https://yourfirm.ca');
var ents = state.entities;
if (!ents.some(function(e){ return e.type === 'business'; })) add('warn', null, 'Add a Business entity. It is the hub your other entities connect to.');
if (ents.filter(function(e){ return e.type === 'website'; }).length > 1) add('warn', null, 'You have more than one Website entity. Most sites need exactly one.');
ents.forEach(function(e){
DEF[e.type].fields.forEach(function(fd){
var val = v(e, fd.k);
if (fd.t === 'ref'){
if (e.f[fd.k] && !byUid(e.f[fd.k])) add('error', e, fd.l + ' points to an entity that no longer exists.');
else if (!e.f[fd.k] && fd.req) add('error', e, fd.l + ' is required. Link it to another entity.');
else if (!e.f[fd.k] && fd.rec) add('warn', e, fd.l + ' is recommended. Linking entities is what builds the graph.');
return;
}
if (fd.t === 'select') return;
if (!val){
if (fd.req) add('error', e, fd.l + ' is required.');
else if (fd.rec) add('warn', e, fd.l + ' is recommended.');
return;
}
if (PH.test(val)) add('warn', e, fd.l + ' looks like placeholder text. Use the real value shown on the page.');
if (fd.t === 'url' && !isAbs(val)) add('error', e, fd.l + ' must be a full URL starting with https://');
if (fd.t === 'url' && /^http:\/\//i.test(val)) add('warn', e, fd.l + ' uses http. Use the https version if your site has one.');
if (fd.t === 'list') lines(val).forEach(function(u){ if (!isAbs(u) && fd.k === 'sameAs') add('error', e, 'This profile is not a full URL: ' + u); });
if (fd.t === 'pairs') pairs(val).forEach(function(p){
if (p.url && !isAbs(p.url)) add('error', e, fd.l + ': "' + p.url + '" is not a full URL.');
if (!p.name && fd.k !== 'sameAs') add('warn', e, fd.l + ': add a name before the | for ' + p.url);
});
if (fd.t === 'date' && !isDate(val)) add('error', e, fd.l + ' must be a date like 2026-09-24.');
if (fd.t === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) add('error', e, fd.l + ' is not a valid email address.');
if (fd.t === 'tel' && !/^\+/.test(val)) add('warn', e, 'Start the phone number with the country code, like +1.');
if (fd.t === 'hours'){ var hb = hours(val).bad; if (hb.length) add('warn', e, 'These hours lines were skipped: ' + hb.join('; ') + '. Use the format Mo-Fr 09:00-17:00.'); }
if ((fd.k === 'url') && host && isAbs(val) && (e.type === 'webpage' || e.type === 'article' || e.type === 'website')){
try { if (new URL(val).hostname.replace(/^www\./,'') !== host) add('warn', e, 'This URL is on a different domain than your website.'); } catch(x){}
}
});
var f = function(k){ return v(e,k); };
if (e.type === 'business'){
var local = LOCAL.indexOf(f('subtype') || 'Organization') > -1;
if (local){
[['street','Street address'],['city','City'],['country','Country code']].forEach(function(p){ if (!f(p[0])) add('error', e, p[1] + ' is required for a local business type.'); });
[['region','Province or state'],['postal','Postal or ZIP code'],['hours','Opening hours'],['telephone',null]].forEach(function(p){ if (p[1] && !f(p[0])) add('warn', e, p[1] + ' is recommended for local businesses.'); });
if (!f('lat')) add('warn', e, 'Latitude and longitude are recommended for local businesses.');
}
if (f('country') && !/^[A-Za-z]{2}$/.test(f('country'))) add('warn', e, 'Use a 2-letter country code, like CA or US.');
if ((f('lat') && !f('lng')) || (!f('lat') && f('lng'))) add('warn', e, 'Add both latitude and longitude, or neither.');
if ((f('lat') && isNaN(+f('lat'))) || (f('lng') && isNaN(+f('lng')))) add('error', e, 'Latitude and longitude must be numbers.');
if (f('subtype') === 'Attorney') add('info', e, 'Schema.org suggests LegalService over Attorney for firms. Both are valid.');
if (e.f.parent === e.uid) add('error', e, 'A business cannot be its own parent organization.');
}
if (e.type === 'article' && f('headline').length > 110) add('warn', e, 'Headlines over 110 characters may be cut off.');
if (e.type === 'breadcrumb' && f('items') && pairs(f('items')).length < 2) add('warn', e, 'A breadcrumb trail usually needs at least two steps.');
if (e.type === 'breadcrumb') pairs(f('items')).forEach(function(p, i, arr){ if (!p.url && i < arr.length - 1) add('error', e, 'Breadcrumb step "' + p.name + '" needs a URL.'); });
if (e.type === 'video' && f('duration') && !/^P(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)$/.test(f('duration'))) add('error', e, 'Duration must be ISO 8601, like PT2M30S.');
if (e.type === 'service' && f('price') && isNaN(+f('price').replace(/,/g,''))) add('error', e, 'Starting price must be a number. Put the currency in its own field.');
if (e.type === 'service' && f('price') && !f('currency')) add('warn', e, 'Add a currency code, like CAD or USD.');
if (e.type === 'faq'){
add('info', e, 'Google stopped showing FAQ rich results on May 7, 2026. Keep FAQPage only for real on-page FAQs. Other search engines and AI systems may still read it.');
qa(f('items')).forEach(function(x){ if (!x.a) add('error', e, 'The question "' + x.q + '" has no answer.'); });
}
});
var rank = {error:0, warn:1, info:2};
return out.sort(function(a,b){ return rank[a.level] - rank[b.level]; });
}
/* ---------- Rendering ---------- */
function select(id){
state.sel = id; save();
renderChips(); renderForm(); renderOutputs();
if (window.innerWidth < 1024){
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
$('#gmsg-formcard').scrollIntoView({behavior: reduce ? 'auto' : 'smooth', block:'start'});
}
}
function removeEntity(id){
var e = byUid(id); if (!e) return;
state.entities = state.entities.filter(function(x){ return x.uid !== id; });
state.entities.forEach(function(x){ Object.keys(x.f).forEach(function(k){ if (x.f[k] === id) delete x.f[k]; }); });
if (state.sel === id) state.sel = state.entities[0] ? state.entities[0].uid : null;
save(); renderAll();
}
function addEntity(type){
var e = mk(type, type === 'business' ? {subtype:'Organization'} : {});
var biz = state.entities.filter(function(x){ return x.type === 'business'; })[0];
var web = state.entities.filter(function(x){ return x.type === 'website'; })[0];
if (type === 'person' && biz) e.f.worksFor = biz.uid;
if (type === 'website' && biz) e.f.publisher = biz.uid;
if (type === 'webpage' && web) e.f.isPartOf = web.uid;
if (type === 'service' && biz) e.f.provider = biz.uid;
if (type === 'article' && biz) e.f.publisher = biz.uid;
if (type === 'business' && biz) e.f.parent = biz.uid;
state.entities.push(e);
select(e.uid);
}
function renderChips(){
var ul = $('#gmsg-chips'); ul.innerHTML = '';
state.entities.forEach(function(e){
var on = e.uid === state.sel;
ul.appendChild(h('li', {'class':'gmsg-chip' + (on ? ' is-on' : '')}, [
h('button', {type:'button','class':'gmsg-chip-main','aria-pressed':on ? 'true' : 'false', onclick:function(){ select(e.uid); }}, [
h('small', {text:schemaType(e)}), h('b', {text:dname(e)})
]),
h('button', {type:'button','class':'gmsg-chip-x','aria-label':'Remove ' + dname(e), title:'Remove', onclick:function(){ removeEntity(e.uid); }}, ['\u00d7'])
]));
});
if (!state.entities.length) ul.appendChild(h('li', {'class':'gmsg-sub', text:'No entities yet. Add one below.'}));
}
function renderAdd(){
var box = $('#gmsg-add');
ORDER.forEach(function(t){
box.appendChild(h('button', {type:'button','class':'gmsg-btn gmsg-btn--xs gmsg-btn--line', onclick:function(){ addEntity(t); }}, ['+ ' + DEF[t].label]));
});
}
function fieldEl(e, fd){
var id = 'gmsg-f-' + e.uid + '-' + fd.k, input, val = e.f[fd.k] == null ? '' : e.f[fd.k];
var onIn = function(ev){
e.f[fd.k] = ev.target.value; save(); renderOutputs();
if (fd.k === 'name' || fd.k === 'headline' || fd.k === 'subtype') { renderChips(); var t = $('#gmsg-ftitle'); if (t) t.textContent = dname(e); var st = $('#gmsg-ftype'); if (st) st.textContent = schemaType(e); }
};
if (fd.t === 'select' || fd.t === 'ref'){
input = h('select', {'class':'gmsg-in', id:id, onchange:onIn});
var opts = fd.t === 'select' ? fd.opts.map(function(o){ return [o,o]; }) :
[['','Not linked']].concat(state.entities.filter(function(x){ return x.uid !== e.uid && fd.to.indexOf(x.type) > -1; }).map(function(x){ return [x.uid, DEF[x.type].label + ': ' + dname(x)]; }));
opts.forEach(function(o){ input.appendChild(h('option', {value:o[0], text:o[1]})); });
input.value = val || (fd.t === 'select' ? fd.opts[0] : '');
} else if (fd.t === 'textarea' || fd.t === 'list' || fd.t === 'pairs' || fd.t === 'hours' || fd.t === 'qa'){
input = h('textarea', {'class':'gmsg-in', id:id, placeholder:fd.ph || '', spellcheck:fd.t === 'textarea' || fd.t === 'qa' ? 'true' : 'false', oninput:onIn, rows:fd.t === 'qa' ? 7 : 3});
input.value = val;
} else {
var type = {url:'url', date:'date', email:'email', tel:'tel'}[fd.t] || 'text';
input = h('input', {'class':'gmsg-in', id:id, type:type, placeholder:fd.ph || '', oninput:onIn, autocomplete:'off'});
input.value = val;
}
var tag = fd.req ? h('span', {'class':'gmsg-tag gmsg-tag--req', text:'Required'}) : fd.rec ? h('span', {'class':'gmsg-tag', text:'Recommended'}) : null;
return h('div', {'class':'gmsg-f'}, [
h('label', {'class':'gmsg-lab', 'for':id}, [fd.l, tag]),
input,
fd.help ? h('p', {'class':'gmsg-help', text:fd.help}) : null
]);
}
function renderForm(){
var box = $('#gmsg-form'); box.innerHTML = '';
var e = byUid(state.sel);
if (!e){ box.appendChild(h('p', {'class':'gmsg-sub', text:'Select or add an entity to edit it.'})); return; }
var d = DEF[e.type];
box.appendChild(h('div', {'class':'gmsg-formhead'}, [
h('div', null, [
h('p', {'class':'gmsg-sub', id:'gmsg-ftype', style:'margin:0 0 4px', text:schemaType(e)}),
h('h3', {'class':'gmsg-h3', id:'gmsg-ftitle', text:dname(e)}),
h('p', {'class':'gmsg-sub', text:d.desc})
])
]));
var pend = [];
function flush(){ if (!pend.length) return; box.appendChild(pend.length === 2 ? h('div', {'class':'gmsg-row2'}, pend) : pend[0]); pend = []; }
d.fields.forEach(function(fd){
if (fd.grp){ flush(); box.appendChild(h('h4', {'class':'gmsg-grp', text:fd.grp})); }
var el = fieldEl(e, fd);
if (fd.half){ pend.push(el); if (pend.length === 2) flush(); }
else { flush(); box.appendChild(el); }
});
flush();
}
var NS = 'http://www.w3.org/2000/svg';
function sv(tag, attrs, kids){ var el = document.createElementNS(NS, tag); Object.keys(attrs||{}).forEach(function(k){ el.setAttribute(k, attrs[k]); }); (kids||[]).forEach(function(c){ el.appendChild(c); }); return el; }
function renderMap(issues){
var svg = $('#gmsg-map'); while (svg.firstChild) svg.removeChild(svg.firstChild);
var narrow = (svg.getBoundingClientRect().width || 600) < 520;
var W = narrow ? 400 : 600, H = narrow ? 460 : 340, cx = W / 2, cy = H / 2, ents = state.entities;
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.appendChild(sv('defs', {}, [sv('marker', {id:'gmsg-arr', viewBox:'0 0 10 10', refX:'9', refY:'5', markerWidth:'7', markerHeight:'7', orient:'auto-start-reverse'}, [sv('path', {d:'M0 0L10 5L0 10z', fill:'#6A6A6A'})])]));
if (!ents.length){ var t = sv('text', {x:cx, y:cy, 'text-anchor':'middle', 'class':'empty'}); t.textContent = 'Add an entity to start your map'; svg.appendChild(t); return; }
var bad = {}; issues.forEach(function(i){ if (i.level === 'error' && i.uid) bad[i.uid] = 1; });
var hub = ents.filter(function(e){ return e.type === 'business' && !e.f.parent; })[0] || ents[0];
var others = ents.filter(function(e){ return e !== hub; }), n = others.length;
var rx = narrow ? 118 : (n > 6 ? 228 : 205), ry = narrow ? (n > 6 ? 188 : 170) : (n > 6 ? 130 : 118), pos = {}, size = {};
ents.forEach(function(e){ var nm = dname(e); if (nm.length > 20) nm = nm.slice(0,19) + '\u2026'; size[e.uid] = {w:Math.max(96, Math.min(172, Math.max(nm.length, schemaType(e).length) * 7 + 26)), h:44, nm:nm}; });
pos[hub.uid] = {x:cx, y:cy};
others.forEach(function(e, i){
var a = n === 1 ? 0 : -Math.PI / 2 + (2 * Math.PI * i / n);
var s = size[e.uid], x = cx + rx * Math.cos(a), y = cy + ry * Math.sin(a);
pos[e.uid] = {x:Math.max(s.w/2 + 4, Math.min(W - s.w/2 - 4, x)), y:Math.max(s.h/2 + 4, Math.min(H - s.h/2 - 4, y))};
});
function edgePt(p, q, s){
var dx = q.x - p.x, dy = q.y - p.y; if (!dx && !dy) return p;
var k = Math.min(dx ? (s.w/2 + 3) / Math.abs(dx) : Infinity, dy ? (s.h/2 + 3) / Math.abs(dy) : Infinity);
return {x:p.x + dx * k, y:p.y + dy * k};
}
var eg = sv('g', {}), lg = sv('g', {});
edges().forEach(function(ed){
var a = pos[ed.from], b = pos[ed.to]; if (!a || !b) return;
var p1 = edgePt(a, b, size[ed.from]), p2 = edgePt(b, a, size[ed.to]);
eg.appendChild(sv('line', {x1:p1.x, y1:p1.y, x2:p2.x, y2:p2.y, 'class':'e', 'marker-end':'url(#gmsg-arr)'}));
var t = sv('text', {x:(p1.x + p2.x) / 2, y:(p1.y + p2.y) / 2 + 3, 'text-anchor':'middle', 'class':'el'}); t.textContent = ed.label; lg.appendChild(t);
});
svg.appendChild(eg); svg.appendChild(lg);
ents.forEach(function(e){
var p = pos[e.uid], s = size[e.uid];
var g = sv('g', {'class':'n' + (e === hub ? ' hub' : '') + (e.uid === state.sel ? ' sel' : '') + (bad[e.uid] ? ' bad' : ''), tabindex:'0', role:'button', 'aria-label':'Edit ' + schemaType(e) + ' ' + dname(e) + (bad[e.uid] ? ', has errors' : '')});
g.appendChild(sv('rect', {x:p.x - s.w/2, y:p.y - s.h/2, width:s.w, height:s.h, rx:12}));
var t1 = sv('text', {x:p.x, y:p.y - 4, 'text-anchor':'middle', 'class':'t1'}); t1.textContent = schemaType(e);
var t2 = sv('text', {x:p.x, y:p.y + 12, 'text-anchor':'middle', 'class':'t2'}); t2.textContent = s.nm;
g.appendChild(t1); g.appendChild(t2);
g.addEventListener('click', function(){ select(e.uid); });
g.addEventListener('keydown', function(ev){ if (ev.key === 'Enter' || ev.key === ' '){ ev.preventDefault(); select(e.uid); } });
svg.appendChild(g);
});
}
function highlight(src){
var re = /("(?:\\.|[^"\\])*")(\s*:)?/g, out = '', last = 0, m;
while ((m = re.exec(src))){
out += '' + esc(src.slice(last, m.index)) + '';
out += m[2] ? '' + esc(m[1]) + '' + esc(m[2]) : '' + esc(m[1]) + '';
last = re.lastIndex;
}
return out + '' + esc(src.slice(last)) + '';
}
var current = {json:'', text:''};
function renderOutputs(){
var ids = computeIds();
var graph = {'@context':'https://schema.org', '@graph':state.entities.map(function(e){ return build(e, ids); })};
var json = JSON.stringify(graph, null, 2).replace(/<\/(script)/gi, '<\\/$1');
current.json = json;
current.text = state.wrap ? '