diff --git a/astro.config.mjs b/astro.config.mjs index 621c415998..f5cdd7967d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -19,6 +19,7 @@ import { expressiveCodeConfig } from "./src/config.ts"; import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge.ts"; import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs"; import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"; +import { GithubFileCardComponent } from "./src/plugins/rehype-component-github-file-card.mjs"; import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js"; import { remarkExcerpt } from "./src/plugins/remark-excerpt.js"; import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs"; @@ -120,6 +121,7 @@ export default defineConfig({ { components: { github: GithubCardComponent, + githubfile: GithubFileCardComponent, note: (x, y) => AdmonitionComponent(x, y, "note"), tip: (x, y) => AdmonitionComponent(x, y, "tip"), important: (x, y) => AdmonitionComponent(x, y, "important"), diff --git a/src/content/posts/markdown-extended.md b/src/content/posts/markdown-extended.md index c28173b6ef..9194d6006c 100644 --- a/src/content/posts/markdown-extended.md +++ b/src/content/posts/markdown-extended.md @@ -6,11 +6,11 @@ description: 'Read more about Markdown features in Fuwari' image: '' tags: [Demo, Example, Markdown, Fuwari] category: 'Examples' -draft: false +draft: false --- ## GitHub Repository Cards -You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API. +You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API. ::github{repo="Fabrizz/MMM-OnSpotify"} @@ -20,6 +20,18 @@ Create a GitHub repository card with the code `::github{repo="/"}`. ::github{repo="saicaca/fuwari"} ``` +## GitHub File Cards +You can add dynamic cards that link to a specific file in a GitHub repository, on page load, the file metadata is pulled from the GitHub API. + +::githubfile{repo="saicaca/fuwari" file="README.md" description="Theme overview"} + +Create a GitHub file card with the code `::githubfile{repo="/" file="path/to/file.ext"}`. + +Optional parameters: `description` (string) and `path` (alias for `file`). + +```markdown +::githubfile{repo="saicaca/fuwari" file="README.md" description="Theme overview"} +``` ## Admonitions Following types of admonitions are supported: `note` `tip` `important` `warning` `caution` diff --git a/src/plugins/rehype-component-github-file-card.mjs b/src/plugins/rehype-component-github-file-card.mjs new file mode 100644 index 0000000000..88107b7ca5 --- /dev/null +++ b/src/plugins/rehype-component-github-file-card.mjs @@ -0,0 +1,203 @@ +/// +import { h } from "hastscript"; + +/** + * Creates a GitHub File Card component. + * + * @param {Object} properties - The properties of the component. + * @param {string} properties.repo - The GitHub repository in the format "owner/repo". + * @param {string} properties.file - The file path in the repository. + * @param {string} properties.description + * @param {import('mdast').RootContent[]} children - The children elements of the component. + * @returns {import('mdast').Parent} The created GitHub File Card component. + */ +export function GithubFileCardComponent(properties, children) { + if (Array.isArray(children) && children.length !== 0) + return h("div", { class: "hidden" }, [ + 'Invalid directive. ("githubfile" directive must be leaf type "::githubfile{repo="owner/repo" file="path/to/file"}")', + ]); + + if (!properties.repo || !properties.repo.includes("/")) + return h( + "div", + { class: "hidden" }, + 'Invalid repository. ("repo" attribute must be in the format "owner/repo")', + ); + + const filePath = properties.file || properties.path; + if (!filePath) + return h( + "div", + { class: "hidden" }, + 'Invalid file. ("file" attribute must be set to a repo path like "path/to/file.ext")', + ); + + const description = properties.description || "No description"; + + const repo = properties.repo; + const owner = repo.split("/")[0]; + const repoName = repo.split("/")[1]; + const ref = properties.ref || "HEAD"; + const fileName = filePath.split("/").filter(Boolean).pop(); + if (!fileName) + return h( + "div", + { class: "hidden" }, + 'Invalid file. ("file" attribute must include a file name)', + ); + + const fileTypeLabel = fileName.includes(".") + ? fileName.split(".").pop().toLowerCase() + : "file"; + + const encodedFilePath = filePath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const refQuery = ref === "HEAD" ? "" : `?ref=${encodeURIComponent(ref)}`; + const contentsUrl = `https://api.github.com/repos/${repo}/contents/${encodedFilePath}${refQuery}`; + const commitRefQuery = + ref === "HEAD" ? "" : `&sha=${encodeURIComponent(ref)}`; + const commitsUrl = `https://api.github.com/repos/${repo}/commits?path=${encodeURIComponent(filePath)}&per_page=1${commitRefQuery}`; + + const cardUuid = `GFC${Math.random().toString(36).slice(-6)}`; // Collisions are not important + const fileUrl = encodeURI( + `https://github.com/${repo}/blob/${ref}/${filePath}`, + ); + + const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" }); + + const nTitle = h("div", { class: "gc-titlebar" }, [ + h("div", { class: "gc-titlebar-left" }, [ + h("div", { class: "gc-owner" }, [ + nAvatar, + h("div", { class: "gc-user" }, owner), + ]), + h("div", { class: "gc-divider" }, "/"), + h("div", { class: "gc-user" }, repoName), + h("div", { class: "gc-divider" }, "/"), + h("div", { class: "gc-repo" }, fileName), + ]), + h("div", { class: "github-logo" }), + ]); + + const nDescription = h( + `div#${cardUuid}-description`, + { class: "gc-description" }, + description, + ); + + const nFileType = h( + `div#${cardUuid}-filetype`, + { class: "gc-filetype" }, + fileTypeLabel, + ); + const nFileSize = h( + `div#${cardUuid}-filesize`, + { class: "gc-filesize" }, + "...", + ); + const nUpdated = h(`div#${cardUuid}-updated`, { class: "gc-updated" }, "..."); + const nInfoBar = h("div", { class: "gc-infobar" }, [ + nFileType, + nFileSize, + nUpdated, + ]); + + const nScript = h( + `script#${cardUuid}-script`, + { type: "text/javascript", defer: true }, + ` + const initGithubFileCard = () => { + const cardEl = document.getElementById('${cardUuid}-card'); + const avatarEl = document.getElementById('${cardUuid}-avatar'); + const fileTypeEl = document.getElementById('${cardUuid}-filetype'); + const fileSizeEl = document.getElementById('${cardUuid}-filesize'); + const updatedEl = document.getElementById('${cardUuid}-updated'); + if (!cardEl || !avatarEl || !fileTypeEl || !fileSizeEl || !updatedEl) { + console.warn("[GITHUB-FILE-CARD] Missing card elements for ${repo} | ${cardUuid}."); + return; + } + + const formatBytes = (bytes) => { + if (typeof bytes !== "number" || Number.isNaN(bytes)) return "unknown"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + const precision = size < 10 && unit > 0 ? 1 : 0; + return size.toFixed(precision) + units[unit]; + }; + + const formatDate = (isoString) => { + if (!isoString) return "unknown"; + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return "unknown"; + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "2-digit", + }); + }; + + const repoFetch = fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }) + .then(response => response.json()) + .then(data => { + const avatarUrl = data?.owner?.avatar_url || 'https://github.com/${owner}.png?size=96'; + avatarEl.style.backgroundImage = 'url(' + avatarUrl + ')'; + avatarEl.style.backgroundColor = 'transparent'; + }) + .catch(() => { + console.warn("[GITHUB-FILE-CARD] (Error) Loading avatar for ${repo} | ${cardUuid}."); + }); + + const contentsFetch = fetch('${contentsUrl}', { referrerPolicy: "no-referrer" }) + .then(response => response.json()) + .then(data => { + fileSizeEl.innerText = formatBytes(data?.size); + }) + .catch(() => { + fileSizeEl.innerText = "unknown"; + console.warn("[GITHUB-FILE-CARD] (Error) Loading file size for ${repo} | ${cardUuid}."); + }); + + const commitsFetch = fetch('${commitsUrl}', { referrerPolicy: "no-referrer" }) + .then(response => response.json()) + .then(data => { + const commitDate = data?.[0]?.commit?.committer?.date || data?.[0]?.commit?.author?.date; + updatedEl.innerText = formatDate(commitDate); + }) + .catch(() => { + updatedEl.innerText = "unknown"; + console.warn("[GITHUB-FILE-CARD] (Error) Loading commit date for ${repo} | ${cardUuid}."); + }); + + Promise.allSettled([repoFetch, contentsFetch, commitsFetch]).then(() => { + cardEl.classList.remove("fetch-waiting"); + console.log("[GITHUB-FILE-CARD] Loaded card for ${repo} | ${cardUuid}."); + }); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initGithubFileCard, { once: true }); + } else { + initGithubFileCard(); + } + `, + ); + + return h( + `a#${cardUuid}-card`, + { + class: "card-github card-github-file fetch-waiting no-styling", + href: fileUrl, + target: "_blank", + repo, + file: filePath, + ref, + }, + [nTitle, nDescription, nInfoBar, nScript], + ); +} diff --git a/src/styles/markdown-extend.styl b/src/styles/markdown-extend.styl index 687be288e3..f12a7ac28a 100644 --- a/src/styles/markdown-extend.styl +++ b/src/styles/markdown-extend.styl @@ -163,7 +163,7 @@ a.card-github .gc-language display: none - .gc-stars, .gc-forks, .gc-license, .github-logo + .gc-stars, .gc-forks, .gc-license, .gc-filetype, .gc-filesize, .gc-updated, .github-logo font-weight: 500 font-size: 0.875rem opacity: 0.9; @@ -198,6 +198,18 @@ a.card-github &:before mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath 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'%3E%3C/path%3E%3C/svg%3E") + .gc-filetype + &:before + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M4 0h5.5L14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2Zm5 1.5V5h3.5L9 1.5Z'%3E%3C/path%3E%3C/svg%3E") + + .gc-filesize + &:before + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 448 512'%3E%3Cpath d='M448 80v48c0 44.2-100.3 80-224 80S0 172.2 0 128V80C0 35.8 100.3 0 224 0s224 35.8 224 80m-54.8 134.7c20.8-7.4 39.9-16.9 54.8-28.6V288c0 44.2-100.3 80-224 80S0 332.2 0 288V186.1c14.9 11.8 34 21.2 54.8 28.6C99.7 230.7 159.5 240 224 240s124.3-9.3 169.2-25.3M0 346.1c14.9 11.8 34 21.2 54.8 28.6C99.7 390.7 159.5 400 224 400s124.3-9.3 169.2-25.3c20.8-7.4 39.9-16.9 54.8-28.6V432c0 44.2-100.3 80-224 80S0 476.2 0 432z'/%3E%3C/svg%3E") + + .gc-updated + &:before + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M8 1.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 0 0 0-13Zm0 1.5a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm.75 1.75a.75.75 0 0 0-1.5 0V8c0 .27.15.52.39.65l2.5 1.5a.75.75 0 1 0 .77-1.3L8.75 7.55V4.75Z'%3E%3C/path%3E%3C/svg%3E") + .github-logo font-size: 1.25rem