Skip to content

Commit 5421d44

Browse files
estradinoclaude
andauthored
feat(seo): emit schema.org JSON-LD for AI/GEO discoverability (#286)
* feat(seo): emit schema.org JSON-LD for AI/GEO discoverability The site previously shipped zero structured data — the top finding of the GEO audit. This adds a single schema.org @graph per page via the shared BaseLayout, so AI engines (ChatGPT/Claude/Perplexity/Gemini/Google AI Overviews) and search engines get a self-contained, self-resolving entity graph on every URL they fetch: - Every page: Organization (with verified sameAs → github/linkedin/x/ youtube), WebSite, SoftwareApplication - Content pages: TechArticle (about→software, author/publisher→org, isPartOf→website) + BreadcrumbList New src/components/SchemaOrg.astro builds the graph; new getBreadcrumbTrail() in src/util/getBreadcrumb.ts yields breadcrumb labels + URLs (locale- and version-aware) and mirrors the visible breadcrumb labels for Google consistency. Descriptions use the real per-page `metaDescription` frontmatter (most pages set it) rather than the generic site fallback. dateModified and SearchAction are intentionally omitted — there is no honest data source (no date frontmatter; docs search is a client-side Algolia modal). offers is omitted from SoftwareApplication (no rating source; avoids a rich-result validator error) while isAccessibleForFree conveys the free tier. Also fixes leftover Astro-starter strings in the es/fr locale files (site.title/description/og.imageAlt) — without this the new WebSite.name identity node and OG tags would publish "Documentación de Astro" / "Documentation Astro" as the canonical brand name. Verified: JSON-LD renders valid across home / content / es / fr / ar-ae (RTL) / versioned / not-in-nav pages; reviewed by a 5-dimension adversarial pass (schema correctness, build safety, i18n, GEO efficacy, claim accuracy). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * seo: prefer per-page metaDescription for meta description tags ~2092 pages set a page-specific metaDescription frontmatter field that HeadSEO.astro ignored, so almost every page shipped the generic site tagline as its meta/og/twitter description. Extend the fallback chain to content.description || content.metaDescription || t('site.description') for both the description/og:description and twitter:description tags. This mirrors the description source already used by the JSON-LD in this PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ccbeadc commit 5421d44

6 files changed

Lines changed: 209 additions & 16 deletions

File tree

src/components/HeadSEO.astro

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const ogLocale = bcpLang.replace(/-/g, '_');
5252
<meta
5353
name="description"
5454
property="og:description"
55-
content={content.description ? content.description : t('site.description')}
55+
content={content.description || content.metaDescription || t('site.description')}
5656
/>
5757
<meta property="og:site_name" content={t('site.title')} />
5858

@@ -62,14 +62,9 @@ const ogLocale = bcpLang.replace(/-/g, '_');
6262
<meta name="twitter:title" content={content.title ?? t('site.title')} />
6363
<meta
6464
name="twitter:description"
65-
content={content.description ? content.description : t('site.description')}
65+
content={content.description || content.metaDescription || t('site.description')}
6666
/>
6767
<meta name="twitter:image" content={canonicalImageSrc} />
6868
<meta name="twitter:image:alt" content={imageAlt} />
6969

70-
<!--
71-
TODO: Add json+ld data, maybe https://schema.org/APIReference makes sense?
72-
Docs: https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data
73-
https://www.npmjs.com/package/schema-dts seems like a great resource for implementing this.
74-
Even better, there’s a React component that integrates with `schema-dts`: https://github.com/google/react-schemaorg
75-
-->
70+
<!-- schema.org JSON-LD is emitted by ../components/SchemaOrg.astro (included in BaseLayout). -->

src/components/SchemaOrg.astro

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
---
2+
/**
3+
* Emits schema.org JSON-LD (as a single @graph) for AI/GEO discoverability and
4+
* rich results. Every page carries the identity nodes (Organization, WebSite,
5+
* SoftwareApplication) so a crawler that fetches a single page gets a complete,
6+
* self-resolving entity graph; content (non-home) pages additionally carry a
7+
* TechArticle and a BreadcrumbList.
8+
*
9+
* Deliberately omitted (do not add without a real data source):
10+
* - dateModified: no date exists in frontmatter — fabricating one is worse than none.
11+
* - SearchAction: docs search is a client-side Algolia modal, not a query-string
12+
* URL, so there is no honest urlTemplate to advertise.
13+
*/
14+
import { normalizeLangTag } from '../i18n/bcp-normalize';
15+
import languages from '../i18n/languages';
16+
import { useTranslations } from '../i18n/util';
17+
import { getLanguageFromURL } from '../util';
18+
import { getBreadcrumbTrail } from '../util/getBreadcrumb';
19+
import { getOgImageUrl } from '../util/getOgImageUrl';
20+
21+
export interface Props {
22+
content: any;
23+
canonicalURL: URL;
24+
}
25+
26+
const { content = {}, canonicalURL } = Astro.props;
27+
const t = useTranslations(Astro);
28+
const site = Astro.site!; // configured as https://docs.openreplay.com/
29+
const abs = (path: string) => new URL(path, site).href;
30+
31+
const lang = getLanguageFromURL(canonicalURL.pathname || '/');
32+
const bcpLang = normalizeLangTag(lang);
33+
34+
// Stable global identifiers (resolved against the OpenReplay product site).
35+
const ORG_ID = 'https://openreplay.com/#organization';
36+
const WEBSITE_ID = `${site.origin}/#website`;
37+
const SOFTWARE_ID = 'https://openreplay.com/#software';
38+
39+
// Is this the (localized) docs homepage? Strip lang + optional version + trailing slash.
40+
const pageSlug = canonicalURL.pathname
41+
.replace(/^\/[a-z-]{2,5}(\/v\d+\.\d+\.\d+)?\//, '')
42+
.replace(/\/+$/, '');
43+
const isHome = pageSlug === '' || pageSlug === 'home';
44+
45+
const organization = {
46+
'@type': 'Organization',
47+
'@id': ORG_ID,
48+
name: 'OpenReplay',
49+
url: 'https://openreplay.com',
50+
logo: {
51+
'@type': 'ImageObject',
52+
url: abs('/android-chrome-512x512.png'),
53+
width: 512,
54+
height: 512,
55+
caption: 'OpenReplay',
56+
},
57+
description:
58+
'OpenReplay is an open-source session replay suite for developers — session replay, co-browsing, and product analytics, self-hosted or cloud.',
59+
sameAs: [
60+
'https://github.com/openreplay',
61+
'https://www.linkedin.com/company/openreplay',
62+
'https://x.com/OpenReplayHQ',
63+
'https://www.youtube.com/@openreplay',
64+
],
65+
};
66+
67+
const website = {
68+
'@type': 'WebSite',
69+
'@id': WEBSITE_ID,
70+
name: t('site.title'),
71+
url: abs(`/${lang}/home/`),
72+
publisher: { '@id': ORG_ID },
73+
inLanguage: Object.keys(languages).map((l) => normalizeLangTag(l)),
74+
};
75+
76+
const software = {
77+
'@type': 'SoftwareApplication',
78+
'@id': SOFTWARE_ID,
79+
name: 'OpenReplay',
80+
applicationCategory: 'DeveloperApplication',
81+
operatingSystem: 'Web, Linux',
82+
description:
83+
'Open-source session replay suite: session replay, co-browsing, and product analytics. Self-hosted or cloud.',
84+
url: 'https://openreplay.com',
85+
downloadUrl: 'https://github.com/openreplay/openreplay',
86+
softwareHelp: abs(`/${lang}/home/`),
87+
license: 'https://github.com/openreplay/openreplay/blob/main/LICENSE',
88+
// Free to self-host and on the free cloud tier. `offers` is intentionally
89+
// omitted: a SoftwareApplication Offer is only rich-result-eligible alongside
90+
// an aggregateRating, and we have no honest rating source to cite.
91+
isAccessibleForFree: true,
92+
publisher: { '@id': ORG_ID },
93+
};
94+
95+
const graph: Record<string, unknown>[] = [organization, website, software];
96+
97+
if (!isHome) {
98+
const title = content.title ?? t('site.title');
99+
const description = content.description || content.metaDescription || t('site.description');
100+
101+
const ogImageUrl = getOgImageUrl(canonicalURL.pathname, !!Astro.params.fallback);
102+
const imageSrc = content?.image?.src ?? ogImageUrl ?? t('site.og.imageSrc');
103+
104+
// Build the breadcrumb trail; guarantee the current page is the final crumb
105+
// even when it isn't present in the nav tree.
106+
const trail = getBreadcrumbTrail(canonicalURL.pathname);
107+
const stripSlash = (s: string) => s.replace(/\/+$/, '');
108+
if (stripSlash(trail[trail.length - 1]?.path ?? '') !== stripSlash(canonicalURL.pathname)) {
109+
trail.push({ name: title, path: canonicalURL.pathname });
110+
}
111+
// Top-level docs section (the crumb right after "Docs"), when the page isn't
112+
// itself a top-level page. Free to derive from the trail.
113+
const articleSection = trail.length > 2 ? trail[1].name : undefined;
114+
115+
graph.push({
116+
'@type': 'TechArticle',
117+
'@id': `${canonicalURL.href}#article`,
118+
headline: title,
119+
name: title,
120+
description,
121+
url: canonicalURL.href,
122+
mainEntityOfPage: canonicalURL.href,
123+
inLanguage: bcpLang,
124+
...(articleSection ? { articleSection } : {}),
125+
image: new URL(imageSrc, site).href,
126+
isPartOf: { '@id': WEBSITE_ID },
127+
author: { '@id': ORG_ID },
128+
publisher: { '@id': ORG_ID },
129+
about: { '@id': SOFTWARE_ID },
130+
breadcrumb: { '@id': `${canonicalURL.href}#breadcrumb` },
131+
});
132+
133+
graph.push({
134+
'@type': 'BreadcrumbList',
135+
'@id': `${canonicalURL.href}#breadcrumb`,
136+
itemListElement: trail.map((c, i) => ({
137+
'@type': 'ListItem',
138+
position: i + 1,
139+
name: c.name,
140+
item: abs(c.path),
141+
})),
142+
});
143+
}
144+
145+
const jsonLd = { '@context': 'https://schema.org', '@graph': graph };
146+
// Escape "<" so a value containing "</script>" can't break out of the tag.
147+
const serialized = JSON.stringify(jsonLd).replace(/</g, '\\u003c');
148+
---
149+
150+
<script type="application/ld+json" is:inline set:html={serialized} />

src/i18n/es/ui.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ export default UIDictionary({
1010
'aside.caution': 'Precaución',
1111
'aside.danger': 'Peligro',
1212
// Site settings
13-
'site.title': 'Documentación de Astro',
13+
'site.title': 'Documentación de OpenReplay',
1414
'site.description':
15-
'Construye páginas web más rápidas con menos Javascript en el lado del cliente.',
15+
'Suite de session replay de código abierto, creada para desarrolladores.',
1616
'site.og.imageSrc': '/default-og-image.png?v=1',
17-
'site.og.imageAlt':
18-
'Logo de Astro en el espacio estrellado, con un planeta púrpura parecido a Saturno flotando en el fondo a la derecha.',
17+
'site.og.imageAlt': 'Suite de session replay de código abierto, creada para desarrolladores.',
1918
// Left Sidebar
2019
'leftSidebar.a11yTitle': 'Primario',
2120
'leftSidebar.learnTab': 'Aprenda',

src/i18n/fr/ui.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ export default UIDictionary({
44
'a11y.skipLink': 'Aller au contenu principal',
55
'navbar.a11yTitle': 'Navigation principale',
66
// Site settings
7-
'site.title': 'Documentation Astro',
7+
'site.title': 'Documentation OpenReplay',
88
'site.description':
9-
'Compilez des sites plus rapidement avec moins de JavaScript pour vos utilisateurs.',
9+
'Suite de session replay open source, conçue pour les développeurs.',
1010
'site.og.imageSrc': '/default-og-image.png?v=1',
11-
'site.og.imageAlt':
12-
"Logo d'Astro dans l'espace, avec une planète violette dans le style de saturne flottant à droite de l'image.",
11+
'site.og.imageAlt': 'Suite de session replay open source, conçue pour les développeurs.',
1312
// Left Sidebar
1413
'leftSidebar.a11yTitle': 'Navigation du site',
1514
'leftSidebar.learnTab': 'Apprendre',

src/layouts/BaseLayout.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import Header from '../components/Header/Header.astro';
33
import HeadCommon from '../components/HeadCommon.astro';
44
import HeadSEO from '../components/HeadSEO.astro';
5+
import SchemaOrg from '../components/SchemaOrg.astro';
56
import LeftSidebar from '../components/LeftSidebar/LeftSidebar.astro';
67
import Hero from '../components/Hero.astro';
78
import { normalizeLangTag } from '../i18n/bcp-normalize';
@@ -42,6 +43,7 @@ const breadcrumb = getBreadcrumb(currentPage);
4243
<head>
4344
<HeadCommon />
4445
<HeadSEO {content} {canonicalURL} site={Astro.site} />
46+
<SchemaOrg {content} {canonicalURL} />
4547
<title set:html={formatTitle(content)}></title>
4648
<!-- Following import contains openreplay Tracker, fonts used in Docs -->
4749
<HeadImports />

src/util/getBreadcrumb.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,51 @@ export function getBreadcrumb(pathname: string): string[] {
4141
walk(nav.children, []);
4242
return ['Docs', ...chain];
4343
}
44+
45+
export interface Crumb {
46+
name: string;
47+
/** Locale- (and version-) aware absolute path, e.g. "/en/deployment/deploy-aws/". */
48+
path: string;
49+
}
50+
51+
/**
52+
* Like {@link getBreadcrumb}, but returns each crumb's label *and* a navigable
53+
* path (for structured data such as schema.org BreadcrumbList). The trail always
54+
* starts at the localized docs home and mirrors the visible breadcrumb labels.
55+
*/
56+
export function getBreadcrumbTrail(pathname: string): Crumb[] {
57+
const lang = getLanguageFromURL(pathname) || 'en';
58+
const version = getVersionFromURL(pathname);
59+
const versionSeg = version ? `/v${version}` : '';
60+
const toPath = (slug: string) => `/${lang}${versionSeg}/${norm(slug)}/`;
61+
62+
let slug = removeTrailingSlash(removeLeadingSlash(pathname));
63+
slug = slug.replace(new RegExp(`^${lang}(/|$)`), '');
64+
if (version) slug = slug.replace(`v${version}/`, '');
65+
const target = norm(slug);
66+
67+
const nav = localizeNav(lang);
68+
const chain: Crumb[] = [];
69+
70+
const walk = (items: NavItem[] | undefined, acc: Crumb[]): boolean => {
71+
if (!items) return false;
72+
for (const it of items) {
73+
if (it.isSectionTitle) continue;
74+
const label = typeof it.text === 'string' ? it.text : String(it.text);
75+
const crumb: Crumb | null = it.slug != null ? { name: label, path: toPath(it.slug) } : null;
76+
if (it.slug != null && norm(it.slug) === target) {
77+
chain.push(...acc, crumb!);
78+
return true;
79+
}
80+
if (it.children && it.children.length) {
81+
const nextAcc = crumb ? [...acc, crumb] : acc;
82+
if (walk(it.children, nextAcc)) return true;
83+
}
84+
}
85+
return false;
86+
};
87+
88+
walk(nav.children, []);
89+
// Root crumb is version-aware so a versioned page's trail stays internally consistent.
90+
return [{ name: 'Docs', path: `/${lang}${versionSeg}/home/` }, ...chain];
91+
}

0 commit comments

Comments
 (0)