@stackql/docusaurus-plugin-structured-data
v1.7.0
Published
A plugin which adds structured data to your docusaurus site.
Readme
docusaurus-plugin-structured-data
Plugin to configure Structured Data for Docusaurus sites
How it works
This plugin will generate Structured Data for your Docusaurus site, compliant with schema.org.
The plugin will generate the following types of structured data, and include them in the <head> of your site using the JSON-LD format:
Organization- augmented using data fromthemeConfig.structuredData.organizationWebSite- augmented using data fromthemeConfig.structuredData.websiteWebPage- dynamically generated for each pageBreadcrumbList- dynamically generated for each page
Docusaurus generated microdata for
BreadcrumbListis removed by this plugin in favor of the corresponding JSON-LD data.
Organization and WebSite can be extended using the themeConfig.structuredData object based upon properties provided (e.g. you can add any schema.org compliant properties for Organization and WebSite and these will be automatically included in your structured data for each page).
WebPage structured data is dynamically generated for each page, and includes the following properties:
name- sourced from pagetitleurl- page urldescription- sourced fromdescriptionin the page frontmattermainEntityOfPage- sourced fromsiteConfig.urlheadline- sourced fromsiteConfig.titledateModified- the page's last change: a doc's git last-update time (showLastUpdateTime, orlast_update.datein front matter), a blog post'slast_update.dateor date, and only when a page has no record of its own, the build time (see Dates below)datePublished- a blog post's date, the page'sdatePublishedfront matter, elsethemeConfig.structuredData.webpage.datePublished; omitted when none applies rather than invented
BreadcrumbList structured data is dynamically generated for each page based upon the page route.
For routes owned by a @docusaurus/plugin-content-blog instance the crumbs are Home, then one per segment of the instance's routeBasePath, then the page. With a single blog at /blog that is Home > Blog > Post. With several instances (for example routeBasePath: '/blog/product') a post is Home > Blog > Product Announcements > Post and the instance list page is Home > Blog > Product Announcements. Segment names come from breadcrumbLabelMap, then Blog for a blog segment, then the instance's blogTitle for its own root segment. Blog posts also carry these names as Article.articleSection. Tag and pagination routes of every instance are skipped.
this plugin uses the
postBuildlifecycle hook to generate the structured data for each page, and inject it into the<head>of the page. It is only invoked uponyarn buildornpm run buildcommands being run.
Article structured data is automatically generated by this plugin for blog articles, including calcualting wordCount and including author data.
Installation
npm i @stackql/docusaurus-plugin-structured-datayarn add @stackql/docusaurus-plugin-structured-dataSetup
Add to plugins in docusaurus.config.js:
{
plugins: [
'@stackql/docusaurus-plugin-structured-data',
...
]
}Update themeConfig in the docusaurus.config.js file, the following shows mandatory properties:
{
...,
themeConfig: {
structuredData: {
excludedRoutes: [], // routes to exclude from structured data generation: exact routes or globs ("/providers/*", "/registry/**"), include custom redirects here
verbose: boolean, // print verbose output to console (default: false)
featuredImageDimensions: {
width: number,
height: number,
},
authors:{
author_name: {
authorId: string, // unique id for the author - used as an identifier in structured data
url: string, // MUST be the same as the `url` property in the `authors.yml` file in the `blog` directory
imageUrl: string, // gravatar url
sameAs: [] // synonymous entity links, e.g. github, linkedin, twitter, etc.
},
},
organization: {}, // Organization properties can be added to this object (contactPoint, address and logo get their @type filled in)
website: {}, // WebSite properties can be added to this object
webpage: {
datePublished: string, // site-wide fallback for pages with no date of their own; omitted when unset
inLanguage: string, // default: en-US
},
softwareSourceCode: { // optional: a site-wide SoftwareSourceCode node for an open-source product
name: string, // default: siteConfig.title
codeRepository: string, // required: the repository URL
programmingLanguage: string,
license: string, // URL or SPDX name
runtimePlatform: string,
},
softwareApplication: { // optional: defaults for every page that opts into a SoftwareApplication node
name: string,
applicationCategory: string, // one of the schema.org application categories
operatingSystem: string,
license: string,
downloadUrl: string,
softwareVersion: string,
isAccessibleForFree: boolean, // true derives offers: { '@type': 'Offer', price: 0, priceCurrency }
priceCurrency: string, // default: USD
},
breadcrumbLabelMap: {} // optional, maps route segments (including blog instance segments such as "product") to labels
}
},
...
}Config Example
Below is an example of a docusaurus.config.js file with the themeConfig.structuredData object populated with all available properties:
structuredData: {
excludedRoutes: [
'/providers',
'/registry/**',
],
verbose: true,
featuredImageDimensions: {
width: 1200,
height: 627,
},
authors:{
'Jeffrey Aven': {
authorId: '1',
url: 'https://www.linkedin.com/in/jeffreyaven/',
imageUrl: 'https://s.gravatar.com/avatar/f96573d092470c74be233e1dded5376f?s=80',
sameAs: [
'https://www.amazon.com/stores/Jeffrey-Aven/author/B0BSP78VVL',
'https://developers.google.com/community/experts/directory/profile/profile-jeffrey-aven',
'https://www.linkedin.com/in/jeffreyaven/',
'https://www.crunchbase.com/person/jeffrey-aven',
'https://github.com/jeffreyaven',
'https://dev.to/jeffreyaven',
],
},
},
organization: {
sameAs: [
'https://twitter.com/stackql',
'https://www.linkedin.com/company/stackql',
'https://github.com/stackql',
'https://www.youtube.com/@stackql',
'https://hub.docker.com/u/stackql',
],
contactPoint: {
'@type': 'ContactPoint',
email: '[email protected]',
},
logo: {
'@type': 'ImageObject',
inLanguage: 'en-US',
'@id': 'https://stackql.io/#logo',
url: 'https://stackql.io/img/stackql-cover.png',
contentUrl: 'https://stackql.io/img/stackql-cover.png',
width: 1440,
height: 900,
caption: 'StackQL - your cloud using SQL',
},
address: {
'@type': 'PostalAddress',
addressCountry: 'AU', // https://en.wikipedia.org/wiki/ISO_3166-1
postalCode: '3001',
streetAddress: 'Level 24, 570 Bourke Street, Melbourne, Victoria',
},
taxID: 'ABN 65 656 147 054',
},
website: {
inLanguage: 'en-US',
},
webpage: {
inLanguage: 'en-US',
datePublished: '2021-07-01',
},
softwareSourceCode: {
name: 'StackQL',
codeRepository: 'https://github.com/stackql/stackql',
programmingLanguage: 'Go',
license: 'https://opensource.org/licenses/MIT',
runtimePlatform: 'macOS, Linux, Windows',
},
softwareApplication: {
name: 'StackQL',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'macOS, Linux, Windows',
license: 'https://opensource.org/licenses/MIT',
downloadUrl: 'https://stackql.io/installing-stackql',
isAccessibleForFree: true,
priceCurrency: 'USD',
},
breadcrumbLabelMap: {
'developers': 'Developers',
'functions': 'Functions',
'aggregate': 'Aggregate',
'datetime': 'Date Time',
'json': 'JSON',
'math': 'Math',
'string': 'String',
'command-line-usage': 'Command Line Usage',
'getting-started': 'Getting Started',
'language-spec': 'Language Specification',
're': 'Regular Expressions',
}
},The plugin takes no plugin options: everything is read from themeConfig.structuredData. Passing options in the plugins array (['@stackql/docusaurus-plugin-structured-data', { ... }]) fails the build with a message saying so, rather than being ignored.
Dates
Agents and search engines read dateModified as a freshness signal, so the plugin only claims what it can source:
| | datePublished | dateModified |
|---|---|---|
| Docs page | datePublished or date in front matter, else webpage.datePublished | git last-update time (showLastUpdateTime: true on the docs plugin; last_update.date front matter overrides it) |
| Blog post | the post's date | the post's last_update.date (or lastUpdatedAt when the blog plugin has showLastUpdateTime), else the post's date |
| Any other page | webpage.datePublished, else omitted | the build time |
dateModified is never earlier than datePublished. Before 1.7.0 dateModified was always the build time and Article.dateModified equalled datePublished, which told agents nothing about freshness.
Organization contact details
organization is merged into the Organization node as written, with one normalisation pass: contactPoint entries get @type: ContactPoint, address gets @type: PostalAddress and a string logo becomes an ImageObject. A contact point without a contactType logs one warning at build time, since Google's Organization guidance and agents expect it:
contactPoint: {
contactType: 'customer support', // or 'sales', 'technical support', ...
email: '[email protected]',
},Open-source product: SoftwareSourceCode and SoftwareApplication
Two site-wide blocks describe the product the site documents:
softwareSourceCodeemits oneSoftwareSourceCodenode (@id<site>/#softwaresourcecode) in every page's graph, referenced fromWebSite.about.codeRepositoryis required;programmingLanguage,license,runtimePlatformand any other schema.org property pass through.softwareApplicationholds defaults for theSoftwareApplicationnode a page opts into withsoftwareApplication: true(or an object) in its front matter: the page payload is layered over these defaults. WithisAccessibleForFree: trueand no explicitoffers, the node getsoffers: { '@type': 'Offer', price: 0, priceCurrency }.applicationCategory(in the defaults or a page payload) must be one of schema.org's application categories (DeveloperApplication,UtilitiesApplication,BusinessApplication, ...); anything else fails the build with the list.
To put a complete SoftwareApplication on the home page, set the defaults and add softwareApplication: true to the home page's front matter.
If your organization is a
LocalBusinessor one of its subtypes (e.g.Store,Restaurant), set'@type': 'LocalBusiness'(or the specific subtype) inside yourorganizationconfig block. Properties likeduns,currenciesAccepted,paymentAccepted, andpriceRangeare defined onLocalBusiness(not onOrganization) and only become schema.org-valid once@typeis narrowed.taxIDis valid onOrganizationitself and works without changing@type.
AEO / Answer Engine Optimization
As of 1.4.0 this plugin emits structured data that AI search surfaces
(Google AI Overviews, Perplexity, ChatGPT search, Claude web tools) consume
in addition to the classic SEO entities. All AEO features are opt-in
except speakable, which is added to every WebPage node by default and can
be turned off globally or per-page.
What each schema is for
| Schema | What it does | Where it shows up |
|---|---|---|
| FAQPage | Marks a list of question/answer pairs as the page's main content | AI Overviews answer cards, Perplexity citations, voice answer extraction |
| HowTo | Marks a sequence of steps to complete a task | AI Overviews step-by-step cards, voice walkthroughs |
| TechArticle | Same as Article but signals a technical/documentation context | Better grounding for ChatGPT / Claude search when answering API or tooling questions |
| SoftwareApplication | Identifies a page as describing an installable tool / CLI / library | Knowledge-panel-style cards in AI surfaces, version / OS metadata for install pages |
| SpeakableSpecification | Tells voice assistants which parts of the page to read aloud | Google Assistant, smart-speaker answer extraction |
| mainEntity linking | Connects the primary entity (Article / TechArticle / WebPage) to the secondary entity (FAQPage / HowTo / SoftwareApplication) in a single @graph | Lets crawlers treat the page as one coherent unit instead of disconnected nodes |
How frontmatter reaches the plugin
As of 1.5.0 the plugin reads page frontmatter directly via Docusaurus's
allContentLoaded lifecycle hook. The plugin captures frontMatter for
every doc, blog post, and MDX page (keyed by permalink) and consults it
during postBuild when emitting structured data for each route. No MDX or
theme changes are required - if your page has a frontmatter block, the
plugin sees it.
Two alternative delivery paths from 1.4.x remain supported and continue to work alongside the frontmatter path:
<script type="application/json" data-aeo-faq>/data-aeo-howto/data-aeo-softwareblocks injected via MDX, for cases where editing frontmatter is awkward (e.g. programmatically generated MDX, pages outside the content-docs / content-blog plugins).<meta name="aeo:proficiencyLevel">,<meta name="aeo:dependencies">,<meta name="aeo:speakable" content="false">set via a page-level<Head>component.
When frontmatter and the legacy path both declare the same schema for the same page, frontmatter wins and the plugin emits a verbose log entry naming the duplicate.
Configuring TechArticle route prefixes
By default, routes under /docs/* emit TechArticle (in place of generic
Article). To extend this to additional surfaces - for example, a curated
/ai/* content tree intended for AI retrieval - set
techArticleRoutePrefixes in themeConfig.structuredData:
themeConfig: {
structuredData: {
techArticleRoutePrefixes: ['/docs/', '/ai/'],
// ...
}
}Rules:
- Each prefix must start and end with
/. A misshaped prefix throws at plugin construction time with a clear error. - A route matches a prefix iff
route.startsWith(prefix). A landing route like/docsor/ai(no trailing slash) does not match/docs/or/ai/and stays a plainWebPage. This matches 1.4.x semantics for the docs landing page. - Setting an empty array (
techArticleRoutePrefixes: []) opts out ofTechArticleentirely. Pages under/docs/*will then emit no article node unless theirog:typeisarticle, in which case they emit genericArticle. - Unset means default (
['/docs/']).
TechArticle by docs instance (docs at the site root)
A prefix cannot identify docs pages when the docs instance is mounted at
the site root (routeBasePath: '/'), because every page starts with /.
For that layout list the content-docs instance ids whose doc pages should
emit TechArticle:
themeConfig: {
structuredData: {
techArticleRoutePrefixes: [],
techArticleDocsInstances: ['default', 'ai'],
}
}Rules:
- A route emits
TechArticlewhen it matches a prefix or is a doc page of a listed instance (the plugin learns each instance's doc permalinks inallContentLoaded). - Each instance's root route (the docs landing, or the homepage when docs
live at
/) stays a plainWebPage, exactly as the/docslanding always has. Generated category index pages are not docs and also stayWebPage. - Unset means
[](prefix matching only), so existing configurations are unaffected.
Breadcrumbs
BreadcrumbList is derived from the route path. For a blog route the
crumbs follow the blog instance (see above). For every other route they
are Home, then the root of the docs instance the route belongs to (for
example /docs, named by the label map or Documentation), then the
page. Other ancestor segments are folded into the leaf name
(Command Line Usage - exec), since a ListItem needs a URL and most
category segments have no page.
Set breadcrumbLinkAncestors: true to turn every ancestor that is itself a
built route (a category index page) into its own crumb with a URL:
Home > Command Line Usage > exec. The default is false, which keeps the
1.5.x shape.
breadcrumbLabelMap keys are single path segments ('quick-starts': 'Quick
Starts') or full route paths ('/blog/providers': 'Provider
Announcements'). A full-path key wins, so one segment can carry different
names in different places. Unmapped segments fall back to Documentation
for docs, Blog for blog, a blog instance's blogTitle for its own
root, and otherwise the raw segment.
Frontmatter-driven structured data
This is the preferred path as of 1.5.0. Declare the AEO fields in the normal Markdown / MDX frontmatter block at the top of your page:
---
title: What is StackQL?
description: StackQL is a SQL query runtime for cloud and SaaS APIs.
proficiencyLevel: Beginner
dependencies: stackql >= 0.6
faq:
- question: Is StackQL a database?
answer: No. StackQL is a query runtime that translates SQL into provider API calls.
- question: Does StackQL replace Terraform?
answer: Not directly. StackQL is for querying and mutating cloud state; Terraform is for declaring desired state.
howTo:
name: Install StackQL on macOS
totalTime: PT2M
steps:
- name: Install via Homebrew
text: Run "brew install stackql" in a terminal.
- name: Verify
text: Run "stackql --version" and confirm the version prints.
softwareApplication:
applicationCategory: DeveloperApplication
operatingSystem: macOS, Linux, Windows
featureList:
- SQL queries against cloud and SaaS providers
- Embeddable in CI pipelines
speakable:
cssSelector:
- h1
- .lead
- "[data-speakable]"
---Field reference:
| Frontmatter field | Type | Effect |
|---|---|---|
| faq | array of {question, answer} | Emits FAQPage node, links from primary entity via mainEntity |
| howTo | {name, totalTime?, estimatedCost?, description?, steps: [{name, text, url?, image?}]} | Emits HowTo node |
| softwareApplication | true or object with applicationCategory?, applicationSubCategory?, operatingSystem?, featureList?, softwareVersion?, downloadUrl?, license?, isAccessibleForFree?, offers? (any other schema.org property passes through) | Emits SoftwareApplication node, layered over themeConfig.structuredData.softwareApplication defaults |
| proficiencyLevel | "Beginner" | "Intermediate" | "Expert" | Added to TechArticle node (only fires on TechArticle routes) |
| dependencies | string | Added to TechArticle node |
| speakable | false or {cssSelector?, xpath?} | false opts the page out; object overrides the default selectors |
softwareApplication: true is the bare opt-in idiom - emits a
SoftwareApplication node with just the page title as the name, useful when
all the metadata lives elsewhere or you only need the schema marker.
The previously-documented <script type='application/json'> and
<meta name='aeo:...'> paths remain supported; use whichever fits your
workflow. Frontmatter is the cleaner of the two for net-new content.
Legacy: in-MDX <script> block path
Equivalent to the frontmatter path above but injected at the MDX level instead of declared in the frontmatter block. Use this when frontmatter is not available or convenient.
FAQPage (in an MDX page):
<script type="application/json" data-aeo-faq>
{`[
{
"question": "How do I install stackql?",
"answer": "Run brew install stackql on macOS, or download the latest release from GitHub for Linux and Windows."
},
{
"question": "Does stackql require a database?",
"answer": "No. stackql uses an embedded SQL engine by default and does not require any external database."
}
]`}
</script>(The {...} wrapper is MDX-required to pass the JSON string through verbatim
without MDX trying to interpret the curly braces.)
HowTo:
<script type="application/json" data-aeo-howto>
{`{
"name": "Install stackql on macOS",
"totalTime": "PT2M",
"steps": [
{ "name": "Install via Homebrew", "text": "Run 'brew install stackql' in a terminal." },
{ "name": "Verify the install", "text": "Run 'stackql --version' and confirm the version prints." }
]
}`}
</script>TechArticle (/docs/* routes, automatic - no payload needed). Optional
metadata via head tags in docusaurus.config.js or a page-level <Head>:
import Head from '@docusaurus/Head';
<Head>
<meta name="aeo:proficiencyLevel" content="Intermediate" />
<meta name="aeo:dependencies" content="stackql >= 0.6, Python 3.10" />
</Head>Valid proficiencyLevel values per schema.org: Beginner, Intermediate,
Expert.
SoftwareApplication:
<script type="application/json" data-aeo-software>
{`{
"name": "stackql",
"applicationCategory": "DeveloperApplication",
"applicationSubCategory": "CLI",
"operatingSystem": "macOS, Linux, Windows",
"softwareVersion": "0.7.0",
"downloadUrl": "https://github.com/stackql/stackql/releases/latest",
"featureList": [
"SQL queries against cloud and SaaS providers",
"Provider-agnostic IaC introspection",
"Embeddable in CI pipelines"
]
}`}
</script>SpeakableSpecification - emitted by default on every WebPage node with
selectors ["h1", "article p:first-of-type", "[data-speakable]"]. Override
globally:
themeConfig: {
structuredData: {
speakable: {
cssSelector: ['h1', '.lead', '[data-speakable]'],
},
// ...
}
}Or with xpath:
speakable: {
xpath: ['/html/head/title', "//*[@data-speakable]"],
}Opt out globally with speakable: false, or per-page with a head tag:
<Head>
<meta name="aeo:speakable" content="false" />
</Head>Graph linking
When a page emits a secondary entity (FAQPage, HowTo, or
SoftwareApplication), the primary entity's mainEntity is set to the
secondary's @id. Priority when more than one secondary is present:
HowTo -> FAQPage -> SoftwareApplication. For /docs/* and /blog/*
the primary is the article (TechArticle or Article). For non-article
routes the primary is the WebPage node.
Worked example
A hypothetical /docs/install/macos page declaring both an FAQ and a
SoftwareApplication payload, with the default speakable and the
automatic TechArticle classification, produces a @graph of the
following shape (abbreviated for clarity):
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "TechArticle",
"@id": "https://stackql.io/docs/install/macos/#article",
"isPartOf": { "@type": "WebPage", "@id": "https://stackql.io/docs/install/macos/#webpage" },
"headline": "Install stackql on macOS",
"mainEntityOfPage": { "@id": "https://stackql.io/docs/install/macos/#webpage" },
"mainEntity": { "@id": "https://stackql.io/docs/install/macos/#faq" },
"proficiencyLevel": "Beginner",
"dependencies": "Homebrew",
"image": { "@id": "https://stackql.io/docs/install/macos/#primaryimage" },
"publisher": { "@id": "https://stackql.io/#organization" },
"articleSection": ["Documentation"],
"inLanguage": "en-US"
},
{
"@type": "WebPage",
"@id": "https://stackql.io/docs/install/macos/#webpage",
"url": "https://stackql.io/docs/install/macos",
"isPartOf": { "@id": "https://stackql.io/#website" },
"breadcrumb": { "@id": "https://stackql.io/docs/install/macos/#breadcrumb" },
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": ["h1", "article p:first-of-type", "[data-speakable]"]
}
},
{ "@type": "ImageObject", "@id": "https://stackql.io/docs/install/macos/#primaryimage", "...": "..." },
{
"@type": "BreadcrumbList",
"@id": "https://stackql.io/docs/install/macos/#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://stackql.io" },
{ "@type": "ListItem", "position": 2, "name": "Documentation", "item": "https://stackql.io/docs" },
{ "@type": "ListItem", "position": 3, "name": "install - Install stackql on macOS" }
]
},
{ "@type": "WebSite", "@id": "https://stackql.io/#website", "...": "..." },
{ "@type": "Organization", "@id": "https://stackql.io/#organization", "...": "..." },
{
"@type": "FAQPage",
"@id": "https://stackql.io/docs/install/macos/#faq",
"mainEntity": [
{
"@type": "Question",
"@id": "https://stackql.io/docs/install/macos/#faq-q-0",
"name": "How do I install stackql?",
"acceptedAnswer": { "@type": "Answer", "text": "Run brew install stackql ..." }
}
]
},
{
"@type": "SoftwareApplication",
"@id": "https://stackql.io/docs/install/macos/#softwareapplication",
"name": "stackql",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Linux, Windows",
"softwareVersion": "0.7.0",
"featureList": ["SQL queries against cloud and SaaS providers", "..."]
}
]
}Note the @id chain: TechArticle.mainEntity -> FAQPage.@id,
TechArticle.isPartOf -> WebPage.@id, WebPage.breadcrumb ->
BreadcrumbList.@id, WebPage.isPartOf -> WebSite.@id,
WebSite.publisher -> Organization.@id. Every link resolves inside the
same @graph.
Validation
Malformed faq, howTo, or softwareApplication payloads throw at build
time with the route, field path, and expected shape - for example:
[docusaurus-plugin-structured-data] route "/docs/install/macos": faq[1].answer must be a non-empty stringso you find out at yarn build rather than at Google's Rich Results Test. The same goes for an applicationCategory outside schema.org's list, a softwareSourceCode block without codeRepository, a malformed featuredImageDimensions block, and a missing featuredImageDimensions on a site that emits Article or TechArticle pages (the message names the option and the first route that needed it).
Development
npm install
npm test # node --test: node builders, validators, dates, breadcrumbs, route matching, plugin optionsThe pure logic lives in src/lib.js; src/index.js is the Docusaurus lifecycle around it.
