Skip to content

refactor doc route url parsing #191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 23, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions app/modules/gh-docs/.server/doc-url-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, it, expect } from "vitest";
import { parseDocUrl } from "./doc-url-parser";

function getSplat(url: URL) {
return url.pathname.slice(1);
}

describe("parseDocUrl", () => {
describe("basic doc pages", () => {
it("should parse a basic doc page", () => {
const url = new URL("https://reactrouter.com/start/modes");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "main",
slug: "docs/start/modes",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/main/docs/start/modes.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/main/docs/start/modes.md",
});
});

it("should handle when URL ends with .md", () => {
const url = new URL("https://reactrouter.com/getting-started.md");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "main",
slug: "docs/getting-started",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/main/docs/getting-started.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/main/docs/getting-started.md",
});
});
});

describe("home page detection", () => {
it("should detect home page with /home", () => {
const url = new URL("https://reactrouter.com/home");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "main",
slug: "docs/index",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/main/docs/index.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/main/docs/index.md",
});
});

it("should detect home page with /home.md", () => {
const url = new URL("https://reactrouter.com/home.md");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "main",
slug: "docs/index",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/main/docs/index.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/main/docs/index.md",
});
});
});

describe("changelog page detection", () => {
it("should detect changelog page with /changelog", () => {
const url = new URL("https://reactrouter.com/changelog");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "main",
slug: "CHANGELOG",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/main/CHANGELOG.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/main/CHANGELOG.md",
});
});

it("should detect changelog page with /changelog.md", () => {
const url = new URL("https://reactrouter.com/changelog.md");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "main",
slug: "CHANGELOG",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/main/CHANGELOG.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/main/CHANGELOG.md",
});
});

it("should detect versioned changelog page", () => {
const url = new URL("https://reactrouter.com/6.28.0/changelog");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "6.28.0",
slug: "CHANGELOG",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/refs/tags/[email protected]/CHANGELOG.md",
});
});
});

describe("version/ref handling", () => {
it("should handle dev ref", () => {
const url = new URL("https://reactrouter.com/dev/start/modes");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "dev",
slug: "docs/start/modes",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/dev/docs/start/modes.md",
githubEditPath:
"https://github.com/remix-run/react-router/edit/dev/docs/start/modes.md",
});
});

it("should handle semantic version ref", () => {
const url = new URL("https://reactrouter.com/6.28.0/start/tutorial");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "6.28.0",
slug: "docs/start/tutorial",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/refs/tags/[email protected]/docs/start/tutorial.md",
});
});

it("should handle semantic version ref with .md extension", () => {
const url = new URL("https://reactrouter.com/6.28.0/start/tutorial.md");
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "6.28.0",
slug: "docs/start/tutorial",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/refs/tags/[email protected]/docs/start/tutorial.md",
});
});

it("should handle pre-6.4.0 semantic version ref with v prefix", () => {
const url = new URL(
"https://reactrouter.com/6.2.0/getting-started/installation",
);
const splat = getSplat(url);
const result = parseDocUrl(url, splat);

expect(result).toEqual({
ref: "6.2.0",
slug: "docs/getting-started/installation",
githubPath:
"https://raw.githubusercontent.com/remix-run/react-router/refs/tags/v6.2.0/docs/getting-started/installation.md",
});
});
});
});
81 changes: 81 additions & 0 deletions app/modules/gh-docs/.server/doc-url-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import semver from "semver";

export function parseDocUrl(url: URL, splat: string) {
// Remove the .md extension if there is one
splat = splat.replace(/\.md$/, "");
let pathname = url.pathname.replace(/\.md$/, "");

let firstSegment = splat.split("/")[0];

let ref = "main";
if (
firstSegment === "dev" ||
firstSegment === "local" ||
semver.valid(firstSegment)
) {
ref = firstSegment;
}

let slug: string;
if (pathname.endsWith("/changelog")) {
slug = "CHANGELOG";
} else if (pathname.endsWith("/home")) {
slug = "docs/index";
} else {
// Build the docs path, removing refParam if present
let docsPath = splat.replace(`${ref}/`, "");
slug = `docs/${docsPath}`;
}

return {
ref,
slug,
...generateGitHubPaths(ref, slug),
};
}

/**
* Fixes up ref names to match the actual GitHub ref structure
* - Branches (dev, main, release-next, local) are used as-is
* - Pre-changeset versions (< 6.4.0) get "v" prefix
* - Post-changeset versions get "react-router@" prefix
*/
export function fixupRefName(ref: string): string {
if (isRefBranch(ref)) {
return ref;
}

// pre changesets, tags were like v6.2.0, so add a "v" to the ref from the URL
if (semver.lt(ref, "6.4.0")) {
return `v${ref}`;
}

// add react-router@ because that's what the tags are called after changesets
return `react-router@${ref}`;
}

function isRefBranch(ref: string): boolean {
return ["dev", "main", "release-next", "local"].includes(ref);
}

