Skip to content

Commit 1f77fe7

Browse files
committed
Prerender resources pages
1 parent d4dde95 commit 1f77fe7

File tree

16 files changed

+1985
-24
lines changed

16 files changed

+1985
-24
lines changed

app/env.server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { z } from "zod";
2+
3+
const requiredInProduction: z.RefinementEffect<
4+
string | undefined
5+
>["refinement"] = (value, ctx) => {
6+
if (process.env.NODE_ENV === "production" && !value) {
7+
ctx.addIssue({
8+
code: z.ZodIssueCode.custom,
9+
message: "Missing required environment variable " + ctx.path.join("."),
10+
});
11+
}
12+
};
13+
14+
const envSchema = z.object({
15+
// A token to increase the rate limiting from 60/hr to 1000/hr
16+
GITHUB_TOKEN: z.string().optional().superRefine(requiredInProduction),
17+
NO_CACHE: z.coerce.boolean().default(false),
18+
});
19+
20+
export const env = envSchema.parse(process.env);

app/lib/github.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Octokit } from "octokit";
2+
import { env } from "~/env.server";
3+
4+
export const octokit = new Octokit(
5+
env.GITHUB_TOKEN ? { auth: env.GITHUB_TOKEN } : undefined,
6+
);

app/lib/resources.server/index.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { LRUCache } from "lru-cache";
2+
import type { Octokit } from "octokit";
3+
import yaml from "yaml";
4+
import { env } from "../../env.server";
5+
import { processMarkdown } from "../md.server";
6+
import resourcesYamlFileContents from "../../../data/resources.yaml?raw";
7+
import { slugify } from "../../ui/primitives/utils";
8+
import type { ResourceYamlData } from "../../schemas/yaml-resource-schema";
9+
import { yamlResourceSchema } from "../../schemas/yaml-resource-schema";
10+
11+
export type CacheContext = { octokit: Octokit };
12+
13+
const GITHUB_URL = "https://github.com";
14+
15+
export const fetchResourcesFromYaml = () => {
16+
return yamlResourceSchema.parse(yaml.parse(resourcesYamlFileContents));
17+
};
18+
19+
let _resources = fetchResourcesFromYaml();
20+
21+
let starFormatter = new Intl.NumberFormat("en", { notation: "compact" });
22+
23+
type ResourceGitHubData = {
24+
description?: string;
25+
sponsorUrl?: string;
26+
stars: number;
27+
starsFormatted: string;
28+
tags: string[];
29+
};
30+
31+
export type Resource = ResourceYamlData & ResourceGitHubData;
32+
33+
export type Category = "all" | ResourceYamlData["category"];
34+
35+
/**
36+
* Gets all of the resources, fetching and merging GitHub data for each one
37+
*/
38+
export async function getAllResources({ octokit }: CacheContext) {
39+
let resources: Resource[] = await Promise.all(
40+
_resources.map(async (resource) => {
41+
// This is cached, so should just be a simple lookup
42+
let gitHubData = await getResourceGitHubData(resource.repoUrl, {
43+
octokit,
44+
});
45+
if (!gitHubData) {
46+
throw new Error(`Could not find GitHub data for ${resource.repoUrl}`);
47+
}
48+
return { ...resource, ...gitHubData };
49+
}),
50+
);
51+
52+
return resources.sort((a, b) => b.stars - a.stars);
53+
}
54+
55+
/**
56+
* Replace relative links in the README with absolute links
57+
*
58+
* Works only with images
59+
*
60+
* @param inputString - The README string
61+
* @param repoUrl - The URL of the repository
62+
* @returns The README string with relative links replaced with absolute links
63+
*
64+
* @example
65+
* const input = `<img src="./relative">`;
66+
* const repoUrl = "https://my-repo";
67+
* const readme = replaceRelativeLinks(input, repoUrl);
68+
* console.log(readme); // <img src="https://my-repo/raw/main/relative">
69+
*
70+
*/
71+
72+
export function replaceRelativeLinks(inputString: string, repoUrl: string) {
73+
// Regular expression to match <img ... src="./relative"
74+
const regex = /(<img(?:\s+\w+="[^"]*")*\s+)(src="\.\/*)/g;
75+
76+
// Replace matched substrings with <img ... src="https://repoUrl/raw/main"
77+
const replacedString = inputString.replace(
78+
regex,
79+
`$1src="${repoUrl}/raw/main/`,
80+
);
81+
82+
return replacedString;
83+
}
84+
85+
/**
86+
* Get a single resource by slug, fetching and merging GitHub data and README contents
87+
*/
88+
export async function getResource(
89+
resourceSlug: string,
90+
{ octokit }: CacheContext,
91+
) {
92+
let resource = _resources.find(
93+
(resource) => slugify(resource.title) === resourceSlug,
94+
);
95+
96+
if (!resource) return;
97+
98+
let [gitHubData, readmeHtml] = await Promise.all([
99+
getResourceGitHubData(resource.repoUrl, { octokit }),
100+
getResourceReadme(resource.repoUrl, { octokit }),
101+
]);
102+
103+
if (!gitHubData || !readmeHtml) {
104+
throw new Error(`Could not find GitHub data for ${resource.repoUrl}`);
105+
}
106+
107+
return {
108+
...resource,
109+
...gitHubData,
110+
readmeHtml,
111+
};
112+
}
113+
114+
//#region LRUCache and fetchers for GitHub data and READMEs
115+
116+
declare global {
117+
var resourceReadmeCache: LRUCache<string, string, CacheContext>;
118+
var resourceGitHubDataCache: LRUCache<
119+
string,
120+
ResourceGitHubData,
121+
CacheContext
122+
>;
123+
}
124+
125+
let NO_CACHE = env.NO_CACHE;
126+
127+
global.resourceReadmeCache ??= new LRUCache<string, string, CacheContext>({
128+
max: 300,
129+
ttl: NO_CACHE ? 1 : 1000 * 60 * 5, // 5 minutes
130+
allowStale: !NO_CACHE,
131+
noDeleteOnFetchRejection: true,
132+
fetchMethod: fetchReadme,
133+
});
134+
135+
async function fetchReadme(
136+
key: string,
137+
_staleValue: string | undefined,
138+
{ context }: LRUCache.FetchOptionsWithContext<string, string, CacheContext>,
139+
): Promise<string> {
140+
let [owner, repo] = key.split("/");
141+
let contents = await context.octokit.rest.repos.getReadme({
142+
owner,
143+
repo,
144+
mediaType: { format: "raw" },
145+
});
146+
147+
// when using `format: raw` the data property is the file contents
148+
let md = contents.data as unknown;
149+
if (md == null || typeof md !== "string") {
150+
throw Error(`Could not find README in ${key}`);
151+
}
152+
let { html } = await processMarkdown(md);
153+
return replaceRelativeLinks(html, `${GITHUB_URL}/${key}`);
154+
}
155+
156+
async function getResourceReadme(repoUrl: string, context: CacheContext) {
157+
let repo = repoUrl.replace(`${GITHUB_URL}/`, "");
158+
let doc = await resourceReadmeCache.fetch(repo, { context });
159+
160+
return doc || undefined;
161+
}
162+
163+
async function getSponsorUrl(owner: string) {
164+
let sponsorUrl = `${GITHUB_URL}/sponsors/${owner}`;
165+
166+
try {
167+
// We don't need the body, just need to know if it's redirected
168+
// method: "HEAD" removes the need for garbage collection: https://github.com/nodejs/undici?tab=readme-ov-file#garbage-collection
169+
let response = await fetch(sponsorUrl, { method: "HEAD" });
170+
return !response.redirected ? sponsorUrl : undefined;
171+
} catch {
172+
console.error("Failed to fetch sponsor url for", owner);
173+
return undefined;
174+
}
175+
}
176+
177+
async function getResourceGitHubData(
178+
repoUrl: string,
179+
{ octokit }: CacheContext,
180+
) {
181+
return resourceGitHubDataCache.fetch(repoUrl, {
182+
context: { octokit },
183+
});
184+
}
185+
186+
global.resourceGitHubDataCache ??= new LRUCache<
187+
string,
188+
ResourceGitHubData,
189+
CacheContext
190+
>({
191+
max: 300,
192+
ttl: NO_CACHE ? 1 : 1000 * 60 * 5, // 5 minutes
193+
allowStale: !NO_CACHE,
194+
noDeleteOnFetchRejection: true,
195+
fetchMethod: fetchResourceGitHubData,
196+
});
197+
198+
let ignoredTopics = new Set(["remix-stack", "remix-run", "remix"]);
199+
200+
async function fetchResourceGitHubData(
201+
repoUrl: string,
202+
staleValue: ResourceGitHubData | undefined,
203+
{
204+
context,
205+
}: LRUCache.FetchOptionsWithContext<string, ResourceGitHubData, CacheContext>,
206+
): Promise<ResourceGitHubData> {
207+
let [owner, repo] = repoUrl.replace(`${GITHUB_URL}/`, "").split("/");
208+
209+
let [{ data }, sponsorUrl] = await Promise.all([
210+
context.octokit.rest.repos.get({ owner, repo }),
211+
getSponsorUrl(owner),
212+
]);
213+
214+
let description = data.description ?? undefined;
215+
let stars = data.stargazers_count;
216+
let tags = (data.topics ?? []).filter((topic) => !ignoredTopics.has(topic));
217+
218+
return {
219+
description,
220+
stars,
221+
starsFormatted: starFormatter.format(stars).toLowerCase(),
222+
tags,
223+
sponsorUrl,
224+
};
225+
}
226+
227+
//#endregion

app/routes/_extras.resources.$.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Pull full readme for this page from GitHub
2+
import invariant from "tiny-invariant";
3+
import { getResource } from "~/lib/resources.server";
4+
import { InitCodeblock, ResourceTag } from "~/ui/resources";
5+
import { octokit } from "~/lib/github.server";
6+
import "~/styles/docs.css";
7+
import iconsHref from "~/icons.svg";
8+
import { getMeta } from "~/lib/meta";
9+
import type { Route } from "./+types/_extras.resources.$";
10+
11+
export async function loader({ request, params }: Route.LoaderArgs) {
12+
const resourceSlug = params["*"];
13+
invariant(resourceSlug, "resourceSlug is required");
14+
15+
let resource = await getResource(resourceSlug, { octokit });
16+
17+
if (!resource) {
18+
throw new Response(null, { status: 404 });
19+
}
20+
21+
let requestUrl = new URL(request.url);
22+
let siteUrl = `${requestUrl.protocol}//${requestUrl.host}/resources/${resourceSlug}`;
23+
24+
return { siteUrl, resource };
25+
}
26+
27+
export function meta({ data }: Route.MetaArgs) {
28+
if (!data) {
29+
return [{ title: "404 Not Found | Remix" }];
30+
}
31+
32+
let { siteUrl, resource } = data;
33+
if (!resource) {
34+
return [{ title: "404 Not Found | Remix" }];
35+
}
36+
37+
let socialImageUrl = resource.imgSrc;
38+
39+
return getMeta({
40+
title: resource.title + " | Remix Resources",
41+
description: resource.description,
42+
siteUrl,
43+
image: socialImageUrl,
44+
});
45+
}
46+
47+
export default function ResourcePage({ loaderData }: Route.ComponentProps) {
48+
let {
49+
description,
50+
repoUrl,
51+
initCommand,
52+
sponsorUrl,
53+
starsFormatted,
54+
tags,
55+
readmeHtml,
56+
} = loaderData.resource;
57+
58+
return (
59+
<main className="flex flex-1 flex-col items-center px-8 lg:container">
60+
<div className="flex w-full flex-col md:flex-row-reverse">
61+
{/* The sidebar comes first with a flex row-reverse for better keyboard navigation */}
62+
<aside className="flex flex-col gap-4 md:sticky md:top-28 md:h-0 md:w-[400px]">
63+
<a
64+
href={repoUrl}
65+
rel="noopener noreferrer"
66+
target="_blank"
67+
className="text-xl font-bold hover:text-gray-600 dark:hover:text-gray-300"
68+
>
69+
{repoUrl.replace("https://github.com/", "")}
70+
</a>
71+
<p className="text-sm italic text-gray-500 dark:text-gray-300 md:text-justify lg:text-base">
72+
{description}
73+
</p>
74+
<a
75+
href={repoUrl}
76+
rel="noopener noreferrer"
77+
target="_blank"
78+
className="group flex items-center gap-2"
79+
>
80+
<svg
81+
aria-hidden
82+
className="h-4 w-4 text-gray-900 dark:text-gray-400"
83+
viewBox="0 0 24 24"
84+
>
85+
<use href={`${iconsHref}#github`} />
86+
</svg>
87+
<span>
88+
<span className="font-medium group-hover:font-semibold">
89+
Star
90+
</span>{" "}
91+
<span className="font-light group-hover:font-normal">
92+
{starsFormatted}
93+
</span>
94+
</span>
95+
</a>
96+
{sponsorUrl ? (
97+
<a
98+
href={sponsorUrl}
99+
rel="noopener noreferrer"
100+
target="_blank"
101+
className="flex items-center gap-2 font-medium hover:font-semibold"
102+
>
103+
<svg aria-hidden className="h-4 w-4" viewBox="0 0 16 16">
104+
<use href={`${iconsHref}#heart-filled`} />
105+
</svg>
106+
<span>Sponsor</span>
107+
</a>
108+
) : null}
109+
<InitCodeblock initCommand={initCommand} />
110+
<div className="flex w-full max-w-full flex-wrap gap-x-2 gap-y-2">
111+
{tags.map((tag) => (
112+
<ResourceTag key={tag} value={tag}>
113+
{tag}
114+
</ResourceTag>
115+
))}
116+
</div>
117+
</aside>
118+
119+
<hr className="mt-6 w-full border-gray-200 dark:border-gray-700 md:hidden" />
120+
121+
{readmeHtml ? (
122+
<div
123+
// Have to specify the width this way, otherwise the markdown
124+
// content will take up the full container without a care in the
125+
// world for it's sibling -- not unlike my older brother on our
126+
// family's annual summer road trip to the beach.
127+
className="markdown mt-6 w-full pr-0 md:mt-0 md:w-[calc(100%-400px)] md:pr-12 lg:pr-16"
128+
>
129+
<div
130+
className="md-prose"
131+
dangerouslySetInnerHTML={{ __html: readmeHtml }}
132+
/>
133+
</div>
134+
) : null}
135+
</div>
136+
</main>
137+
);
138+
}

0 commit comments

Comments
 (0)