Skip to content

Commit 456e502

Browse files
committed
Add GitHub cards via Remark plugin
1 parent 2d79bc1 commit 456e502

File tree

4 files changed

+266
-1
lines changed

4 files changed

+266
-1
lines changed

astro.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
remarkReadingTime,
1515
rehypeTitleFigure,
1616
} from './src/settings-utils'
17+
import { remarkGithubCard } from './src/plugins/remark-github-card'
1718
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
1819
import rehypeExternalLinks from 'rehype-external-links'
1920
import remarkDirective from 'remark-directive' /* Handle ::: directives as nodes */
@@ -34,6 +35,7 @@ export default defineConfig({
3435
[remarkDescription, { maxChars: 200 }],
3536
remarkReadingTime,
3637
remarkDirective,
38+
remarkGithubCard,
3739
remarkAdmonitions,
3840
remarkMath,
3941
remarkGemoji,

src/content/posts/showing-off-blog-features/index.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ testing123
120120
testing123
121121
:::
122122

123+
## GitHub Cards
124+
125+
GitHub overview cards heavily inspired by [Astro Cactus](https://github.com/chrismwilliams/astro-theme-cactus).
126+
127+
```md title="GitHub repo card example in markdown"
128+
::github{repo="stelcodes/multiterm-astro"}
129+
```
130+
131+
::github{repo="stelcodes/multiterm-astro"}
132+
133+
```md title="GitHub user card example in markdown"
134+
::github{user="withastro"}
135+
```
136+
137+
::github{user="withastro"}
138+
123139
## Emoji :star_struck:
124140

125141
Emojis can be added in markdown by including a literal emoji character or a GitHub shortcode. You can browse an unofficial database [here](https://emojibase.dev/emojis?shortcodePresets=github).

src/plugins/remark-github-card.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import type { Root } from "mdast";
2+
import type { Plugin } from "unified";
3+
import { visit } from "unist-util-visit";
4+
import type { Directives } from 'mdast-util-directive'
5+
import type { Node, Paragraph as P } from 'mdast'
6+
import { h as _h, type Properties } from 'hastscript'
7+
8+
/** Checks if a node is a directive. */
9+
function isNodeDirective(node: Node): node is Directives {
10+
return (
11+
node.type === 'containerDirective' ||
12+
node.type === 'leafDirective' ||
13+
node.type === 'textDirective'
14+
)
15+
}
16+
17+
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
18+
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
19+
const { properties, tagName } = _h(el, attrs)
20+
return {
21+
children,
22+
data: { hName: tagName, hProperties: properties },
23+
type: 'paragraph',
24+
}
25+
}
26+
27+
const DIRECTIVE_NAME = "github";
28+
29+
export const remarkGithubCard: Plugin<[], Root> = () => (tree) => {
30+
visit(tree, (node, index, parent) => {
31+
if (!parent || index === undefined || !isNodeDirective(node)) return;
32+
33+
// We only want a leaf directive named DIRECTIVE_NAME
34+
if (node.type !== "leafDirective" || node.name !== DIRECTIVE_NAME) return;
35+
36+
let repoName = node.attributes?.repo ?? node.attributes?.user ?? null;
37+
if (!repoName) return; // Leave the directive as-is if no repo is provided
38+
39+
repoName = repoName.endsWith("/") ? repoName.slice(0, -1) : repoName; // Remove trailing slash
40+
repoName = repoName.startsWith("https://github.com/")
41+
? repoName.replace("https://github.com/", "")
42+
: repoName; // Remove leading URL
43+
44+
const repoParts = repoName.split("/");
45+
const SimpleUUID = `GC-${crypto.randomUUID()}`;
46+
const realUrl = `https://github.com/${repoName}`;
47+
48+
// If its a repo link
49+
if (repoParts.length > 1) {
50+
const script = h("script", {}, [
51+
{
52+
type: "text",
53+
value: `
54+
fetch('https://api.github.com/repos/${repoName}', { referrerPolicy: "no-referrer" })
55+
.then(response => response.json())
56+
.then(data => {
57+
const t = document.getElementById('${SimpleUUID}');
58+
t.classList.remove("gh-loading");
59+
60+
if (data.description) {
61+
t.querySelector('.gh-description').innerText = data.description.replace(/:[a-zA-Z0-9_]+:/g, '');
62+
} else {
63+
t.querySelector('.gh-description').style.display = 'none';
64+
}
65+
if (data.language) t.querySelector('.gh-language').innerText = data.language;
66+
t.querySelector('.gh-forks').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
67+
t.querySelector('.gh-stars').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
68+
const avatarEl = t.querySelector('.gh-avatar');
69+
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
70+
71+
if (data.license?.spdx_id) {
72+
t.querySelector('.gh-license').innerText = data.license?.spdx_id
73+
} else {
74+
t.querySelector('.gh-license').style.display = 'none';
75+
};
76+
})
77+
.catch(err => {
78+
document.getElementById('${SimpleUUID}').classList.add("gh-error")
79+
console.warn("[GITHUB-CARD] Error loading card for ${repoName} | ${SimpleUUID}.", err)
80+
})
81+
`,
82+
},
83+
]);
84+
85+
const hTitle = h("div", { class: "gh-title title" }, [
86+
h("span", { class: "gh-avatar" }),
87+
h("a", { class: "gh-text not-prose cactus-link", href: realUrl }, [
88+
{ type: "text", value: `${repoParts[0]}/${repoParts[1]}` },
89+
]),
90+
h("span", { class: "gh-icon" }),
91+
]);
92+
93+
const hChips = h("div", { class: "gh-chips" }, [
94+
h("span", { class: "gh-stars" }, [{ type: "text", value: "00K" }]),
95+
h("span", { class: "gh-forks" }, [{ type: "text", value: "00K" }]),
96+
h("span", { class: "gh-license" }, [{ type: "text", value: "MIT" }]),
97+
h("span", { class: "gh-language" }, [{ type: "text", value: "" }]),
98+
]);
99+
100+
const hDescription = h("div", { class: "gh-description" }, [
101+
{
102+
type: "text",
103+
value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
104+
},
105+
]);
106+
107+
parent.children.splice(
108+
index,
109+
1,
110+
h("div", { id: SimpleUUID, class: "github-card gh-loading" }, [
111+
hTitle,
112+
hDescription,
113+
hChips,
114+
script,
115+
]),
116+
);
117+
}
118+
119+
// If its a user link
120+
else if (repoParts.length === 1) {
121+
const script = h("script", {}, [
122+
{
123+
type: "text",
124+
value: `
125+
fetch('https://api.github.com/users/${repoName}', { referrerPolicy: "no-referrer" })
126+
.then(response => response.json())
127+
.then(data => {
128+
const t = document.getElementById('${SimpleUUID}');
129+
t.classList.remove("gh-loading");
130+
131+
const avatarEl = t.querySelector('.gh-avatar');
132+
avatarEl.style.backgroundImage = 'url(' + data.avatar_url + ')';
133+
t.querySelector('.gh-followers').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.followers).replaceAll("\u202f", '');
134+
t.querySelector('.gh-repositories').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.public_repos).replaceAll("\u202f", '');
135+
if (data.location) t.querySelector('.gh-region').innerText = data.location;
136+
137+
})
138+
.catch(err => {
139+
const c = document.getElementById('${SimpleUUID}').classList.add("gh-error")
140+
console.warn("[GITHUB-CARD] Error loading card for ${repoName} | ${SimpleUUID}.", err)
141+
})
142+
`,
143+
},
144+
]);
145+
146+
parent.children.splice(
147+
index,
148+
1,
149+
h("div", { id: SimpleUUID, class: "github-card gh-simple gh-loading" }, [
150+
h("div", { class: "gh-title title" }, [
151+
h("span", { class: "gh-avatar" }),
152+
h("a", { class: "gh-text not-prose cactus-link", href: realUrl }, [
153+
{ type: "text", value: repoParts[0] },
154+
]),
155+
h("span", { class: "gh-icon" }),
156+
]),
157+
h("div", { class: "gh-chips" }, [
158+
h("span", { class: "gh-followers" }, [{ type: "text", value: "00K" }]),
159+
h("span", { class: "gh-repositories" }, [{ type: "text", value: "00K" }]),
160+
h("span", { class: "gh-region" }, [{ type: "text", value: "" }]),
161+
]),
162+
script,
163+
]),
164+
);
165+
}
166+
});
167+
};

src/styles/global.css

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,8 @@ article img {
259259
video,
260260
table,
261261
blockquote,
262-
aside {
262+
aside,
263+
.github-card {
263264
@apply my-6.5;
264265
}
265266

@@ -388,6 +389,85 @@ article img {
388389
}
389390
}
390391

392+
.github-card {
393+
@apply bg-foreground/5 rounded-xl px-4 py-3;
394+
395+
.gh-title {
396+
@apply relative flex items-center gap-2 text-base;
397+
398+
.gh-avatar {
399+
@apply bg-foreground/20 h-6 w-6 flex-none rounded-full bg-none bg-cover bg-center;
400+
}
401+
.gh-text {
402+
@apply line-clamp-2;
403+
&:after {
404+
@apply absolute inset-0 content-[''];
405+
}
406+
}
407+
.gh-icon {
408+
@apply bg-foreground pointer-events-none ms-auto h-6 w-6 flex-none;
409+
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='31' height='32' viewBox='0 0 496 512'><path fill='%23a1f7cb' d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6c-3.3.3-5.6-1.3-5.6-3.6c0-2 2.3-3.6 5.2-3.6c3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9c2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9c.3 2 2.9 3.3 5.9 2.6c2.9-.7 4.9-2.6 4.6-4.6c-.3-1.9-3-3.2-5.9-2.9M244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2c12.8 2.3 17.3-5.6 17.3-12.1c0-6.2-.3-40.4-.3-61.4c0 0-70 15-84.7-29.8c0 0-11.4-29.1-27.8-36.6c0 0-22.9-15.7 1.6-15.4c0 0 24.9 2 38.6 25.8c21.9 38.6 58.6 27.5 72.9 20.9c2.3-16 8.8-27.1 16-33.7c-55.9-6.2-112.3-14.3-112.3-110.5c0-27.5 7.6-41.3 23.6-58.9c-2.6-6.5-11.1-33.3 2.6-67.9c20.9-6.5 69 27 69 27c20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27c13.7 34.7 5.2 61.4 2.6 67.9c16 17.7 25.8 31.5 25.8 58.9c0 96.5-58.9 104.2-114.8 110.5c9.2 7.9 17 22.9 17 46.4c0 33.7-.3 75.4-.3 83.6c0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252C496 113.3 383.5 8 244.8 8M97.2 352.9c-1.3 1-1 3.3.7 5.2c1.6 1.6 3.9 2.3 5.2 1c1.3-1 1-3.3-.7-5.2c-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9c1.6 1 3.6.7 4.3-.7c.7-1.3-.3-2.9-2.3-3.9c-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2c2.3 2.3 5.2 2.6 6.5 1c1.3-1.3.7-4.3-1.3-6.2c-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9c1.6 2.3 4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2c-1.4-2.3-4-3.3-5.6-2'/%3E%3C/svg%3E")
410+
center center / 24px auto no-repeat;
411+
}
412+
}
413+
.gh-description {
414+
@apply mt-4 leading-tight;
415+
}
416+
.gh-chips {
417+
@apply mt-4 flex flex-wrap items-center gap-4;
418+
419+
.gh-stars,
420+
.gh-forks,
421+
.gh-license,
422+
.gh-followers,
423+
.gh-repositories {
424+
@apply flex items-center gap-x-1;
425+
&:before {
426+
@apply bg-foreground block h-5 w-5 content-[''];
427+
mask: var(--chip-image) center center/16px auto no-repeat;
428+
}
429+
}
430+
.gh-stars {
431+
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z'/></svg>");
432+
}
433+
.gh-forks {
434+
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z'/></svg>");
435+
}
436+
.gh-license {
437+
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z'/></svg>");
438+
}
439+
.gh-followers {
440+
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' text='muted' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z'/></svg>");
441+
}
442+
.gh-repositories {
443+
--chip-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' height='16' viewBox='0 0 16 16' version='1.1' width='16'><path d='M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z'/></svg>");
444+
}
445+
.gh-language,
446+
.gh-region {
447+
@apply ms-auto;
448+
}
449+
}
450+
451+
&.gh-loading {
452+
.gh-title .gh-avatar,
453+
.gh-description,
454+
.gh-chips span {
455+
@apply bg-foreground/50 animate-pulse rounded-xl text-transparent;
456+
}
457+
.gh-chips span:before {
458+
@apply bg-transparent;
459+
}
460+
}
461+
462+
&.gh-error {
463+
.gh-avatar,
464+
.gh-description,
465+
.gh-chips {
466+
@apply hidden;
467+
}
468+
}
469+
}
470+
391471
table,
392472
th,
393473
td {

0 commit comments

Comments
 (0)