Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# A token to increase the rate limiting from 60/hr to 1000/hr
GITHUB_TOKEN=""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I missing something? I added this to my .env file but build fails

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh - yeah I must have exported it locally in the shell during my testing - added dotenv 👍

20 changes: 20 additions & 0 deletions app/env.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod";

const requiredInProduction: z.RefinementEffect<
string | undefined
>["refinement"] = (value, ctx) => {
if (process.env.NODE_ENV === "production" && !value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Missing required environment variable " + ctx.path.join("."),
});
}
};

const envSchema = z.object({
// A token to increase the rate limiting from 60/hr to 1000/hr
GITHUB_TOKEN: z.string().optional().superRefine(requiredInProduction),
NO_CACHE: z.coerce.boolean().default(false),
});

export const env = envSchema.parse(process.env);
6 changes: 6 additions & 0 deletions app/lib/github.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Octokit } from "octokit";
import { env } from "~/env.server";

export const octokit = new Octokit(
env.GITHUB_TOKEN ? { auth: env.GITHUB_TOKEN } : undefined,
);
227 changes: 227 additions & 0 deletions app/lib/resources.server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { LRUCache } from "lru-cache";
import type { Octokit } from "octokit";
import yaml from "yaml";
import { env } from "../../env.server";
import { processMarkdown } from "../md.server";
import resourcesYamlFileContents from "../../../data/resources.yaml?raw";
import { slugify } from "../../ui/primitives/utils";
import type { ResourceYamlData } from "../../schemas/yaml-resource-schema";
import { yamlResourceSchema } from "../../schemas/yaml-resource-schema";

export type CacheContext = { octokit: Octokit };

const GITHUB_URL = "https://github.com";

export const fetchResourcesFromYaml = () => {
return yamlResourceSchema.parse(yaml.parse(resourcesYamlFileContents));
};

let _resources = fetchResourcesFromYaml();

let starFormatter = new Intl.NumberFormat("en", { notation: "compact" });

type ResourceGitHubData = {
description?: string;
sponsorUrl?: string;
stars: number;
starsFormatted: string;
tags: string[];
};

export type Resource = ResourceYamlData & ResourceGitHubData;

export type Category = "all" | ResourceYamlData["category"];

/**
* Gets all of the resources, fetching and merging GitHub data for each one
*/
export async function getAllResources({ octokit }: CacheContext) {
let resources: Resource[] = await Promise.all(
_resources.map(async (resource) => {
// This is cached, so should just be a simple lookup
let gitHubData = await getResourceGitHubData(resource.repoUrl, {
octokit,
});
if (!gitHubData) {
throw new Error(`Could not find GitHub data for ${resource.repoUrl}`);
}
return { ...resource, ...gitHubData };
}),
);

return resources.sort((a, b) => b.stars - a.stars);
}

/**
* Replace relative links in the README with absolute links
*
* Works only with images
*
* @param inputString - The README string
* @param repoUrl - The URL of the repository
* @returns The README string with relative links replaced with absolute links
*
* @example
* const input = `<img src="./relative">`;
* const repoUrl = "https://my-repo";
* const readme = replaceRelativeLinks(input, repoUrl);
* console.log(readme); // <img src="https://my-repo/raw/main/relative">
*
*/