/**
* Generates the correct GitHub raw URL based on the ref type
* - For main/dev/local: uses the ref directly
* - For semantic versions: uses refs/tags/{version}
*/
function generateGitHubPaths(ref: string, slug: string) {
let baseUrl = "https://raw.githubusercontent.com/remix-run/react-router";

// For main, dev, local, or any non-semver ref, use directly
if (isRefBranch(ref)) {
return {
githubPath: `${baseUrl}/${ref}/${slug}.md`,
githubEditPath: `https://github.com/remix-run/react-router/edit/${ref}/${slug}.md`,
};
}

// For semantic versions, use refs/tags/ structure
return {
githubPath: `${baseUrl}/refs/tags/${fixupRefName(ref)}/${slug}.md`,
};
}
16 changes: 1 addition & 15 deletions app/modules/gh-docs/.server/index.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { getBranches } from "./branches";
import { getReferenceAPI } from "./reference-docs";
import { getLatestVersion, getTags } from "./tags";
import invariant from "tiny-invariant";
import semver from "semver";
import { fixupRefName } from "./doc-url-parser";

export { getRepoTarballStream } from "./repo-tarball";

@@ -52,17 +52,3 @@ export async function getPackageIndexDoc(ref: string, pkgName: string) {
const api = await getReferenceAPI(REPO, ref);
return api.getPackageIndexDoc(pkgName);
}

function fixupRefName(ref: string) {
if (["dev", "main", "release-next", "local"].includes(ref)) {
return ref;
}

// pre changesets, tags were like v6.2.0, so add a "v" to the ref from the URL
if (semver.lt(ref, "6.4.0")) {
return `v${ref}`;
}

// add react-router@ because that's what the tags are called after changesets
return `react-router@${ref}`;
}
56 changes: 11 additions & 45 deletions app/pages/doc.tsx
Original file line number Diff line number Diff line change
@@ -2,8 +2,8 @@ import { useRef } from "react";
import { getRepoDoc } from "~/modules/gh-docs/.server";
import { CACHE_CONTROL } from "~/http";
import { seo } from "~/seo";
import semver from "semver";
import { getDocTitle, getSearchMetaTags } from "~/ui/meta";
import { parseDocUrl } from "~/modules/gh-docs/.server/doc-url-parser";

import { CopyPageDropdown } from "~/components/copy-page-dropdown";
import { LargeOnThisPage, SmallOnThisPage } from "~/components/on-this-page";
@@ -14,48 +14,14 @@ import type { Route } from "./+types/doc";

export { ErrorBoundary } from "~/components/doc-error-boundary";

export let loader = async ({ request, params }: Route.LoaderArgs) => {
export async function loader({ request, params }: Route.LoaderArgs) {
let url = new URL(request.url);
let splat = params["*"] ?? "";
let hasMdExtension = url.pathname.endsWith(".md");
let firstSegment = splat.split("/")[0];
let refParam =
firstSegment === "dev" ||
firstSegment === "local" ||
semver.valid(firstSegment)
? firstSegment
: undefined;

let ref = refParam || "main";

let isHomePage =
url.pathname.endsWith("/home") || url.pathname.endsWith("/home.md");
let isChangelogPage =
url.pathname.endsWith("/changelog") ||
url.pathname.endsWith("/changelog.md");

let slug;
if (isChangelogPage) {
slug = "CHANGELOG";
} else if (isHomePage) {
slug = "docs/index";
} else {
// Build the docs path, removing refParam if present
let docsPath = refParam ? splat.replace(`${refParam}/`, "") : splat;
slug = `docs/${docsPath}`;
}

let ghSlug;
if (isChangelogPage || isHomePage) {
ghSlug = `${slug}.md`;
} else {
ghSlug = hasMdExtension ? slug : `${slug}.md`;
}

let githubPath = `https://raw.githubusercontent.com/remix-run/react-router/${ref}/${ghSlug}`;
const { ref, slug, githubPath, githubEditPath } = parseDocUrl(url, splat);

// If the page is a markdown file, redirect to the raw GitHub file
if (hasMdExtension) {
if (url.pathname.endsWith(".md")) {
return redirect(githubPath);
}

@@ -64,16 +30,16 @@ export let loader = async ({ request, params }: Route.LoaderArgs) => {
if (!doc) {
throw new Response("Not Found", { status: 404 });
}
let githubEditPath =
ref === "main" || ref === "dev"
? `https://github.com/remix-run/react-router/edit/${ref}/${doc.filename}`
: undefined;
return { doc, githubPath, githubEditPath };
return {
doc,
githubPath: githubPath,
githubEditPath: githubEditPath,
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
throw new Response("Not Found", { status: 404 });
}
};
}

export function headers({ parentHeaders }: HeadersArgs) {
parentHeaders.set("Cache-Control", CACHE_CONTROL.doc);
@@ -103,7 +69,7 @@ export function meta({ error, data, matches }: Route.MetaArgs) {
...meta,
...getSearchMetaTags(
rootMatch.data.isProductionHost,
doc.header.docSearchVersion
doc.header.docSearchVersion,
),
];
}