diff --git a/api/pin.js b/api/pin.js index 21ecf966b3ff4..fe724bbea89a7 100644 --- a/api/pin.js +++ b/api/pin.js @@ -3,6 +3,7 @@ import { blacklist } from "../src/common/blacklist.js"; import { clampValue, CONSTANTS, + getBase64URIFromImage, parseBoolean, renderError, } from "../src/common/utils.js"; @@ -24,6 +25,7 @@ export default async (req, res) => { locale, border_radius, border_color, + show_image, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); @@ -68,6 +70,10 @@ export default async (req, res) => { }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, ); + repoData.stringifiedRepoImage = await getBase64URIFromImage( + repoData.openGraphImageUrl, + ); + return res.send( renderRepoCard(repoData, { hide_border: parseBoolean(hide_border), @@ -80,6 +86,7 @@ export default async (req, res) => { border_color, show_owner: parseBoolean(show_owner), locale: locale ? locale.toLowerCase() : null, + show_image: parseBoolean(show_image), }), ); } catch (err) { diff --git a/readme.md b/readme.md index a010eca3b6d5f..3d1814852110e 100644 --- a/readme.md +++ b/readme.md @@ -386,6 +386,7 @@ If we don't support your language, please consider contributing! You can find mo #### Repo Card Exclusive Options * `show_owner` - Shows the repo's owner name *(boolean)*. Default: `false`. +* `show_image` - Shows the repo's social preview image *(boolean)*. Default: `false`. #### Gist Card Exclusive Options @@ -447,6 +448,11 @@ Use [show\_owner](#repo-card-exclusive-options) query option to include the repo ![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show_owner=true) + +Use [show\_image](#repo-card-exclusive-options) query option to include the repo's social preview image header + +![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show_image=true) + # GitHub Gist Pins GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile. diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 09b5841880a97..fc6ba8a5eb528 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -61,6 +61,7 @@ const renderRepoCard = (repo, options = {}) => { isTemplate, starCount, forkCount, + stringifiedRepoImage, } = repo; const { hide_border = false, @@ -73,6 +74,7 @@ const renderRepoCard = (repo, options = {}) => { border_radius, border_color, locale, + show_image = false, } = options; const lineHeight = 10; @@ -141,6 +143,7 @@ const renderRepoCard = (repo, options = {}) => { height, border_radius, colors, + stringifiedRepoImage: show_image ? stringifiedRepoImage : "", }); card.disableAnimations(); diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index dce964d21af7e..df5fc54cc0d3c 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -32,6 +32,7 @@ export type StatCardOptions = CommonOptions & { export type RepoCardOptions = CommonOptions & { show_owner: boolean; + show_image: boolean; }; export type TopLangOptions = CommonOptions & { diff --git a/src/common/Card.js b/src/common/Card.js index d32da56255f89..6850c8a7b2dfe 100644 --- a/src/common/Card.js +++ b/src/common/Card.js @@ -11,6 +11,8 @@ class Card { * @param {string?=} args.customTitle Card custom title. * @param {string?=} args.defaultTitle Card default title. * @param {string?=} args.titlePrefixIcon Card title prefix icon. + * @param {string?=} args.stringifiedRepoImage Card preview image. + * @param {number?=} args.imageHeight Card preview image. * @param {object?=} args.colors Card colors arguments. * @param {string} args.colors.titleColor Card title color. * @param {string} args.colors.textColor Card text color. @@ -27,6 +29,8 @@ class Card { customTitle, defaultTitle = "", titlePrefixIcon, + stringifiedRepoImage = "", + imageHeight = 200, }) { this.width = width; this.height = height; @@ -36,6 +40,9 @@ class Card { this.border_radius = border_radius; + this.imageHeight = imageHeight; + this.stringifiedRepoImage = stringifiedRepoImage; + // returns theme based colors with proper overrides and defaults this.colors = colors; this.title = @@ -51,6 +58,10 @@ class Card { this.animations = true; this.a11yTitle = ""; this.a11yDesc = ""; + if (this.stringifiedRepoImage) { + this.height += this.imageHeight; + this.paddingY += this.imageHeight; + } } /** @@ -199,6 +210,21 @@ class Card { `; }; + /** + * @returns {string} Renders social preview image + */ + renderImage = () => { + if (!this.stringifiedRepoImage) { + return ""; + } + return ` + + + + `; + }; + /** * @param {string} body The inner body of the card. * @returns {string} The rendered card. @@ -264,6 +290,7 @@ class Card { > ${body} + ${this.renderImage()} `; } diff --git a/src/common/utils.js b/src/common/utils.js index 6792df881ac7a..81edc0a8a572a 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -569,6 +569,27 @@ const dateDiff = (d1, d2) => { return Math.round(diff / (1000 * 60)); }; +const getBase64URIFromImage = async (imageURL) => { + try { + const response = await axios.get(imageURL, { + responseType: "arraybuffer", + }); + + if (response.status === 200) { + const base64Image = Buffer.from(response.data, "binary").toString( + "base64", + ); + const mimeType = response.headers["content-type"]; + return `data:${mimeType};base64,${base64Image}`; + } else { + throw new Error("Failed to fetch the image."); + } + } catch (error) { + console.error("Error:", error); + return null; + } +}; + export { ERROR_CARD_LENGTH, renderError, @@ -595,4 +616,5 @@ export { chunkArray, parseEmojis, dateDiff, + getBase64URIFromImage, }; diff --git a/src/fetchers/repo-fetcher.js b/src/fetchers/repo-fetcher.js index 6438f8895cfb6..b361a987d7a78 100644 --- a/src/fetchers/repo-fetcher.js +++ b/src/fetchers/repo-fetcher.js @@ -34,6 +34,7 @@ const fetcher = (variables, token) => { name } forkCount + openGraphImageUrl } query getRepo($login: String!, $repo: String!) { user(login: $login) { diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts index affb407b816b0..7d0c377b8d4ad 100644 --- a/src/fetchers/types.d.ts +++ b/src/fetchers/types.d.ts @@ -22,6 +22,7 @@ export type RepositoryData = { }; forkCount: number; starCount: number; + stringifiedRepoImage: string; }; export type StatsData = { diff --git a/tests/__snapshots__/renderWakatimeCard.test.js.snap b/tests/__snapshots__/renderWakatimeCard.test.js.snap index f38ac26ef07f7..6592058915ac3 100644 --- a/tests/__snapshots__/renderWakatimeCard.test.js.snap +++ b/tests/__snapshots__/renderWakatimeCard.test.js.snap @@ -150,6 +150,7 @@ exports[`Test Render WakaTime Card should render correctly with compact layout 1 + " `; @@ -302,6 +303,7 @@ exports[`Test Render WakaTime Card should render correctly with compact layout w + " `; diff --git a/tests/renderRepoCard.test.js b/tests/renderRepoCard.test.js index 050e7109490bb..f1e045bf23f2f 100644 --- a/tests/renderRepoCard.test.js +++ b/tests/renderRepoCard.test.js @@ -18,6 +18,7 @@ const data_repo = { }, starCount: 38000, forkCount: 100, + stringifiedRepoImage: "data:image/png;base64,base64/image/string", }, }; @@ -339,4 +340,21 @@ describe("Test renderRepoCard", () => { "No description provided", ); }); + + it("should render repo's social preview image, when show_image is true", () => { + document.body.innerHTML = renderRepoCard(data_repo.repository, { + show_image: true, + }); + + expect(queryByTestId(document.body, "card-image")).toBeInTheDocument(); + expect( + queryByTestId(document.body, "card-image").children[0], + ).toHaveAttribute("href", data_repo.repository.stringifiedRepoImage); + }); + + it("should not render repo's social preview image by default", () => { + document.body.innerHTML = renderRepoCard(data_repo.repository); + + expect(queryByTestId(document.body, "card-image")).not.toBeInTheDocument(); + }); });