diff --git a/src/library/GithubPermalink/github-permalink.css b/src/library/GithubPermalink/github-permalink.css index 5229982..b2f5f6d 100644 --- a/src/library/GithubPermalink/github-permalink.css +++ b/src/library/GithubPermalink/github-permalink.css @@ -400,3 +400,70 @@ svg.github-logo{ margin: 0 2px; } } + +/* Repository Link Styles */ +.react-github-repository-link { + border: 1px solid var(--rgp-color-border); + border-radius: 6px; + background-color: var(--rgp-color-bg-stark); + color: var(--rgp-color-text-stark); + font-family: "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + padding: 16px; + margin: 8px 0; +} + +.react-github-repository-link-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.react-github-repository-link-repo { + display: flex; + align-items: center; + gap: 8px; +} + +.react-github-repository-link-name { + color: var(--rgp-color-file-link); + font-weight: 600; + font-size: 16px; + text-decoration: none; +} + +.react-github-repository-link-name:hover { + text-decoration: underline; +} + +.react-github-repository-link-stats { + display: flex; + gap: 16px; + color: var(--rgp-color-text-frame); + font-size: 14px; +} + +.react-github-repository-link-description { + color: var(--rgp-color-text-frame); + font-size: 14px; + line-height: 1.5; +} + +.react-github-repository-link-inline { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--rgp-color-file-link); + text-decoration: none; + font-size: 14px; +} + +.react-github-repository-link-inline:hover { + text-decoration: underline; +} + +.react-github-repository-link-counts { + margin-left: 8px; + color: var(--rgp-color-text-frame); + font-size: 12px; +} diff --git a/src/library/GithubRepositoryLink/GithubRepositoryLink.stories.tsx b/src/library/GithubRepositoryLink/GithubRepositoryLink.stories.tsx new file mode 100644 index 0000000..499c302 --- /dev/null +++ b/src/library/GithubRepositoryLink/GithubRepositoryLink.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { GithubRepositoryLink } from "./GithubRepositoryLink"; +import { GithubPermalinkProvider } from "../config/GithubPermalinkContext"; +import "../GithubPermalink/github-permalink.css"; + +const meta: Meta = { + component: GithubRepositoryLink, +}; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + render: () => ( + + ), +}; + +export const Inline: Story = { + render: () => ( +
+

Check out this repository: for more details.

+
+ ), +}; + +export const WithToken: Story = { + render: () => ( + + + + ), +}; + +export const CustomDataFn: Story = { + render: () => ( + { + return Promise.resolve({ + owner: "example", + repo: "test-repo", + name: "test-repo", + fullName: "example/test-repo", + description: "A test repository with custom data", + stargazersCount: 100, + forksCount: 25, + htmlUrl: "https://github.com/example/test-repo", + status: "ok" + }); + }} + > + + + ), +}; + +export const ErrorReporting: Story = { + render: () => ( + console.error(err)}> + + + ), +}; \ No newline at end of file diff --git a/src/library/GithubRepositoryLink/GithubRepositoryLink.tsx b/src/library/GithubRepositoryLink/GithubRepositoryLink.tsx new file mode 100644 index 0000000..4d56b64 --- /dev/null +++ b/src/library/GithubRepositoryLink/GithubRepositoryLink.tsx @@ -0,0 +1,25 @@ +import { useContext, useEffect, useState } from "react"; +import { GithubPermalinkContext } from "../config/GithubPermalinkContext"; +import { GithubRepositoryLinkDataResponse } from "../config/GithubPermalinkContext"; +import { GithubRepositoryLinkBase, GithubRepositoryLinkBaseProps } from "./GithubRepositoryLinkBase"; + +export type GithubRepositoryLinkProps = Omit & { + repositoryLink: string; +}; + +export function GithubRepositoryLink(props: GithubRepositoryLinkProps) { + const { getRepositoryFn, githubToken, onError } = useContext(GithubPermalinkContext); + const [data, setData] = useState({ status: "other-error" }); + const { repositoryLink } = props; + + useEffect(() => { + getRepositoryFn(repositoryLink, githubToken, onError).then((v) => { + setData(v); + }).catch((err) => { + onError?.(err); + setData({ status: "other-error" }); + }); + }, [repositoryLink, getRepositoryFn, githubToken, onError]); + + return ; +} \ No newline at end of file diff --git a/src/library/GithubRepositoryLink/GithubRepositoryLinkBase.stories.tsx b/src/library/GithubRepositoryLink/GithubRepositoryLinkBase.stories.tsx new file mode 100644 index 0000000..f9a84c8 --- /dev/null +++ b/src/library/GithubRepositoryLink/GithubRepositoryLinkBase.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { GithubRepositoryLinkBase } from "./GithubRepositoryLinkBase"; +import "../GithubPermalink/github-permalink.css"; + +const meta: Meta = { + component: GithubRepositoryLinkBase, +}; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + render: () => ( + + ), +}; + +export const Inline: Story = { + render: () => ( +
+

Check out this repository: for more details.

+
+ ), +}; + +export const WithoutDescription: Story = { + render: () => ( + + ), +}; + +export const WithoutCounts: Story = { + render: () => ( + + ), +}; + +export const ErrorState: Story = { + render: () => ( + + ), +}; + +export const NoDescription: Story = { + render: () => ( + + ), +}; \ No newline at end of file diff --git a/src/library/GithubRepositoryLink/GithubRepositoryLinkBase.tsx b/src/library/GithubRepositoryLink/GithubRepositoryLinkBase.tsx new file mode 100644 index 0000000..fee686e --- /dev/null +++ b/src/library/GithubRepositoryLink/GithubRepositoryLinkBase.tsx @@ -0,0 +1,77 @@ +import { PropsWithChildren } from "react"; +import { GithubSvg } from "../GithubSvg/GithubSvg"; +import { GithubRepositoryLinkDataResponse } from "../config/GithubPermalinkContext"; +import { ErrorMessages } from "../ErrorMessages/ErrorMessages"; + +export type GithubRepositoryLinkBaseProps = { + className?: string; + repositoryLink: string; + data: GithubRepositoryLinkDataResponse; + variant?: "inline" | "block"; + showDescription?: boolean; + showCounts?: boolean; +} + +export function GithubRepositoryLinkBase(props: GithubRepositoryLinkBaseProps) { + const { + data, + variant = "block", + repositoryLink, + showDescription = true, + showCounts = true + } = props; + + if (data.status === "ok") { + if (variant === "inline") { + return ( + + + {data.fullName} + {showCounts && ( + + ⭐ {data.stargazersCount} 🍴 {data.forksCount} + + )} + + ); + } + + return ( + +
+ + {showCounts && ( +
+ + ⭐ {data.stargazersCount} + + + 🍴 {data.forksCount} + +
+ )} +
+ {showDescription && data.description && ( +
+ {data.description} +
+ )} +
+ ); + } + + return ; +} + +export function GithubRepositoryLinkInner(props: PropsWithChildren) { + return ( +
+ {props.children} +
+ ); +} \ No newline at end of file diff --git a/src/library/GithubRepositoryLink/GithubRepositoryLinkRsc.tsx b/src/library/GithubRepositoryLink/GithubRepositoryLinkRsc.tsx new file mode 100644 index 0000000..055ae31 --- /dev/null +++ b/src/library/GithubRepositoryLink/GithubRepositoryLinkRsc.tsx @@ -0,0 +1,21 @@ +import { GithubRepositoryLinkBase, GithubRepositoryLinkBaseProps } from "./GithubRepositoryLinkBase"; +import { githubPermalinkRscConfig } from "../config/GithubPermalinkRscConfig"; + +export type GithubRepositoryLinkRscProps = Omit & { + repositoryLink: string; +}; + +export async function GithubRepositoryLinkRsc(props: GithubRepositoryLinkRscProps) { + const { repositoryLink } = props; + const { getRepositoryFn, githubToken, onError } = githubPermalinkRscConfig.getConfig(); + + let data; + try { + data = await getRepositoryFn(repositoryLink, githubToken, onError); + } catch (err) { + onError?.(err); + data = { status: "other-error" as const }; + } + + return ; +} \ No newline at end of file diff --git a/src/library/config/BaseConfiguration.ts b/src/library/config/BaseConfiguration.ts index f75651f..0f095d8 100644 --- a/src/library/config/BaseConfiguration.ts +++ b/src/library/config/BaseConfiguration.ts @@ -1,5 +1,6 @@ import { defaultGetPermalinkFn } from "./defaultFunctions"; import { defaultGetIssueFn } from "./defaultFunctions"; +import { defaultGetRepositoryFn } from "./defaultFunctions"; export type BaseConfiguration = { @@ -11,6 +12,9 @@ export type BaseConfiguration = { /** Function to provide issue data payload */ getIssueFn: typeof defaultGetIssueFn; + /** Function to provide repository data payload */ + getRepositoryFn: typeof defaultGetRepositoryFn; + /** * A github personal access token - will be passed to the data fetching functions */ diff --git a/src/library/config/GithubPermalinkContext.tsx b/src/library/config/GithubPermalinkContext.tsx index c9b7f57..06c46f2 100644 --- a/src/library/config/GithubPermalinkContext.tsx +++ b/src/library/config/GithubPermalinkContext.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren, createContext } from "react"; import { BaseConfiguration } from "./BaseConfiguration"; import { defaultGetIssueFn } from "./defaultFunctions"; import { defaultGetPermalinkFn } from "./defaultFunctions"; +import { defaultGetRepositoryFn } from "./defaultFunctions"; // Thanks ChatGPT export type GithubPermalinkUrlInfo = { @@ -56,6 +57,18 @@ export type GithubIssueLinkDataResponse = { } } | ErrorResponses; +export type GithubRepositoryLinkDataResponse = { + owner: string; + repo: string; + name: string; + fullName: string; + description: string | null; + stargazersCount: number; + forksCount: number; + htmlUrl: string; + status: "ok" +} | ErrorResponses; + @@ -63,12 +76,14 @@ export type GithubIssueLinkDataResponse = { export const GithubPermalinkContext = createContext({ getDataFn: defaultGetPermalinkFn, getIssueFn: defaultGetIssueFn, + getRepositoryFn: defaultGetRepositoryFn, }); export function GithubPermalinkProvider(props: PropsWithChildren>) { return diff --git a/src/library/config/GithubPermalinkRscConfig.ts b/src/library/config/GithubPermalinkRscConfig.ts index 0e66ded..2e081f1 100644 --- a/src/library/config/GithubPermalinkRscConfig.ts +++ b/src/library/config/GithubPermalinkRscConfig.ts @@ -1,9 +1,10 @@ import { BaseConfiguration } from "./BaseConfiguration"; -import { defaultGetIssueFn, defaultGetPermalinkFn } from "./defaultFunctions"; +import { defaultGetIssueFn, defaultGetPermalinkFn, defaultGetRepositoryFn } from "./defaultFunctions"; const defaultConfiguration = { getDataFn: defaultGetPermalinkFn, getIssueFn: defaultGetIssueFn, + getRepositoryFn: defaultGetRepositoryFn, }; class GithubPermalinkRscConfig { @@ -15,6 +16,10 @@ class GithubPermalinkRscConfig { }; } + public getConfig() { + return this.baseConfiguration; + } + public getPermalinkFn() { return this.baseConfiguration.getDataFn; } @@ -23,6 +28,10 @@ class GithubPermalinkRscConfig { return this.baseConfiguration.getIssueFn; } + public getRepositoryFn() { + return this.baseConfiguration.getRepositoryFn; + } + public getGithubToken() { return this.baseConfiguration.githubToken; } diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts index 63f54ad..5a3813b 100644 --- a/src/library/config/defaultFunctions.ts +++ b/src/library/config/defaultFunctions.ts @@ -1,5 +1,5 @@ -import { GithubIssueLinkDataResponse } from "./GithubPermalinkContext"; -import { parseGithubIssueLink, parseGithubPermalinkUrl } from "../utils/urlParsers"; +import { GithubIssueLinkDataResponse, GithubRepositoryLinkDataResponse } from "./GithubPermalinkContext"; +import { parseGithubIssueLink, parseGithubPermalinkUrl, parseGithubRepositoryLink } from "../utils/urlParsers"; import { GithubPermalinkDataResponse } from "./GithubPermalinkContext"; import { ErrorResponses } from "./GithubPermalinkContext"; @@ -76,6 +76,37 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string, status: "ok" }; } +export async function defaultGetRepositoryFn(repositoryLink: string, githubToken?: string, onError?: (err: unknown) => void): Promise { + const config = parseGithubRepositoryLink(repositoryLink); + + const options = githubToken ? { + headers: { + Authorization: `Bearer ${githubToken}` + } + } : undefined; + + const repositoryResult = await fetch(`https://api.github.com/repos/${config.owner}/${config.repo}`, options); + + if (!repositoryResult.ok) { + onError?.(repositoryResult); + return handleResponse(repositoryResult); + } + + const repositoryJson = await repositoryResult.json(); + + return { + owner: config.owner, + repo: config.repo, + name: repositoryJson.name, + fullName: repositoryJson.full_name, + description: repositoryJson.description, + stargazersCount: repositoryJson.stargazers_count, + forksCount: repositoryJson.forks_count, + htmlUrl: repositoryJson.html_url, + status: "ok" + }; +} + export function handleResponse(response: Response): ErrorResponses { if (response.status === 404) { return { status: "404" }; diff --git a/src/library/export.ts b/src/library/export.ts index 31db22b..6bda10e 100644 --- a/src/library/export.ts +++ b/src/library/export.ts @@ -3,4 +3,6 @@ export * from "./GithubPermalink/GithubPermalink"; export * from "./GithubPermalink/GithubPermalinkBase"; export * from "./GithubIssueLink/GithubIssueLink"; export * from "./GithubIssueLink/GithubIssueLinkBase" +export * from "./GithubRepositoryLink/GithubRepositoryLink"; +export * from "./GithubRepositoryLink/GithubRepositoryLinkBase"; export * from "./config/GithubPermalinkContext"; diff --git a/src/library/rsc.ts b/src/library/rsc.ts index ce35b3d..63d06e6 100644 --- a/src/library/rsc.ts +++ b/src/library/rsc.ts @@ -1,3 +1,4 @@ export * from "./GithubPermalink/GithubPermalinkRsc"; export * from "./config/GithubPermalinkRscConfig"; -export * from "./GithubIssueLink/GithubIssueLinkRsc"; \ No newline at end of file +export * from "./GithubIssueLink/GithubIssueLinkRsc"; +export * from "./GithubRepositoryLink/GithubRepositoryLinkRsc"; \ No newline at end of file diff --git a/src/library/utils/urlParsers.test.ts b/src/library/utils/urlParsers.test.ts index b8376ba..308aed9 100644 --- a/src/library/utils/urlParsers.test.ts +++ b/src/library/utils/urlParsers.test.ts @@ -1,5 +1,5 @@ import { expect, test, it, describe } from 'vitest' -import { parseGithubPermalinkUrl } from "./urlParsers"; +import { parseGithubPermalinkUrl, parseGithubRepositoryLink } from "./urlParsers"; describe(parseGithubPermalinkUrl, () => { it("behaves correctly for correct urls", () => { @@ -32,3 +32,32 @@ describe(parseGithubPermalinkUrl, () => { }) }) }); + +describe(parseGithubRepositoryLink, () => { + it("behaves correctly for repository URLs", () => { + expect( + parseGithubRepositoryLink("https://github.com/dwjohnston/react-github-permalink") + ).toEqual({ + "owner": "dwjohnston", + "repo": "react-github-permalink", + }); + }); + + it("behaves correctly for repository URLs with trailing paths", () => { + expect( + parseGithubRepositoryLink("https://github.com/dwjohnston/react-github-permalink/tree/main") + ).toEqual({ + "owner": "dwjohnston", + "repo": "react-github-permalink", + }); + }); + + it("behaves correctly for repository URLs with trailing slash", () => { + expect( + parseGithubRepositoryLink("https://github.com/dwjohnston/react-github-permalink/") + ).toEqual({ + "owner": "dwjohnston", + "repo": "react-github-permalink", + }); + }); +}); diff --git a/src/library/utils/urlParsers.ts b/src/library/utils/urlParsers.ts index 34f203b..a290863 100644 --- a/src/library/utils/urlParsers.ts +++ b/src/library/utils/urlParsers.ts @@ -36,3 +36,15 @@ export function parseGithubIssueLink(url: string): { owner: string, repo: string throw new Error("Invalid issue link URL"); } } + +export function parseGithubRepositoryLink(url: string): { owner: string, repo: string } { + const regex = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/; + const match = url.match(regex); + + if (match) { + const [, owner, repo] = match; + return { owner, repo }; + } else { + throw new Error("Invalid repository link URL"); + } +}