Skip to content

Commit 4e044a3

Browse files
committed
feat: add JSON‑LD Organization, BreadcrumbList, and Article schema
Signed-off-by: amaan-bhati <[email protected]>
1 parent 2b066db commit 4e044a3

File tree

4 files changed

+344
-3
lines changed

4 files changed

+344
-3
lines changed

docusaurus.config.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,36 @@ module.exports = {
7979
logo: "https://keploy.io/docs/img/favicon.png",
8080
}),
8181
},
82+
{
83+
tagName: "script",
84+
attributes: {
85+
type: "application/ld+json",
86+
},
87+
innerHTML: JSON.stringify({
88+
"@context": "https://schema.org/",
89+
"@type": "Organization",
90+
name: "Keploy",
91+
url: "https://keploy.io/",
92+
logo: "https://keploy.io/docs/img/favicon.png",
93+
}),
94+
},
95+
{
96+
tagName: "script",
97+
attributes: {
98+
type: "application/ld+json",
99+
},
100+
innerHTML: JSON.stringify({
101+
"@context": "https://schema.org/",
102+
"@type": "WebSite",
103+
name: "Keploy Documentation",
104+
url: "https://keploy.io/docs/",
105+
potentialAction: {
106+
"@type": "SearchAction",
107+
target: "https://keploy.io/docs/search?q={search_term_string}",
108+
"query-input": "required name=search_term_string",
109+
},
110+
}),
111+
},
82112
],
83113
colorMode: {
84114
defaultMode: "light",

src/theme/DocBreadcrumbs/index.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import React from "react";
9+
import clsx from "clsx";
10+
import {ThemeClassNames} from "@docusaurus/theme-common";
11+
import {useSidebarBreadcrumbs} from "@docusaurus/plugin-content-docs/client";
12+
import {useHomePageRoute} from "@docusaurus/theme-common/internal";
13+
import {useLocation} from "@docusaurus/router";
14+
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
15+
import Link from "@docusaurus/Link";
16+
import {translate} from "@docusaurus/Translate";
17+
import Head from "@docusaurus/Head";
18+
import HomeBreadcrumbItem from "@theme/DocBreadcrumbs/Items/Home";
19+
20+
import styles from "./styles.module.css";
21+
22+
function BreadcrumbsItemLink({children, href, isLast}) {
23+
const className = "breadcrumbs__link";
24+
if (isLast) {
25+
return (
26+
<span className={className} itemProp="name">
27+
{children}
28+
</span>
29+
);
30+
}
31+
return href ? (
32+
<Link className={className} href={href} itemProp="item">
33+
<span itemProp="name">{children}</span>
34+
</Link>
35+
) : (
36+
<span className={className}>{children}</span>
37+
);
38+
}
39+
40+
function BreadcrumbsItem({children, active, index, addMicrodata}) {
41+
return (
42+
<li
43+
{...(addMicrodata && {
44+
itemScope: true,
45+
itemProp: "itemListElement",
46+
itemType: "https://schema.org/ListItem",
47+
})}
48+
className={clsx("breadcrumbs__item", {
49+
"breadcrumbs__item--active": active,
50+
})}
51+
>
52+
{children}
53+
<meta itemProp="position" content={String(index + 1)} />
54+
</li>
55+
);
56+
}
57+
58+
export default function DocBreadcrumbs() {
59+
const breadcrumbs = useSidebarBreadcrumbs();
60+
const homePageRoute = useHomePageRoute();
61+
const {siteConfig} = useDocusaurusContext();
62+
const {pathname} = useLocation();
63+
64+
if (!breadcrumbs) {
65+
return null;
66+
}
67+
68+
const toAbsoluteUrl = (baseUrl, url) => {
69+
if (!url) {
70+
return null;
71+
}
72+
if (url.startsWith("http://") || url.startsWith("https://")) {
73+
return url;
74+
}
75+
const trimmedBase = baseUrl?.replace(/\/$/, "") ?? "";
76+
const normalizedPath = url.startsWith("/") ? url : `/${url}`;
77+
return `${trimmedBase}${normalizedPath}`;
78+
};
79+
80+
const breadcrumbItems = [];
81+
const pushBreadcrumbItem = (item) => {
82+
if (!item?.item || !item?.name) {
83+
return;
84+
}
85+
if (breadcrumbItems.some((existing) => existing.item === item.item)) {
86+
return;
87+
}
88+
breadcrumbItems.push(item);
89+
};
90+
91+
if (siteConfig?.url) {
92+
pushBreadcrumbItem({name: "Home", item: siteConfig.url});
93+
}
94+
95+
if (siteConfig?.url && siteConfig?.baseUrl) {
96+
const docsUrl = toAbsoluteUrl(siteConfig.url, siteConfig.baseUrl);
97+
if (docsUrl && docsUrl !== siteConfig.url) {
98+
pushBreadcrumbItem({name: "Docs", item: docsUrl});
99+
}
100+
}
101+
102+
if (breadcrumbs.length > 0) {
103+
breadcrumbs.forEach((crumb, index) => {
104+
const isLast = index === breadcrumbs.length - 1;
105+
const href = crumb.href || (isLast ? pathname : null);
106+
const absoluteUrl = toAbsoluteUrl(siteConfig?.url, href);
107+
if (!absoluteUrl) {
108+
return;
109+
}
110+
pushBreadcrumbItem({
111+
name: crumb.label,
112+
item: absoluteUrl,
113+
});
114+
});
115+
}
116+
117+
const breadcrumbSchema =
118+
breadcrumbItems.length > 0
119+
? {
120+
"@context": "https://schema.org",
121+
"@type": "BreadcrumbList",
122+
itemListElement: breadcrumbItems.map((item, index) => ({
123+
"@type": "ListItem",
124+
position: index + 1,
125+
name: item.name,
126+
item: item.item,
127+
})),
128+
}
129+
: null;
130+
131+
return (
132+
<>
133+
{breadcrumbSchema && (
134+
<Head>
135+
<script type="application/ld+json">
136+
{JSON.stringify(breadcrumbSchema)}
137+
</script>
138+
</Head>
139+
)}
140+
<nav
141+
className={clsx(
142+
ThemeClassNames.docs.docBreadcrumbs,
143+
styles.breadcrumbsContainer
144+
)}
145+
aria-label={translate({
146+
id: "theme.docs.breadcrumbs.navAriaLabel",
147+
message: "Breadcrumbs",
148+
description: "The ARIA label for the breadcrumbs",
149+
})}
150+
>
151+
<ul
152+
className="breadcrumbs"
153+
itemScope
154+
itemType="https://schema.org/BreadcrumbList"
155+
>
156+
{homePageRoute && <HomeBreadcrumbItem />}
157+
{breadcrumbs.map((item, idx) => {
158+
const isLast = idx === breadcrumbs.length - 1;
159+
const href =
160+
item.type === "category" && item.linkUnlisted
161+
? undefined
162+
: item.href;
163+
return (
164+
<BreadcrumbsItem
165+
key={idx}
166+
active={isLast}
167+
index={idx}
168+
addMicrodata={!!href}
169+
>
170+
<BreadcrumbsItemLink href={href} isLast={isLast}>
171+
{item.label}
172+
</BreadcrumbsItemLink>
173+
</BreadcrumbsItem>
174+
);
175+
})}
176+
</ul>
177+
</nav>
178+
</>
179+
);
180+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
.breadcrumbsContainer {
9+
--ifm-breadcrumb-size-multiplier: 0.8;
10+
margin-bottom: 0.8rem;
11+
}

src/theme/DocItem/index.js

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ import DocBreadcrumbs from "@theme/DocBreadcrumbs";
1919
import Layout from "@docusaurus/core/lib/client/theme-fallback/Layout";
2020
import Head from "@docusaurus/Head";
2121
import MDXContent from "@theme/MDXContent";
22+
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
2223

2324
export default function DocItem(props) {
2425
const {content: DocContent} = props;
2526
const {metadata, frontMatter, assets} = DocContent;
2627
const {
27-
keywords,
28+
keywords: frontMatterKeywords,
2829
hide_title: hideTitle,
2930
hide_table_of_contents: hideTableOfContents,
3031
toc_min_heading_level: tocMinHeadingLevel,
@@ -42,19 +43,138 @@ export default function DocItem(props) {
4243
!hideTableOfContents && DocContent.toc && DocContent.toc.length > 0;
4344
const renderTocDesktop =
4445
canRenderTOC && (windowSize === "desktop" || windowSize === "ssr");
45-
46+
const {siteConfig} = useDocusaurusContext();
47+
const toIsoDate = (value) => {
48+
if (!value) {
49+
return null;
50+
}
51+
const isEpochSeconds = Number.isFinite(value) && value < 1e12;
52+
const date = new Date(isEpochSeconds ? value * 1000 : value);
53+
if (Number.isNaN(date.getTime())) {
54+
return null;
55+
}
56+
return date.toISOString();
57+
};
58+
const toAbsoluteUrl = (baseUrl, url) => {
59+
if (!url) {
60+
return null;
61+
}
62+
if (url.startsWith("http://") || url.startsWith("https://")) {
63+
return url;
64+
}
65+
const trimmedBase = baseUrl?.replace(/\/$/, "") ?? "";
66+
const normalizedPath = url.startsWith("/") ? url : `/${url}`;
67+
return `${trimmedBase}${normalizedPath}`;
68+
};
69+
const toArray = (value) => {
70+
if (!value) {
71+
return [];
72+
}
73+
return Array.isArray(value) ? value : [value];
74+
};
75+
const toPersonList = (value) => {
76+
return toArray(value)
77+
.map((item) => {
78+
if (!item) {
79+
return null;
80+
}
81+
if (typeof item === "string") {
82+
return {["@type"]: "Person", name: item};
83+
}
84+
if (item.name) {
85+
return {
86+
"@type": "Person",
87+
name: item.name,
88+
...(item.url ? {url: item.url} : {}),
89+
};
90+
}
91+
return null;
92+
})
93+
.filter(Boolean);
94+
};
95+
const pageUrl = toAbsoluteUrl(siteConfig?.url, metadata?.permalink);
96+
const modifiedTime = toIsoDate(
97+
metadata?.lastUpdatedAt || frontMatter?.lastUpdatedAt
98+
);
99+
const publishedTime = toIsoDate(frontMatter?.date || frontMatter?.publishedAt);
100+
const schemaTypeFromFrontMatter =
101+
frontMatter?.schemaType || frontMatter?.schema_type;
102+
const isApi =
103+
frontMatter?.apiReference === true ||
104+
frontMatter?.type === "api" ||
105+
(frontMatter?.tags || []).includes?.("api");
106+
const isBlog =
107+
frontMatter?.type === "blog" ||
108+
frontMatter?.blog === true ||
109+
(frontMatter?.tags || []).includes?.("blog");
110+
const schemaType = schemaTypeFromFrontMatter
111+
? schemaTypeFromFrontMatter
112+
: isApi
113+
? "APIReference"
114+
: isBlog
115+
? "BlogPosting"
116+
: "Article";
117+
const authorList = toPersonList(frontMatter?.author || frontMatter?.authors);
118+
const maintainerList = toPersonList(frontMatter?.maintainer);
119+
const contributorList = toPersonList(frontMatter?.contributor);
120+
const combinedContributors = [...maintainerList, ...contributorList];
121+
const keywords = frontMatter?.keywords || metadata?.keywords;
122+
const metaKeywords = frontMatterKeywords ?? metadata?.keywords;
123+
const programmingLanguage =
124+
frontMatter?.programmingLanguage || frontMatter?.programmingLanguages;
125+
const targetPlatform = frontMatter?.targetPlatform;
126+
const proficiencyLevel = frontMatter?.proficiencyLevel;
127+
const articleSchema =
128+
pageUrl && title
129+
? {
130+
"@context": "https://schema.org",
131+
"@type": schemaType,
132+
headline: title,
133+
description,
134+
...(modifiedTime ? {dateModified: modifiedTime} : {}),
135+
...(publishedTime ? {datePublished: publishedTime} : {}),
136+
...(keywords ? {keywords} : {}),
137+
...(authorList.length ? {author: authorList} : {}),
138+
...(combinedContributors.length
139+
? {contributor: combinedContributors}
140+
: {}),
141+
...(proficiencyLevel ? {proficiencyLevel} : {}),
142+
...(programmingLanguage ? {programmingLanguage} : {}),
143+
...(targetPlatform ? {targetPlatform} : {}),
144+
mainEntityOfPage: {
145+
"@type": "WebPage",
146+
"@id": pageUrl,
147+
},
148+
publisher: {
149+
"@type": "Organization",
150+
name: "Keploy",
151+
logo: {
152+
"@type": "ImageObject",
153+
url: "https://keploy.io/docs/img/favicon.png",
154+
},
155+
},
156+
}
157+
: null;
46158
const MDXComponent = props.content;
47159
return (
48160
<>
49161
<Head>
50162
<title>{title}</title>
51163
{description && <meta name="description" content={description} />}
164+
{modifiedTime && (
165+
<meta property="article:modified_time" content={modifiedTime} />
166+
)}
167+
{articleSchema && (
168+
<script type="application/ld+json">
169+
{JSON.stringify(articleSchema)}
170+
</script>
171+
)}
52172
</Head>
53173
<Layout
54174
{...{
55175
title,
56176
description,
57-
keywords,
177+
keywords: metaKeywords,
58178
image,
59179
}}
60180
/>

0 commit comments

Comments
 (0)