diff --git a/app/packages/rehype-embedded-github-code/index.ts b/app/packages/rehype-embedded-github-code/index.ts new file mode 100644 index 0000000..538c47f --- /dev/null +++ b/app/packages/rehype-embedded-github-code/index.ts @@ -0,0 +1,92 @@ +import type { Element, Root } from "hast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; +import { + buildRequestURL, + extractCodeByLines, + extractFilePathFromURL, + extractLinesFromURL, + extractRefFromURL, + extractRepoNameFromURL, +} from "./util"; + +interface EmbeddedGithubCode { + url: string; + index: number; + parent: Root | Element; +} + +const rehypeEmbeddedGithubCode: Plugin<[], Root> = () => { + const embeddedGithubCodes: EmbeddedGithubCode[] = []; + + return async (tree) => { + visit(tree, "element", (node, index, parent) => { + if ( + node.tagName !== "a" || + node.properties["data-remark-target"] !== "remark-embedded-github-code" || + typeof node.properties.href !== "string" + ) { + return; + } + + if (index === undefined || parent === undefined) { + return; + } + + if (parent.type !== "root" && parent.type !== "element") { + return; + } + + embeddedGithubCodes.push({ + url: node.properties.href, + index, + parent, + }); + }); + + await Promise.all( + embeddedGithubCodes.map(async (embeddedGithubCode) => { + const repoName = extractRepoNameFromURL(embeddedGithubCode.url); + const ref = extractRefFromURL(embeddedGithubCode.url); + const path = extractFilePathFromURL(embeddedGithubCode.url); + const lines = extractLinesFromURL(embeddedGithubCode.url); + + const requestUrl = buildRequestURL(repoName, ref, path); + + const response = await fetch(requestUrl); + const content = (await response.json()) as { content: string }; + + const codeBase64 = content.content; + let code = Buffer.from(codeBase64, "base64").toString("utf-8"); + if (lines) { + code = extractCodeByLines(code, lines); + } + + const codeElement: Element = { + type: "element", + tagName: "code", + properties: { + className: "", + }, + children: [ + { + type: "text", + value: code, + }, + ], + }; + + const preElement: Element = { + type: "element", + tagName: "pre", + children: [codeElement], + properties: {}, + }; + + embeddedGithubCode.parent.children[embeddedGithubCode.index] = preElement; + }), + ); + }; +}; + +export { rehypeEmbeddedGithubCode }; diff --git a/app/packages/rehype-embedded-github-code/util.ts b/app/packages/rehype-embedded-github-code/util.ts new file mode 100644 index 0000000..2801510 --- /dev/null +++ b/app/packages/rehype-embedded-github-code/util.ts @@ -0,0 +1,62 @@ +const extractRepoNameFromURL = (url: string): string => { + const base = url.replace("https://github.com/", ""); + const urlParts = base.split("/"); + return urlParts.slice(0, 2).join("/"); +}; + +const extractFilePathFromURL = (url: string): string => { + const base = url.replace("https://github.com/", ""); + const urlParts = base.split("/"); + const result = urlParts.slice(4).join("/"); + const hashIndex = result.indexOf("#"); + if (hashIndex === -1) { + return result; + } + return result.slice(0, hashIndex); +}; + +const extractRefFromURL = (url: string): string => { + const base = url.replace("https://github.com/", ""); + const urlParts = base.split("/"); + return urlParts[3]; +}; + +type LineNumbers = { + startLine: number; + endLine: number; +}; + +const extractLinesFromURL = (url: string): LineNumbers | undefined => { + const base = url.replace("https://github.com/", ""); + const urlParts = base.split("/"); + const result = urlParts.slice(4).join("/"); + const hashIndex = result.indexOf("#"); + if (hashIndex === -1) { + return; + } + const hash = result.slice(hashIndex + 1); + const numbers = hash.match(/\d+/g); + if (!numbers) return; + return { + startLine: Number.parseInt(numbers[0]), + endLine: Number.parseInt(numbers[1]), + }; +}; + +const buildRequestURL = (repo: string, ref: string, path: string): string => { + return `https://api.github.com/repos/${repo}/contents/${path}?ref=${ref}`; +}; + +const extractCodeByLines = (code: string, lines: LineNumbers): string => { + const codeLines = code.split("\n"); + return codeLines.slice(lines.startLine - 1, lines.endLine).join("\n"); +}; + +export { + extractRepoNameFromURL, + extractFilePathFromURL, + extractRefFromURL, + extractLinesFromURL, + buildRequestURL, + extractCodeByLines, +}; diff --git a/app/packages/remark-embedded-github-code/index.ts b/app/packages/remark-embedded-github-code/index.ts new file mode 100644 index 0000000..4fa6bb7 --- /dev/null +++ b/app/packages/remark-embedded-github-code/index.ts @@ -0,0 +1,30 @@ +import type { Root } from "mdast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; + +const remarkEmbeddedGithubCode: Plugin<[], Root> = () => { + //TODO: check this regex + const regex = /https:\/\/github\.com\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+\/blob\/[a-zA-Z0-9._-]+\/?[^\s]*/g; + return (tree) => { + visit(tree, "link", (node, _, parent) => { + if (node.url && typeof node.url === "string") { + const matches = node.url.match(regex); + if (matches) { + node.data = { + hProperties: { + "data-remark-target": "remark-embedded-github-code", + }, + }; + + if (parent && parent.type === "paragraph") { + parent.data = { + hName: "div", + }; + } + } + } + }); + }; +}; + +export { remarkEmbeddedGithubCode }; diff --git a/vite.config.ts b/vite.config.ts index 2805882..8d42c3d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,8 @@ import rehypeMomiji from './app/packages/rehype-momiji' import remarkMomijiCodeFilename from './app/packages/remark-momiji-filename' import rehypeMermaid from './app/packages/rehype-mermaid/rehypeMermaid' import remarkEmojiName from './app/packages/remark-emoji-name' +import { remarkEmbeddedGithubCode } from './app/packages/remark-embedded-github-code' +import { rehypeEmbeddedGithubCode } from './app/packages/rehype-embedded-github-code' export default defineConfig(() => { return { @@ -24,8 +26,8 @@ export default defineConfig(() => { ssg({ entry: "./app/server.ts" }), mdx({ jsxImportSource: 'hono/jsx', - remarkPlugins: [remarkGfm, remarkBreaks, remarkFrontmatter, remarkMdxFrontmatter, remarkMomijiCodeFilename, remarkEmojiName], - rehypePlugins: [[rehypeMomiji, { excludeLangs: ['mermaid'] }], rehypeMermaid], + remarkPlugins: [remarkGfm, remarkBreaks, remarkFrontmatter, remarkMdxFrontmatter, remarkMomijiCodeFilename, remarkEmojiName, remarkEmbeddedGithubCode], + rehypePlugins: [[rehypeMomiji, { excludeLangs: ['mermaid'] }], rehypeMermaid, rehypeEmbeddedGithubCode], }) ], }