Skip to content
Closed
138 changes: 133 additions & 5 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,144 @@ const config: Config = {
},
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "Corporation",
"@type": "Organization",
"@id": "https://goteleport.com/#organization",
name: "Teleport",
alternateName: "Gravitational Inc.",
legalName: "Gravitational, Inc.",
url: "https://goteleport.com/",
logo: "https://goteleport.com/static/og-image.png",
logo: {
"@type": "ImageObject",
url: "https://goteleport.com/static/og-image.png",
width: 1200,
height: 630,
},
description:
"Teleport is the Infrastructure Identity Company. Teleport gives every user, machine, and AI agent a cryptographic identity and governed access path across servers, Kubernetes, databases, cloud, and agentic AI workloads. No shared secrets, no standing privileges, no anonymous access.",
foundingDate: "2015",
numberOfEmployees: {
"@type": "QuantitativeValue",
value: 230,
},
address: {
"@type": "PostalAddress",
streetAddress: "2100 Franklin St, Suite 400",
addressLocality: "Oakland",
addressRegion: "CA",
postalCode: "94612",
addressCountry: "US",
},
contactPoint: {
"@type": "ContactPoint",
telephone: "+1-855-818-9008",
contactType: "sales",
areaServed: "Worldwide",
availableLanguage: "English",
},
hasCredential: [
{
"@type": "EducationalOccupationalCredential",
name: "SOC 2 Type II",
credentialCategory: "Security Certification",
recognizedBy: {
"@type": "Organization",
name: "AICPA",
url: "https://www.aicpa-cima.com/",
},
url: "https://trust.goteleport.com",
},
{
"@type": "EducationalOccupationalCredential",
name: "ISO 27001:2022",
credentialCategory: "Information Security Certification",
url: "https://trust.goteleport.com",
},
{
"@type": "EducationalOccupationalCredential",
name: "ISO 27701:2019",
credentialCategory: "Privacy Information Management Certification",
url: "https://trust.goteleport.com",
},
{
"@type": "EducationalOccupationalCredential",
name: "PCI DSS 4.0",
credentialCategory: "Payment Security Certification",
url: "https://trust.goteleport.com",
},
{
"@type": "EducationalOccupationalCredential",
name: "HIPAA",
credentialCategory: "Healthcare Compliance Certification",
url: "https://trust.goteleport.com",
},
],
subjectOf: {
"@type": "Article",
name: "Teleport (software)",
url: "https://en.wikipedia.org/wiki/Teleport_(software)",
},
founder: [
{
"@type": "Person",
"@id": "https://goteleport.com/learn/author/evkontsevoy/#person",
name: "Ev Kontsevoy",
jobTitle: "Co-Founder and CEO",
worksFor: { "@id": "https://goteleport.com/#organization" },
url: "https://goteleport.com/learn/author/evkontsevoy/",
sameAs: [
"https://www.linkedin.com/in/kontsevoy",
"https://x.com/kontsevoy",
"https://github.com/kontsevoy",
],
},
{
"@type": "Person",
"@id": "https://goteleport.com/#person-klizhentas",
name: "Aleksandr Klizhentas",
jobTitle: "Co-Founder and CTO",
worksFor: { "@id": "https://goteleport.com/#organization" },
sameAs: [
"https://www.linkedin.com/in/alexander-klizhentas",
"https://github.com/klizhentas",
],
},
{
"@type": "Person",
"@id":
"https://goteleport.com/learn/author/taylorwakefield/#person",
name: "Taylor Wakefield",
jobTitle: "Co-Founder and COO",
worksFor: { "@id": "https://goteleport.com/#organization" },
url: "https://goteleport.com/learn/author/taylorwakefield/",
sameAs: ["https://www.linkedin.com/in/taylorwakefield"],
},
],
sameAs: [
"https://www.youtube.com/channel/UCmtTJaeEKYxCjfNGiijOyJw",
"https://twitter.com/goteleport/",
"https://www.linkedin.com/company/go-teleport/",
"https://x.com/goteleport",
"https://www.youtube.com/gravitational",
"https://www.linkedin.com/company/go-teleport",
"https://github.com/gravitational/teleport",
"https://www.crunchbase.com/organization/gravitational",
"https://en.wikipedia.org/wiki/Teleport_(software)",
"https://www.wikidata.org/wiki/Q96407919",
],
knowsAbout: [
"Infrastructure Identity",
"Privileged Access Management",
"Zero Trust Security",
"Just-in-Time Access",
"Non-Human Identity Security",
"Machine and Workload Identity",
"Agentic AI Security",
"MCP Security",
"Identity Governance",
"Identity Threat Detection and Response",
"Compliance Automation",
"Kubernetes Access Control",
"Database Access Control",
"Cryptographic Identity",
"Ephemeral Privileges",
"Secretless Authentication",
],
}),
},
Expand Down
6 changes: 6 additions & 0 deletions frontmatter_fields.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ properties:
type: string
videoBannerDescription:
type: string
# The date the page was first published, used as datePublished in the page's
# JSON-LD structured data. Format: YYYY-MM-DD or a full ISO 8601 timestamp.
# To set dateModified, use the native Docusaurus field `last_update.date`.
date_published:
type: string
pattern: "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?(Z|[+-]\\d{2}:\\d{2})?)?$"
# This field is used to specify specific agent skills that are relevant to a page.
# It is an array of strings, where each string represents a skill name.
skills:
Expand Down
27 changes: 27 additions & 0 deletions scripts/prepare-files.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createRequire } from "module";
import { join, resolve, dirname } from "path";
import { glob } from "glob";
import { parseSkillMarkdown } from "../server/parse-skill-md";
import { readChangelogReleaseDate } from "../server/changelog-release-date";
import {
createIconSubset,
extractIconNames,
Expand All @@ -23,6 +24,7 @@ const SIDEBAR_FILENAME = "sidebars.json";
const VERSION_FILENAME = "versions.json";
const SKILLS_FILENAME = "data/skills.json";
const MERMAID_ICON_PACKS_FILENAME = "data/mermaid-icon-packs.json";
const VERSION_RELEASE_DATES_FILENAME = "data/version-release-dates.json";
const GET_VERSION_SIDEBAR_FILENAME = (version) =>
`versioned_sidebars/version-${version}-sidebars.json`;

Expand Down Expand Up @@ -84,6 +86,31 @@ versions.forEach((version) => {

writeVersions();

// Newest release date of each docs version, taken from its CHANGELOG.md. Used
// as the dateModified fallback in JSON-LD structured data. Keys are Docusaurus
// version names ("current" for the edge version) to match the version in doc
// metadata.
const versionReleaseDates: Record<string, string> = {};
versions.forEach((version) => {
const releaseDate = readChangelogReleaseDate(
resolve("content", version),
version,
);
if (releaseDate) {
versionReleaseDates[version === currentVersion ? "current" : version] =
releaseDate;
} else {
console.warn(
`No release date found in content/${version}/CHANGELOG.md; structured data for ${version} pages falls back to frontmatter dates only.`,
);
}
});
writeFileSync(
VERSION_RELEASE_DATES_FILENAME,
JSON.stringify(versionReleaseDates),
"utf8",
);

// Make sure the upcoming releases page is the same on all 3 branches.
const versionsToOverride = getVersionNames().filter(
(v) => v !== defaultVersion
Expand Down
74 changes: 74 additions & 0 deletions server/changelog-release-date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "@jest/globals";
import {
majorFromVersionName,
parseChangelogReleaseDate,
} from "./changelog-release-date";

const now = new Date("2026-11-05T12:00:00Z");

describe("parseChangelogReleaseDate", () => {
test("returns the newest release date for the major version", () => {
const changelog = [
"# Changelog",
"",
"## 18.11.0 (08/25/26)",
"",
"Notes.",
"",
"## 18.10.7 (08/19/26)",
].join("\n");
expect(parseChangelogReleaseDate(changelog, "18", now)).toBe(
"2026-08-25T00:00:00.000Z",
);
});

test("ignores other major versions", () => {
// The master branch changelog lists 18.x releases below the 19.0.0 entry.
const changelog = ["## 19.0.0 (xx/xx/26)", "## 18.5.0 (12/04/25)"].join(
"\n",
);
expect(parseChangelogReleaseDate(changelog, "19", now)).toBeUndefined();
});

test("ignores placeholder, impossible, and future dates", () => {
const changelog = [
"## 17.7.28 (08/19/27)", // future (typo)
"## 17.7.27 (02/30/26)", // impossible
"## 17.7.26 (06/xx/26)", // placeholder
"## 17.7.25 (06/09/26)",
].join("\n");
expect(parseChangelogReleaseDate(changelog, "17", now)).toBe(
"2026-06-09T00:00:00.000Z",
);
});

test("uses the newest date regardless of heading order", () => {
const changelog = ["## 16.5.1 (01/02/25)", "## 16.5.2 (03/04/25)"].join(
"\n",
);
expect(parseChangelogReleaseDate(changelog, "16", now)).toBe(
"2025-03-04T00:00:00.000Z",
);
});

test("accepts four-digit years and v-prefixed versions", () => {
expect(
parseChangelogReleaseDate("## v15.4.0 (10/10/2024)", "15", now),
).toBe("2024-10-10T00:00:00.000Z");
});

test("ignores headings without dates", () => {
const changelog = ["## 16.0.0", "## VNet for SSH", "## 16.0"].join("\n");
expect(parseChangelogReleaseDate(changelog, "16", now)).toBeUndefined();
});
});

describe("majorFromVersionName", () => {
test.each([
["18.x", "18"],
["current", undefined],
["18", undefined],
])("%s -> %s", (version, expected) => {
expect(majorFromVersionName(version)).toBe(expected);
});
});
73 changes: 73 additions & 0 deletions server/changelog-release-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Derives a version-level release date from a Teleport CHANGELOG.md. Used as a
// dateModified fallback for JSON-LD structured data, since production builds
// extract content from GitHub tarballs that carry no per-file git history.
import { existsSync, readFileSync } from "fs";
import { join } from "path";

// Matches release headings such as "## 18.11.0 (08/25/26)". Placeholders for
// unreleased versions, like "(xx/xx/26)", do not match and are skipped.
const RELEASE_HEADING =
/^##\s+v?(\d+)\.\d+\.\d+\S*\s+\((\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})\)/gm;

/**
* Returns the newest release date (ISO 8601, UTC midnight) for the given major
* version from CHANGELOG.md content.
*
* - Only headings for that major version count, so 18.x entries in the master
* branch changelog are not attributed to 19.x docs.
* - Dates are MM/DD/YY (or MM/DD/YYYY). Impossible dates are ignored.
* - Dates after `now` are ignored: they are typos or planned release dates.
*/
export function parseChangelogReleaseDate(
changelog: string,
major: string,
now: Date = new Date(),
): string | undefined {
let newest: number | undefined;

for (const [, headingMajor, mm, dd, yy] of changelog.matchAll(
RELEASE_HEADING,
)) {
if (headingMajor !== major) continue;

const year = yy.length === 2 ? 2000 + Number(yy) : Number(yy);
const month = Number(mm) - 1;
const day = Number(dd);
const time = Date.UTC(year, month, day);
const date = new Date(time);
const isRealDate =
date.getUTCFullYear() === year &&
date.getUTCMonth() === month &&
date.getUTCDate() === day;
if (!isRealDate || time > now.getTime()) continue;

if (newest === undefined || time > newest) newest = time;
}

return newest === undefined ? undefined : new Date(newest).toISOString();
}

/** Extracts the major version from a content version name, e.g. "18.x". */
export const majorFromVersionName = (version: string): string | undefined =>
/^(\d+)\.x$/.exec(version)?.[1];

/**
* Reads the newest release date for a content version from
* content/<version>/CHANGELOG.md. Returns undefined if the changelog is
* missing or has no usable release headings for that version.
*/
export function readChangelogReleaseDate(
contentRoot: string,
version: string,
now: Date = new Date(),
): string | undefined {
const major = majorFromVersionName(version);
const changelogPath = join(contentRoot, "CHANGELOG.md");
if (!major || !existsSync(changelogPath)) return undefined;

return parseChangelogReleaseDate(
readFileSync(changelogPath, "utf8"),
major,
now,
);
}
35 changes: 35 additions & 0 deletions src/theme/DocBreadcrumbs/StructuredData/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Head from "@docusaurus/Head";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import type { ReactNode } from "react";
import {
type BreadcrumbInput,
buildBreadcrumbListNode,
serializeJsonLd,
toJsonLdDocument,
} from "@site/src/utils/structuredData";

interface Props {
readonly breadcrumbs: BreadcrumbInput[];
}

export default function DocBreadcrumbsStructuredData({
breadcrumbs,
}: Props): ReactNode {
const { siteConfig } = useDocusaurusContext();
const structuredData = toJsonLdDocument([
buildBreadcrumbListNode({
siteUrl: siteConfig.url,
rootUrl: `${siteConfig.url}${siteConfig.baseUrl}`,
breadcrumbs,
trailingSlash: siteConfig.trailingSlash,
}),
]);

return (
<Head>
<script type="application/ld+json">
{serializeJsonLd(structuredData)}
</script>
</Head>
);
}
Loading
Loading