Article
On this page. Headline, images, dates and authors, with BlogPosting as the type.
One article with its byline, embedded video and members-only section: an Article whose author is the same Person entity as their profile page, published by the Organization from the homepage.
Use on news, blog and guide pages where the main content is one article with a visible headline, byline and date.
The author gets an @id on their profile page, so every article they write and the profile page itself describe the same person. The publisher is the Organization from the homepage, by @id.
Each card opens the framework for that feature, with its own rules and property reference. Cards marked "Linked by @id" are defined on the homepage and only referenced here.
On this page. Headline, images, dates and authors, with BlogPosting as the type.
On this page. Marks the members-only section so the gap is not read as cloaking.
On this page. The embedded how-to video, nested in the article.
On this page. Home, Journal, article.
Linked by @id. The author Person is defined on their profile page; the @id matches.
Linked by @id. Publisher, by @id from the homepage.
Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.
| @id | Defined on | Type | Referenced by |
|---|---|---|---|
/journal/how-to-waterproof-a-rain-jacket/#webpage | This page | WebPage | BlogPosting.mainEntityOfPage |
/journal/how-to-waterproof-a-rain-jacket/#breadcrumb | This page | BreadcrumbList | WebPage.breadcrumb |
/journal/how-to-waterproof-a-rain-jacket/#article | This page | BlogPosting | WebPage.mainEntity |
/journal/authors/maya-okafor/#person | This page | Person | Nested in BlogPosting.author |
/#organization | Homepage | OnlineStore | Person.worksFor, BlogPosting.publisher |
/#website | Homepage | WebSite | WebPage.isPartOf |
One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.
// Article page schema: one article with its author, publisher and page.
// Article (or NewsArticle / BlogPosting), a Person for the author with an
// @id that the author's ProfilePage also uses, the page's WebPage and
// BreadcrumbList, optional VideoObject and paywall markup, and an @id link
// to the Organization defined on the homepage as publisher.
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:\d{2})$/;
const ISO_DURATION = /^PT(\d+H)?(\d+M)?(\d+S)?$/;
const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
if (Array.isArray(obj)) {
const out = obj.map(compact).filter((v) => v !== undefined);
return out.length ? out : undefined;
}
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const c = compact(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
return obj === null || obj === '' ? undefined : obj;
};
const TYPES = { news: 'NewsArticle', blog: 'BlogPosting', article: 'Article' };
function breadcrumb(trail, id) {
const items = (trail || []).filter((c) => c.name && isUrl(c.url));
if (items.length < 2) return undefined;
return {
'@type': 'BreadcrumbList',
'@id': id,
itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
};
}
// The author is a Person with a stable @id on their profile page, so every
// article and the ProfilePage itself describe the same entity. A byline that
// is really a brand or a desk ("Staff") is not passed off as a person.
function author(a, root) {
if (!a || !a.name || /^(staff|admin|editor|team)$/i.test(a.name.trim())) return undefined;
if (!isUrl(a.profileUrl)) return { '@type': 'Person', name: clean(a.name) };
return compact({
'@type': 'Person',
'@id': `${a.profileUrl}#person`,
name: clean(a.name),
url: a.profileUrl,
jobTitle: clean(a.jobTitle),
sameAs: (a.sameAs || []).filter(isUrl),
worksFor: { '@id': `${root}/#organization` },
});
}
function video(v) {
if (!v || !v.name || !isUrl(v.thumbnailUrl) || !ISO_DATE_TIME.test(v.uploadDate || '')) return undefined;
if (!isUrl(v.contentUrl) && !isUrl(v.embedUrl)) return undefined;
return compact({
'@type': 'VideoObject',
name: clean(v.name),
description: clean(v.description),
thumbnailUrl: [v.thumbnailUrl],
uploadDate: v.uploadDate,
duration: ISO_DURATION.test(v.duration || '') ? v.duration : undefined,
contentUrl: isUrl(v.contentUrl) ? v.contentUrl : undefined,
embedUrl: isUrl(v.embedUrl) ? v.embedUrl : undefined,
});
}
function buildArticlePageSchema(source) {
const site = source.site || {};
const page = source.page || {};
const a = source.article || {};
if (!isUrl(site.url) || !isUrl(page.url) || !a.headline) return null;
if (!ISO_DATE_TIME.test(a.datePublished || '')) return null;
const root = site.url.replace(/\/+$/, '');
const pageUrl = page.url;
const articleId = `${pageUrl}#article`;
// dateModified is only stated when it is a real, later edit.
const modified = ISO_DATE_TIME.test(a.dateModified || '') && Date.parse(a.dateModified) > Date.parse(a.datePublished)
? a.dateModified
: undefined;
// Paywalled content: mark the gated section so Google does not read the
// gap between what crawlers and visitors see as cloaking.
const paywall = a.paywalledSelector
? { isAccessibleForFree: false, hasPart: { '@type': 'WebPageElement', isAccessibleForFree: false, cssSelector: a.paywalledSelector } }
: {};
const authors = (a.authors || []).map((x) => author(x, root)).filter(Boolean);
const article = compact({
'@type': TYPES[a.kind] || 'Article',
'@id': articleId,
headline: clean(a.headline).slice(0, 110),
description: clean(a.description),
image: (a.images || []).filter(isUrl),
datePublished: a.datePublished,
dateModified: modified,
author: authors.length ? authors : undefined,
publisher: { '@id': `${root}/#organization` },
mainEntityOfPage: { '@id': `${pageUrl}#webpage` },
video: video(a.video),
...paywall,
});
const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
const webpage = compact({
'@type': 'WebPage',
'@id': `${pageUrl}#webpage`,
url: pageUrl,
name: clean(page.title) || clean(a.headline),
isPartOf: { '@id': `${root}/#website` },
breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
mainEntity: { '@id': articleId },
});
return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, article].filter(Boolean) };
}
{
"site": {
"url": "https://www.example.com"
},
"page": {
"url": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/",
"title": "How to Re-Waterproof a Rain Jacket | Harbor & Pine Journal",
"breadcrumbs": [
{
"name": "Home",
"url": "https://www.example.com/"
},
{
"name": "Journal",
"url": "https://www.example.com/journal/"
},
{
"name": "How to Re-Waterproof a Rain Jacket",
"url": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/"
}
]
},
"article": {
"kind": "blog",
"headline": "How to Re-Waterproof a Rain Jacket in Four Steps",
"description": "Wash, dry, treat and heat: how to bring back the water beading on a tired shell jacket.",
"images": [
"https://www.example.com/img/journal/rewaterproof-1x1.jpg",
"https://www.example.com/img/journal/rewaterproof-4x3.jpg",
"https://www.example.com/img/journal/rewaterproof-16x9.jpg"
],
"datePublished": "2026-03-12T09:00:00-04:00",
"dateModified": "2026-09-02T14:30:00-04:00",
"authors": [
{
"name": "Maya Okafor",
"jobTitle": "Gear Editor",
"profileUrl": "https://www.example.com/journal/authors/maya-okafor/",
"sameAs": [
"https://www.linkedin.com/in/example-maya"
]
},
{
"name": "Staff"
}
],
"video": {
"name": "Re-waterproofing a rain shell",
"description": "The four steps, start to finish, on a three-layer jacket.",
"thumbnailUrl": "https://www.example.com/img/journal/rewaterproof-video.jpg",
"uploadDate": "2026-03-12T09:00:00-04:00",
"duration": "PT4M20S",
"embedUrl": "https://www.youtube.com/embed/example"
},
"paywalledSelector": ".members-only"
}
}
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/#webpage",
"url": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/",
"name": "How to Re-Waterproof a Rain Jacket | Harbor & Pine Journal",
"isPartOf": {
"@id": "https://www.example.com/#website"
},
"breadcrumb": {
"@id": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/#breadcrumb"
},
"mainEntity": {
"@id": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/#article"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Journal",
"item": "https://www.example.com/journal/"
},
{
"@type": "ListItem",
"position": 3,
"name": "How to Re-Waterproof a Rain Jacket",
"item": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/"
}
]
},
{
"@type": "BlogPosting",
"@id": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/#article",
"headline": "How to Re-Waterproof a Rain Jacket in Four Steps",
"description": "Wash, dry, treat and heat: how to bring back the water beading on a tired shell jacket.",
"image": [
"https://www.example.com/img/journal/rewaterproof-1x1.jpg",
"https://www.example.com/img/journal/rewaterproof-4x3.jpg",
"https://www.example.com/img/journal/rewaterproof-16x9.jpg"
],
"datePublished": "2026-03-12T09:00:00-04:00",
"dateModified": "2026-09-02T14:30:00-04:00",
"author": [
{
"@type": "Person",
"@id": "https://www.example.com/journal/authors/maya-okafor/#person",
"name": "Maya Okafor",
"url": "https://www.example.com/journal/authors/maya-okafor/",
"jobTitle": "Gear Editor",
"sameAs": [
"https://www.linkedin.com/in/example-maya"
],
"worksFor": {
"@id": "https://www.example.com/#organization"
}
}
],
"publisher": {
"@id": "https://www.example.com/#organization"
},
"mainEntityOfPage": {
"@id": "https://www.example.com/journal/how-to-waterproof-a-rain-jacket/#webpage"
},
"video": {
"@type": "VideoObject",
"name": "Re-waterproofing a rain shell",
"description": "The four steps, start to finish, on a three-layer jacket.",
"thumbnailUrl": [
"https://www.example.com/img/journal/rewaterproof-video.jpg"
],
"uploadDate": "2026-03-12T09:00:00-04:00",
"duration": "PT4M20S",
"embedUrl": "https://www.youtube.com/embed/example"
},
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"isAccessibleForFree": false,
"cssSelector": ".members-only"
}
}
]
}
The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:
The anchor every other page type points at: Organization, site name and store-wide policies.
ProductGroup with a priced Offer per variant, member pricing and links to store-wide policies.
One store location with address, geo and hours, linked to its parent brand.
Recipe with timed steps, nutrition, ratings and a video whose key moments match the steps.
One open role with pay, closing date and work location, linked to the employer and the store.
An in-store workshop with times, a rescheduled date, the store as venue and a free ticket.
A community thread that becomes Q&A or a discussion posting, depending on the thread.