Skip to content

Commit c371b4c

Browse files
authored
Prerender resources pages (#7)
1 parent d4dde95 commit c371b4c

File tree

21 files changed

+2008
-26
lines changed

21 files changed

+2008
-26
lines changed

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# A token to increase the rate limiting from 60/hr to 1000/hr
2+
GITHUB_TOKEN=""

.github/workflows/lint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
lint:
1414
name: ⬣ Lint
1515
runs-on: ubuntu-latest
16+
env:
17+
GITHUB_TOKEN: "dummy-value-needed-for-typecheck"
1618
steps:
1719
- name: ⬇️ Checkout repo
1820
uses: actions/checkout@v4

.github/workflows/pages.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818
if: github.repository == 'remix-run/remix-v2-website'
1919
name: 🏗 Build
2020
runs-on: ubuntu-latest
21+
env:
22+
GITHUB_TOKEN: ${{ github.token }}
2123
steps:
2224
- name: ⬇️ Checkout repo
2325
uses: actions/checkout@v4
@@ -31,7 +33,7 @@ jobs:
3133
run: npm ci
3234

3335
- name: 🏗 Build
34-
run: npm run build
36+
run: npm run build:ci
3537

3638
- name: ⬆️ Upload static files as artifact
3739
uses: actions/upload-pages-artifact@v3

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

0 commit comments

Comments
 (0)