|
1 | | -import { SeoPluginOptions } from "@vuepress/plugin-seo"; |
2 | | -import { App, HeadConfig, Page } from "vuepress"; |
| 1 | +import type { SeoPluginOptions } from "@vuepress/plugin-seo"; |
3 | 2 | import { match } from "ts-pattern"; |
| 3 | +import type { App, HeadConfig, Page } from "vuepress"; |
4 | 4 | import { hostname } from "./shared"; |
5 | 5 |
|
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(' '); |
9 | 61 |
|
10 | 62 | export const seoPlugin: SeoPluginOptions = { |
11 | 63 | hostname, |
12 | 64 |
|
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); |
17 | 74 |
|
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)) |
22 | 77 |
|
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}`; |
29 | 79 |
|
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; |
34 | 81 |
|
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`)}`; |
37 | 88 |
|
38 | | - customHead: (head: HeadConfig[], page: Page, app: App) => { |
39 | | - if (!page.pathInferred) return; |
| 89 | + return fallback; |
| 90 | + }, |
40 | 91 |
|
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); |
43 | 104 |
|
44 | | - // drop indexing on unwanted versions |
45 | | - if (version && EXCLUDED_VERSIONS.includes(version)) { |
| 105 | + if (isExcluded(section, version)) { |
46 | 106 | head.push(["meta", { name: "robots", content: "noindex,nofollow" }]); |
47 | 107 | return; |
48 | 108 | } |
49 | 109 |
|
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) |
57 | 111 | head.push(["meta", { name: "es:version", content: version }]); |
58 | | - } |
59 | 112 |
|
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") |
65 | 127 | .with("cloud", () => "Cloud") |
66 | | - .with("http-api", () => "HTTP API") |
67 | | - .with("connectors", () => "Connectors") |
68 | 128 | .with("getting-started", () => "Getting Started") |
69 | | - .otherwise(() => category); |
| 129 | + .with("server/kubernetes-operator", () => "Kubernetes Operator") |
| 130 | + .with("server", () => "Server") |
| 131 | + .otherwise(() => normalize(section)); |
70 | 132 |
|
71 | | - head.push(["meta", { name: "es:category", content: readable }]); |
72 | | - }, |
| 133 | + head.push(["meta", { name: "es:category", content: category }]); |
| 134 | + } |
73 | 135 | }; |
0 commit comments