diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx
index 779687f77152..edee5d2e291c 100644
--- a/src/components/Layout/Layout.tsx
+++ b/src/components/Layout/Layout.tsx
@@ -8,6 +8,7 @@ import {CONTENT_WRAPPER_ID, DEFAULT_THEME, MENU_ID} from '../../constants';
import {EnvironmentContext} from '../../contexts';
import {useLocale} from '../../hooks/useLocale';
import {block} from '../../utils';
+import type {JsonLd} from '../../utils/structuredData';
import {CustomScrollbar} from '../CustomScrollbar';
import {Footer} from '../Footer/Footer';
import {Menu} from '../Menu/Menu';
@@ -31,6 +32,8 @@ export type LayoutProps = {
noScroll?: boolean;
meta?: MetaProps;
hideLocalePicker?: boolean;
+ /** Schema.org blocks rendered as JSON-LD inside
. */
+ jsonLd?: JsonLd[];
};
export const Layout: React.FC = ({
@@ -43,6 +46,7 @@ export const Layout: React.FC = ({
noScroll = false,
hideLocalePicker = false,
meta = {},
+ jsonLd,
}) => {
const locale = useLocale();
@@ -104,6 +108,13 @@ export const Layout: React.FC = ({
{`Gravity UI${title ? ` – ${title}` : ''}`}
+ {jsonLd?.map((item, index) => (
+
+ ))}
diff --git a/src/pages/components/[libId]/[componentId].tsx b/src/pages/components/[libId]/[componentId].tsx
index 83ca73960bd6..1ffd70ade92f 100644
--- a/src/pages/components/[libId]/[componentId].tsx
+++ b/src/pages/components/[libId]/[componentId].tsx
@@ -12,10 +12,13 @@ import {Component} from '../../../components/Component/Component';
import {ComponentsLayout} from '../../../components/ComponentsLayout/ComponentsLayout';
import {Layout} from '../../../components/Layout/Layout';
import {libs} from '../../../content/components';
+import {useLocale} from '../../../hooks/useLocale';
import {getMaintainers} from '../../../utils';
+import {getCanonicalUrlForLocale} from '../../../utils/canonical';
import {getLibComponents} from '../../../utils/components';
import {getI18nProps} from '../../../utils/i18next';
import {getComponentMeta} from '../../../utils/meta';
+import {getBreadcrumbJsonLd, getTechArticleJsonLd} from '../../../utils/structuredData';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const components = getLibComponents(ctx.params?.libId as string);
@@ -72,6 +75,7 @@ export const ComponentPage = ({
readmeContent: string;
}) => {
const {t} = useTranslation();
+ const locale = useLocale();
const componentsLib = libs.find((item) => item.id === lib.config.id);
const component = componentsLib?.components.find((item) => item.id === componentId);
@@ -93,6 +97,26 @@ export const ComponentPage = ({
const maintainers = getMaintainers(lib, `/src/components/${component.title}`);
+ const componentPath = `/components/${lib.config.id}/${component.id}`;
+ const componentUrl = getCanonicalUrlForLocale(locale, componentPath);
+
+ const structuredData = [
+ getBreadcrumbJsonLd([
+ {name: t('menu_components'), url: getCanonicalUrlForLocale(locale, '/components')},
+ {
+ name: lib.config.title,
+ url: getCanonicalUrlForLocale(locale, `/components/${lib.config.id}`),
+ },
+ {name: component.title, url: componentUrl},
+ ]),
+ getTechArticleJsonLd({
+ headline: `${lib.config.title} – ${component.title}`,
+ description: componentMeta.description,
+ url: componentUrl,
+ locale,
+ }),
+ ];
+
const sections = React.useMemo(() => {
return libs.map(({id, title, components}) => {
return {
@@ -118,6 +142,7 @@ export const ComponentPage = ({
hideFooter
noScroll={!isMobile}
meta={componentMeta}
+ jsonLd={structuredData}
>
{
const sectionId = ctx.params?.sectionId as string;
@@ -40,6 +43,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
export const ArticlePage = ({section, article}: {section: Section; article: Article}) => {
const {i18n, t} = useTranslation();
+ const locale = useLocale();
const sectionId = section.id;
const articleId = article.id;
@@ -75,12 +79,33 @@ export const ArticlePage = ({section, article}: {section: Section; article: Arti
};
});
+ const articlePath = `/design/${sectionId}/${articleId}`;
+ const articleUrl = getCanonicalUrlForLocale(locale, articlePath);
+
+ const structuredData = [
+ getBreadcrumbJsonLd([
+ {name: t('menu_design'), url: getCanonicalUrlForLocale(locale, '/design')},
+ {
+ name: sectionTitle,
+ url: getCanonicalUrlForLocale(locale, `/design/${sectionId}`),
+ },
+ {name: articleTitle, url: articleUrl},
+ ]),
+ getTechArticleJsonLd({
+ headline: `${sectionTitle} – ${articleTitle}`,
+ description: articleMeta.description,
+ url: articleUrl,
+ locale,
+ }),
+ ];
+
return (
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 51591f20d49a..55710f0fe9c0 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -7,6 +7,7 @@ import backgroundAsset from '../assets/background.jpg';
import {Landing} from '../components/Landing/Landing';
import {Layout} from '../components/Layout/Layout';
import {getI18nProps} from '../utils/i18next';
+import {getOrganizationJsonLd, getWebSiteJsonLd} from '../utils/structuredData';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const [libs, i18nProps] = await Promise.all([
@@ -35,7 +36,8 @@ const Home = ({libs}: {libs: LibWithMetadata[]}) => {
type="image/jpeg"
/>
-
+ {/* Emitted on the homepage only: these describe the site as a whole, not each page. */}
+
>
diff --git a/src/pages/libraries/[libId]/index.tsx b/src/pages/libraries/[libId]/index.tsx
index 13cf9c575afb..6341c993b9ec 100644
--- a/src/pages/libraries/[libId]/index.tsx
+++ b/src/pages/libraries/[libId]/index.tsx
@@ -6,6 +6,8 @@ import {ServerApi} from '../../../api/server';
import {Layout} from '../../../components/Layout/Layout';
import {Library} from '../../../components/Library/Library';
import {getI18nProps, getLibraryMeta, isValidLibId} from '../../../utils';
+import {SITE_URL} from '../../../utils/canonical';
+import {getSoftwareSourceCodeJsonLd} from '../../../utils/structuredData';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const libId = ctx.params?.libId as string;
@@ -33,10 +35,20 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
export const LibraryPage = ({lib}: {lib: LibWithFullData}) => {
const {t} = useTranslation();
+ const meta = getLibraryMeta({id: lib.config.id, title: lib.config.title}, t);
+
return (
diff --git a/src/utils/structuredData.ts b/src/utils/structuredData.ts
new file mode 100644
index 000000000000..7622c10460fa
--- /dev/null
+++ b/src/utils/structuredData.ts
@@ -0,0 +1,89 @@
+import {SITE_URL} from './canonical';
+
+const SCHEMA_CONTEXT = 'https://schema.org';
+
+/**
+ * Profiles the site itself links to, so they are authoritative rather than guessed.
+ * The npm organisation is deliberately absent: it is not linked from the site and could not
+ * be verified, and an unverified sameAs is worse than a missing one.
+ */
+const OFFICIAL_PROFILES = [
+ 'https://github.com/gravity-ui',
+ 'https://t.me/gravity_ui',
+ 'https://www.figma.com/community/file/1271150067798118027/Gravity-UI-Design-System-(Beta)',
+];
+
+export type JsonLd = Record;
+
+export const getOrganizationJsonLd = (): JsonLd => ({
+ '@context': SCHEMA_CONTEXT,
+ '@type': 'Organization',
+ name: 'Gravity UI',
+ url: `${SITE_URL}/`,
+ logo: `${SITE_URL}/favicon-512x512.png`,
+ sameAs: OFFICIAL_PROFILES,
+});
+
+export const getWebSiteJsonLd = (): JsonLd => ({
+ '@context': SCHEMA_CONTEXT,
+ '@type': 'WebSite',
+ name: 'Gravity UI',
+ url: `${SITE_URL}/`,
+ // No `potentialAction: SearchAction` on purpose: the site has no search endpoint, so
+ // declaring one would be markup that does not describe the page.
+});
+
+export const getBreadcrumbJsonLd = (items: {name: string; url: string}[]): JsonLd => ({
+ '@context': SCHEMA_CONTEXT,
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ })),
+});
+
+export const getSoftwareSourceCodeJsonLd = (params: {
+ name: string;
+ description?: string;
+ githubId: string;
+ url: string;
+}): JsonLd => {
+ const {name, description, githubId, url} = params;
+
+ return {
+ '@context': SCHEMA_CONTEXT,
+ '@type': 'SoftwareSourceCode',
+ name,
+ url,
+ codeRepository: `https://github.com/${githubId}`,
+ programmingLanguage: 'TypeScript',
+ ...(description ? {description} : {}),
+ };
+};
+
+export const getTechArticleJsonLd = (params: {
+ headline: string;
+ description?: string;
+ url: string;
+ locale: string;
+}): JsonLd => {
+ const {headline, description, url, locale} = params;
+
+ return {
+ '@context': SCHEMA_CONTEXT,
+ '@type': 'TechArticle',
+ headline,
+ url,
+ inLanguage: locale,
+ isPartOf: {
+ '@type': 'WebSite',
+ name: 'Gravity UI',
+ url: `${SITE_URL}/`,
+ },
+ // datePublished/dateModified are omitted rather than invented: the readme content is
+ // fetched from GitHub raw URLs, which carry no reliable authoring date.
+ ...(description ? {description} : {}),
+ };
+};