From a6e578077fb99611dd8ec3f67812b6ea96c1f4c0 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 19 Aug 2026 14:11:56 -0400 Subject: [PATCH] feat(plugins): add remark admonitions, GitHub card, and reading time plugins --- src/plugins/remark-admonitions.ts | 82 ++++++++++++++++ src/plugins/remark-github-card.ts | 146 +++++++++++++++++++++++++++++ src/plugins/remark-reading-time.ts | 11 +++ 3 files changed, 239 insertions(+) create mode 100644 src/plugins/remark-admonitions.ts create mode 100644 src/plugins/remark-github-card.ts create mode 100644 src/plugins/remark-reading-time.ts diff --git a/src/plugins/remark-admonitions.ts b/src/plugins/remark-admonitions.ts new file mode 100644 index 0000000..27900f4 --- /dev/null +++ b/src/plugins/remark-admonitions.ts @@ -0,0 +1,82 @@ +import type { Parent, PhrasingContent, Root } from "mdast"; +import type { LeafDirective, TextDirective } from "mdast-util-directive"; +import { directiveToMarkdown } from "mdast-util-directive"; +import { toMarkdown } from "mdast-util-to-markdown"; +import { toString as mdastToString } from "mdast-util-to-string"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; +import type { AdmonitionType } from "@/types"; +import { h, isNodeDirective } from "../utils/remark"; + +// Supported admonition types +const Admonitions = new Set(["tip", "note", "important", "caution", "warning"]); + +/** Checks if a string is a supported admonition type. */ +function isAdmonition(s: string): s is AdmonitionType { + return Admonitions.has(s as AdmonitionType); +} + +/** + * From Astro Starlight: + * Transforms directives not supported back to original form as it can break user content and result in 'broken' output. + */ +function transformUnhandledDirective( + node: LeafDirective | TextDirective, + index: number, + parent: Parent, +) { + const textNode = { + type: "text", + value: toMarkdown(node, { extensions: [directiveToMarkdown()] }), + } as const; + if (node.type === "textDirective") { + parent.children[index] = textNode; + } else { + parent.children[index] = { + children: [textNode], + type: "paragraph", + }; + } +} + +export const remarkAdmonitions: Plugin<[], Root> = () => (tree) => { + visit(tree, (node, index, parent) => { + if (!parent || index === undefined || !isNodeDirective(node)) return; + if (node.type === "textDirective" || node.type === "leafDirective") { + transformUnhandledDirective(node, index, parent); + return; + } + + const admonitionType = node.name; + if (!isAdmonition(admonitionType)) return; + + let title: string = admonitionType; + let titleNode: PhrasingContent[] = [{ type: "text", value: title }]; + + // Check if there's a custom title + const firstChild = node.children[0]; + if ( + firstChild?.type === "paragraph" && + firstChild.data && + "directiveLabel" in firstChild.data && + firstChild.children.length > 0 + ) { + titleNode = firstChild.children; + title = mdastToString(firstChild.children); + // The first paragraph contains a custom title, we can safely remove it. + node.children.splice(0, 1); + } + + // Do not change prefix to AD, ADM, or similar, adblocks will block the content inside. + const admonition = h( + "aside", + { "aria-label": title, class: "admonition", "data-admonition-type": admonitionType }, + [ + h("p", { class: "admonition-title", "aria-hidden": "true" }, [...titleNode]), + h("div", { class: "admonition-content" }, node.children), + ], + ); + + parent.children[index] = admonition; + }); +}; diff --git a/src/plugins/remark-github-card.ts b/src/plugins/remark-github-card.ts new file mode 100644 index 0000000..ca4f3ce --- /dev/null +++ b/src/plugins/remark-github-card.ts @@ -0,0 +1,146 @@ +import type { Root } from "mdast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; +import { h, isNodeDirective } from "../utils/remark"; + +const DIRECTIVE_NAME = "github"; + +export const remarkGithubCard: Plugin<[], Root> = () => (tree) => { + visit(tree, (node, index, parent) => { + if (!parent || index === undefined || !isNodeDirective(node)) return; + + // We only want a leaf directive named DIRECTIVE_NAME + if (node.type !== "leafDirective" || node.name !== DIRECTIVE_NAME) return; + + let repoName = node.attributes?.repo ?? node.attributes?.user ?? null; + if (!repoName) return; // Leave the directive as-is if no repo is provided + + repoName = repoName.endsWith("/") ? repoName.slice(0, -1) : repoName; // Remove trailing slash + repoName = repoName.startsWith("https://github.com/") + ? repoName.replace("https://github.com/", "") + : repoName; // Remove leading URL + + const repoParts = repoName.split("/"); + const SimpleUUID = `GC-${crypto.randomUUID()}`; + const realUrl = `https://github.com/${repoName}`; + + // If its a repo link + if (repoParts.length > 1) { + const script = h("script", {}, [ + { + type: "text", + value: ` + fetch('https://api.github.com/repos/${repoName}', { referrerPolicy: "no-referrer" }) + .then(response => response.json()) + .then(data => { + const t = document.getElementById('${SimpleUUID}'); + t.classList.remove("gh-loading"); + + if (data.description) { + t.querySelector('.gh-description').innerText = data.description.replace(/:[a-zA-Z0-9_]+:/g, ''); + } else { + t.querySelector('.gh-description').style.display = 'none'; + } + if (data.language) t.querySelector('.gh-language').innerText = data.language; + t.querySelector('.gh-forks').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", ''); + t.querySelector('.gh-stars').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", ''); + const avatarEl = t.querySelector('.gh-avatar'); + avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')'; + + if (data.license?.spdx_id) { + t.querySelector('.gh-license').innerText = data.license?.spdx_id + } else { + t.querySelector('.gh-license').style.display = 'none'; + }; + }) + .catch(err => { + document.getElementById('${SimpleUUID}').classList.add("gh-error") + console.warn("[GITHUB-CARD] Error loading card for ${repoName} | ${SimpleUUID}.", err) + }) + `, + }, + ]); + + const hTitle = h("div", { class: "gh-title title" }, [ + h("span", { class: "gh-avatar" }), + h("a", { class: "gh-text not-prose cactus-link", href: realUrl }, [ + { type: "text", value: `${repoParts[0]}/${repoParts[1]}` }, + ]), + h("span", { class: "gh-icon" }), + ]); + + const hChips = h("div", { class: "gh-chips" }, [ + h("span", { class: "gh-stars" }, [{ type: "text", value: "00K" }]), + h("span", { class: "gh-forks" }, [{ type: "text", value: "00K" }]), + h("span", { class: "gh-license" }, [{ type: "text", value: "MIT" }]), + h("span", { class: "gh-language" }, [{ type: "text", value: "" }]), + ]); + + const hDescription = h("div", { class: "gh-description" }, [ + { + type: "text", + value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + }, + ]); + + parent.children.splice( + index, + 1, + h("div", { id: SimpleUUID, class: "github-card gh-loading" }, [ + hTitle, + hDescription, + hChips, + script, + ]), + ); + } + + // If its a user link + else if (repoParts.length === 1) { + const script = h("script", {}, [ + { + type: "text", + value: ` + fetch('https://api.github.com/users/${repoName}', { referrerPolicy: "no-referrer" }) + .then(response => response.json()) + .then(data => { + const t = document.getElementById('${SimpleUUID}'); + t.classList.remove("gh-loading"); + + const avatarEl = t.querySelector('.gh-avatar'); + avatarEl.style.backgroundImage = 'url(' + data.avatar_url + ')'; + t.querySelector('.gh-followers').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.followers).replaceAll("\u202f", ''); + t.querySelector('.gh-repositories').innerText = Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(data.public_repos).replaceAll("\u202f", ''); + if (data.location) t.querySelector('.gh-region').innerText = data.location; + + }) + .catch(err => { + const c = document.getElementById('${SimpleUUID}').classList.add("gh-error") + console.warn("[GITHUB-CARD] Error loading card for ${repoName} | ${SimpleUUID}.", err) + }) + `, + }, + ]); + + parent.children.splice( + index, + 1, + h("div", { id: SimpleUUID, class: "github-card gh-simple gh-loading" }, [ + h("div", { class: "gh-title title" }, [ + h("span", { class: "gh-avatar" }), + h("a", { class: "gh-text not-prose cactus-link", href: realUrl }, [ + { type: "text", value: repoParts[0] }, + ]), + h("span", { class: "gh-icon" }), + ]), + h("div", { class: "gh-chips" }, [ + h("span", { class: "gh-followers" }, [{ type: "text", value: "00K" }]), + h("span", { class: "gh-repositories" }, [{ type: "text", value: "00K" }]), + h("span", { class: "gh-region" }, [{ type: "text", value: "" }]), + ]), + script, + ]), + ); + } + }); +}; diff --git a/src/plugins/remark-reading-time.ts b/src/plugins/remark-reading-time.ts new file mode 100644 index 0000000..843dde1 --- /dev/null +++ b/src/plugins/remark-reading-time.ts @@ -0,0 +1,11 @@ +import { toString as mdastToString } from "mdast-util-to-string"; +import getReadingTime from "reading-time"; + +export function remarkReadingTime() { + // @ts-expect-error:next-line + return (tree, { data }) => { + const textOnPage = mdastToString(tree); + const readingTime = getReadingTime(textOnPage); + data.astro.frontmatter.readingTime = readingTime.text; + }; +}