Skip to content

Commit 72ef7e2

Browse files
authored
[DEV-813] Improve seo plugin version detection (#913)
1 parent 7888518 commit 72ef7e2

File tree

2 files changed

+112
-49
lines changed

2 files changed

+112
-49
lines changed

docs/.vuepress/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export default defineClientConfig({
114114
});
115115
const operatorLatest = __VERSIONS__.all.filter(x => x.id === 'kubernetes-operator')[0].versions[0].version;
116116
addDynamicRoute("/server/kubernetes-operator", to => `/server/kubernetes-operator/${operatorLatest}/getting-started/`);
117+
addDynamicRoute("/server/kubernetes-operator/latest/:pathMatch(.*)*", to => to.path.replace(/^\/server\/kubernetes-operator\/latest/, `/server/kubernetes-operator/${operatorLatest}`));
117118
addDynamicRoute("/server/kubernetes-operator/:version", to => `/server/kubernetes-operator/${to.params.version}/getting-started/`);
118119

119120
// Clients routes
Lines changed: 111 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,135 @@
1-
import { SeoPluginOptions } from "@vuepress/plugin-seo";
2-
import { App, HeadConfig, Page } from "vuepress";
1+
import type { SeoPluginOptions } from "@vuepress/plugin-seo";
32
import { match } from "ts-pattern";
3+
import type { App, HeadConfig, Page } from "vuepress";
44
import { hostname } from "./shared";
55

6-
const LEGACY = "Legacy";
7-
const EXCLUDED_VERSIONS = ["v5", "v24.6"];
8-
const LATEST_VERSION = "v25.0"; // fallback latest version
6+
interface DocumentationPath {
7+
version: string | null;
8+
section: string;
9+
};
10+
11+
type Section = string;
12+
type Version = string;
13+
14+
const SITE = "https://docs.kurrent.io";
15+
16+
/**
17+
* Configuration for excluding specific versions from SEO indexing.
18+
*/
19+
const EXCLUDED_VERSIONS: Record<Section, readonly Version[]> = {
20+
"server": ["v5", "v24.6"],
21+
};
22+
23+
/**
24+
* Extracts version and section from path.
25+
* @example
26+
* parsePathInfo("/clients/dotnet/v1.0/auth") // { version: "v1.0", section: "clients/dotnet" }
27+
* parsePathInfo("/cloud/introduction.html") // { version: null, section: "cloud" }
28+
*/
29+
const parsePathInfo = (path: string): DocumentationPath => {
30+
const segments = path.split("/");
31+
const versionIndex = segments.findIndex((s, i) => i > 1 && /^(v\d+(\.\d+)*|\d+\.\d+)$/.test(s));
32+
const hasVersion = versionIndex > 1;
33+
34+
return {
35+
version: hasVersion ? segments[versionIndex] : null,
36+
section: segments.slice(1, hasVersion ? versionIndex : 2).join("/")
37+
};
38+
}
39+
40+
/**
41+
* Checks if a specific version of a section is excluded for SEO.
42+
* @param section The section to check.
43+
* @param version The version to check.
44+
* @returns True if the version is excluded, false otherwise.
45+
*/
46+
const isExcluded = (section: Section, version: Version | null): boolean =>
47+
!!version && !!EXCLUDED_VERSIONS[section]?.includes(version);
48+
49+
/**
50+
* Converts kebab case to title case.
51+
* @param str The input string.
52+
* @returns The normalized string.
53+
*
54+
* @example
55+
* normalize("dev-center") // "Dev Center"
56+
*/
57+
const normalize = (str: string): string =>
58+
str.split(/[-/]/)
59+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
60+
.join(' ');
961

1062
export const seoPlugin: SeoPluginOptions = {
1163
hostname,
1264

13-
canonical: (page: Page) => {
14-
const segments = page.pathInferred?.split("/") ?? [];
15-
const section = segments[1];
16-
const version = segments[2];
65+
/**
66+
* Redirects versioned pages to their "latest" equivalent for SEO purposes:
67+
* - `/server/v25.0/config` -> `/server/latest/config`
68+
* - `/clients/dotnet/v1.0/auth` -> `/clients/dotnet/latest/auth`
69+
*
70+
* Excludes legacy and TCP clients from redirection
71+
*/
72+
canonical: ({ path }: Page) => {
73+
const { version, section } = parsePathInfo(path);
1774

18-
// don’t index/remove unwanted versions
19-
if (EXCLUDED_VERSIONS.includes(version)) {
20-
return null;
21-
}
75+
const isLegacy = ["legacy", "tcp"].some(exclude => section.includes(exclude));
76+
const isVersionized = ["server", "clients"].some(s => section.startsWith(s))
2277

23-
// cloud & tutorials always point at root of that section
24-
if (section === "cloud" || section === "tutorials") {
25-
return `https://docs.kurrent.io/${section}${page.path.slice(
26-
section.length + 1
27-
)}`;
28-
}
78+
const fallback = `${SITE}${path}`;
2979

30-
if (version?.startsWith("v")) {
31-
const rest = page.path.slice(`/${section}/${version}`.length);
32-
return `https://docs.kurrent.io/${section}/${LATEST_VERSION}${rest}`;
33-
}
80+
if (!version) return fallback;
3481

35-
return `https://docs.kurrent.io${page.path}`;
36-
},
82+
if (isExcluded(section, version))
83+
return null;
84+
85+
// redirect to latest for server and gRPC clients
86+
if (isVersionized && !isLegacy)
87+
return `${SITE}${path.replace(`/${section}/${version}`, `/${section}/latest`)}`;
3788

38-
customHead: (head: HeadConfig[], page: Page, app: App) => {
39-
if (!page.pathInferred) return;
89+
return fallback;
90+
},
4091

41-
const segments = page.pathInferred.split("/");
42-
let version = segments.length > 2 ? segments[2] : null;
92+
/**
93+
* Used to set custom head tags for the page unless it's excluded for SEO.
94+
* Algolia will automatically pick up these tags for groupings.
95+
*
96+
* Sets the following tags:
97+
* e.g. <meta name="es:category" content=".NET Client" />
98+
* <meta name="es:version" content="v1.0" />
99+
*
100+
* If it's a legacy or tcp client, it will be labelled as "Legacy"
101+
*/
102+
customHead: (head: HeadConfig[], { path }: Page, app: App) => {
103+
const { version, section } = parsePathInfo(path);
43104

44-
// drop indexing on unwanted versions
45-
if (version && EXCLUDED_VERSIONS.includes(version)) {
105+
if (isExcluded(section, version)) {
46106
head.push(["meta", { name: "robots", content: "noindex,nofollow" }]);
47107
return;
48108
}
49109

50-
// map “tcp” to Legacy, then tag es:version if it applies
51-
if (version === "tcp") version = LEGACY;
52-
if (
53-
version &&
54-
(version === LEGACY ||
55-
(version.startsWith("v") && (version.includes(".") || version === "v5")))
56-
) {
110+
if (version)
57111
head.push(["meta", { name: "es:version", content: version }]);
58-
}
59112

60-
const category = segments[1];
61-
if (!category) return;
62-
const readable = match(category)
63-
.with("server", () => "Server")
64-
.with("clients", () => "Client")
113+
const category = match(section)
114+
.with("clients/dotnet", () => ".NET Client")
115+
.with("clients/golang", () => "Golang Client")
116+
.with("clients/java", () => "Java Client")
117+
.with("clients/node", () => "Node.JS Client")
118+
.with("clients/python", () => "Python Client")
119+
.with("clients/rust", () => "Rust Client")
120+
.with("clients/dotnet/legacy", () => "Legacy gRPC .NET Client")
121+
.with("clients/golang/legacy", () => "Legacy gRPC Golang Client")
122+
.with("clients/java/legacy", () => "Legacy gRPC Java Client")
123+
.with("clients/node/legacy", () => "Legacy gRPC Node.JS Client")
124+
.with("clients/python/legacy", () => "Legacy gRPC Python Client")
125+
.with("clients/rust/legacy", () => "Legacy gRPC Rust Client")
126+
.with("clients/tcp/dotnet", () => "Legacy TCP .NET Client")
65127
.with("cloud", () => "Cloud")
66-
.with("http-api", () => "HTTP API")
67-
.with("connectors", () => "Connectors")
68128
.with("getting-started", () => "Getting Started")
69-
.otherwise(() => category);
129+
.with("server/kubernetes-operator", () => "Kubernetes Operator")
130+
.with("server", () => "Server")
131+
.otherwise(() => normalize(section));
70132

71-
head.push(["meta", { name: "es:category", content: readable }]);
72-
},
133+
head.push(["meta", { name: "es:category", content: category }]);
134+
}
73135
};

0 commit comments

Comments
 (0)