export function replaceRelativeLinks(inputString: string, repoUrl: string) {
// Regular expression to match <img ... src="./relative"
const regex = /(<img(?:\s+\w+="[^"]*")*\s+)(src="\.\/*)/g;

// Replace matched substrings with <img ... src="https://repoUrl/raw/main"
const replacedString = inputString.replace(
regex,
`$1src="${repoUrl}/raw/main/`,
);

return replacedString;
}

/**
* Get a single resource by slug, fetching and merging GitHub data and README contents
*/
export async function getResource(
resourceSlug: string,
{ octokit }: CacheContext,
) {
let resource = _resources.find(
(resource) => slugify(resource.title) === resourceSlug,
);

if (!resource) return;

let [gitHubData, readmeHtml] = await Promise.all([
getResourceGitHubData(resource.repoUrl, { octokit }),
getResourceReadme(resource.repoUrl, { octokit }),
]);

if (!gitHubData || !readmeHtml) {
throw new Error(`Could not find GitHub data for ${resource.repoUrl}`);
}

return {
...resource,
...gitHubData,
readmeHtml,
};
}

//#region LRUCache and fetchers for GitHub data and READMEs

declare global {
var resourceReadmeCache: LRUCache<string, string, CacheContext>;
var resourceGitHubDataCache: LRUCache<
string,
ResourceGitHubData,
CacheContext
>;
}

let NO_CACHE = env.NO_CACHE;

global.resourceReadmeCache ??= new LRUCache<string, string, CacheContext>({
max: 300,
ttl: NO_CACHE ? 1 : 1000 * 60 * 5, // 5 minutes
allowStale: !NO_CACHE,
noDeleteOnFetchRejection: true,
fetchMethod: fetchReadme,
});

async function fetchReadme(
key: string,
_staleValue: string | undefined,
{ context }: LRUCache.FetchOptionsWithContext<string, string, CacheContext>,
): Promise<string> {
let [owner, repo] = key.split("/");
let contents = await context.octokit.rest.repos.getReadme({
owner,
repo,
mediaType: { format: "raw" },
});

// when using `format: raw` the data property is the file contents
let md = contents.data as unknown;
if (md == null || typeof md !== "string") {
throw Error(`Could not find README in ${key}`);
}
let { html } = await processMarkdown(md);
return replaceRelativeLinks(html, `${GITHUB_URL}/${key}`);
}

async function getResourceReadme(repoUrl: string, context: CacheContext) {
let repo = repoUrl.replace(`${GITHUB_URL}/`, "");
let doc = await resourceReadmeCache.fetch(repo, { context });

return doc || undefined;
}

async function getSponsorUrl(owner: string) {
let sponsorUrl = `${GITHUB_URL}/sponsors/${owner}`;

try {
// We don't need the body, just need to know if it's redirected
// method: "HEAD" removes the need for garbage collection: https://github.com/nodejs/undici?tab=readme-ov-file#garbage-collection
let response = await fetch(sponsorUrl, { method: "HEAD" });
return !response.redirected ? sponsorUrl : undefined;
} catch {
console.error("Failed to fetch sponsor url for", owner);
return undefined;
}
}

async function getResourceGitHubData(
repoUrl: string,
{ octokit }: CacheContext,
) {
return resourceGitHubDataCache.fetch(repoUrl, {
context: { octokit },
});
}

global.resourceGitHubDataCache ??= new LRUCache<
string,
ResourceGitHubData,
CacheContext
>({
max: 300,
ttl: NO_CACHE ? 1 : 1000 * 60 * 5, // 5 minutes
allowStale: !NO_CACHE,
noDeleteOnFetchRejection: true,
fetchMethod: fetchResourceGitHubData,
});

let ignoredTopics = new Set(["remix-stack", "remix-run", "remix"]);

async function fetchResourceGitHubData(
repoUrl: string,
staleValue: ResourceGitHubData | undefined,
{
context,
}: LRUCache.FetchOptionsWithContext<string, ResourceGitHubData, CacheContext>,
): Promise<ResourceGitHubData> {
let [owner, repo] = repoUrl.replace(`${GITHUB_URL}/`, "").split("/");

let [{ data }, sponsorUrl] = await Promise.all([
context.octokit.rest.repos.get({ owner, repo }),
getSponsorUrl(owner),
]);

let description = data.description ?? undefined;
let stars = data.stargazers_count;
let tags = (data.topics ?? []).filter((topic) => !ignoredTopics.has(topic));

return {
description,
stars,
starsFormatted: starFormatter.format(stars).toLowerCase(),
tags,
sponsorUrl,
};
}

//#endregion
138 changes: 138 additions & 0 deletions app/routes/_extras.resources.$.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Pull full readme for this page from GitHub
import invariant from "tiny-invariant";
import { getResource } from "~/lib/resources.server";
import { InitCodeblock, ResourceTag } from "~/ui/resources";
import { octokit } from "~/lib/github.server";
import "~/styles/docs.css";
import iconsHref from "~/icons.svg";
import { getMeta } from "~/lib/meta";
import type { Route } from "./+types/_extras.resources.$";

export async function loader({ request, params }: Route.LoaderArgs) {
const resourceSlug = params["*"];
invariant(resourceSlug, "resourceSlug is required");

let resource = await getResource(resourceSlug, { octokit });

if (!resource) {
throw new Response(null, { status: 404 });
}

let requestUrl = new URL(request.url);
let siteUrl = `${requestUrl.protocol}//${requestUrl.host}/resources/${resourceSlug}`;

return { siteUrl, resource };
}

export function meta({ data }: Route.MetaArgs) {
if (!data) {
return [{ title: "404 Not Found | Remix" }];
}

let { siteUrl, resource } = data;
if (!resource) {
return [{ title: "404 Not Found | Remix" }];
}

let socialImageUrl = resource.imgSrc;

return getMeta({
title: resource.title + " | Remix Resources",
description: resource.description,
siteUrl,
image: socialImageUrl,
});
}

export default function ResourcePage({ loaderData }: Route.ComponentProps) {
let {
description,
repoUrl,
initCommand,
sponsorUrl,
starsFormatted,
tags,
readmeHtml,
} = loaderData.resource;

return (
<main className="flex flex-1 flex-col items-center px-8 lg:container">
<div className="flex w-full flex-col md:flex-row-reverse">
{/* The sidebar comes first with a flex row-reverse for better keyboard navigation */}
<aside className="flex flex-col gap-4 md:sticky md:top-28 md:h-0 md:w-[400px]">
<a
href={repoUrl}
rel="noopener noreferrer"
target="_blank"
className="text-xl font-bold hover:text-gray-600 dark:hover:text-gray-300"
>
{repoUrl.replace("https://github.com/", "")}
</a>
<p className="text-sm italic text-gray-500 dark:text-gray-300 md:text-justify lg:text-base">
{description}
</p>
<a
href={repoUrl}
rel="noopener noreferrer"
target="_blank"
className="group flex items-center gap-2"
>
<svg
aria-hidden
className="h-4 w-4 text-gray-900 dark:text-gray-400"
viewBox="0 0 24 24"
>
<use href={`${iconsHref}#github`} />
</svg>
<span>
<span className="font-medium group-hover:font-semibold">
Star
</span>{" "}
<span className="font-light group-hover:font-normal">
{starsFormatted}
</span>
</span>
</a>
{sponsorUrl ? (
<a
href={sponsorUrl}
rel="noopener noreferrer"
target="_blank"
className="flex items-center gap-2 font-medium hover:font-semibold"
>
<svg aria-hidden className="h-4 w-4" viewBox="0 0 16 16">
<use href={`${iconsHref}#heart-filled`} />
</svg>
<span>Sponsor</span>
</a>
) : null}
<InitCodeblock initCommand={initCommand} />
<div className="flex w-full max-w-full flex-wrap gap-x-2 gap-y-2">
{tags.map((tag) => (
<ResourceTag key={tag} value={tag}>
{tag}
</ResourceTag>
))}
</div>
</aside>

<hr className="mt-6 w-full border-gray-200 dark:border-gray-700 md:hidden" />

{readmeHtml ? (
<div
// Have to specify the width this way, otherwise the markdown
// content will take up the full container without a care in the
// world for it's sibling -- not unlike my older brother on our
// family's annual summer road trip to the beach.
className="markdown mt-6 w-full pr-0 md:mt-0 md:w-[calc(100%-400px)] md:pr-12 lg:pr-16"
>
<div
className="md-prose"
dangerouslySetInnerHTML={{ __html: readmeHtml }}
/>
</div>
) : null}
</div>
</main>
);
}
Loading
Loading