プログラミング

Markdown ItのプラグインでGitHubのpermalinkの埋め込みを可能にする

GitHubではコメントにpermalinkを貼り付けることで、該当のコードを埋め込めます。これを私のブログで再現してぇなと思いました。

GitHub上のコメントでpermalinkを貼り付けたときの挙動。貼り付けたpermalink先のコードが表示されている。

こんな感じです。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
{ "extends": "../../tsconfig.json", "compilerOptions": { "paths": { "@blog/*": [ "packages/blog/src/*" ] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] }

permalinkから情報取得

既に同様の思いを持った方がおりました。GitHubのPermalinkから埋め込みを作るにて解説されています。

重要なのは、Permalinkからデータを取得する処理です。実はGitHubのpermalinkはAcceptヘッダーにapplication/jsonを設定するとちゃんとJSON形式で情報を返してくれます。一部抜粋ですが、こんな感じでいろんな情報を取得できます。

$ curl -H "Accept: application/json" https://github.com/sa2taka/next-blog/blob/a81fdd89dc479edc2d24bbb10e9de8d3175bdf88/packages/blog/tsconfig.json { "payload": { "allShortcutsEnabled": false, "fileTree": { ... }, "repo": { "id": 504221802, "defaultBranch": "main", "name": "next-blog", "ownerLogin": "sa2taka", ... }, "refInfo": { "name": "a81fdd89dc479edc2d24bbb10e9de8d3175bdf88", ... }, "path": "packages/blog/tsconfig.json", "currentUser": null, "blob": { "rawLines": [ "{", " \"extends\": \"../../tsconfig.json\",", " \"compilerOptions\": {", ... ], ... }, ... }, "title": "next-blog/packages/blog/tsconfig.json at a81fdd89dc479edc2d24bbb10e9de8d3175bdf88 · sa2taka/next-blog" }

特にpayload.blob.rawLinesが実データとなります。この辺の情報を取得すればいい感じに表示できます。

Markdown Itのプラグイン化

今回MarkdownItのプラグイン化したのが下記です。が、かなり私のブログに特化している上に読みづらいし変なことやっているのであまり参考にしない方が良いです。雰囲気はつかめると思います。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180
import fetchSync from 'sync-fetch'; import MarkdownIt from 'markdown-it'; import { escapeHtml } from '@blog/libs/escapeHtml'; import prism from '@blog/libs/prism'; const fetchPermalink = ( permalink: string ): { lines: string[]; language: string; path: string; owner: string; repo: string; } => { const response = fetchSync(permalink, { headers: { Accept: 'application/json', }, }); const data = response.json(); const { payload } = data; return { lines: payload.blob.rawLines, language: payload.blob.language, path: payload.path, owner: payload.repo.ownerLogin, repo: payload.repo.name, }; }; const githubPermalinkRegexp = /^https:\/\/github.com\/[^/]+\/[^/]+\/blob\/.+/i; const isGithubPermaLink = (url: string): boolean => { return githubPermalinkRegexp.test(url); }; const getLineRange = (url: string): { start: number; end: number } | null => { const m = url.match(/#L(\d+)(?:C\d+)?-L(\d+)(?:C\d+)?$/); if (!m) { return null; } return { start: Number(m[1]), end: Number(m[2]), }; }; const generateGitHubCodeBlock = ({ url, lang, lines, hasLineRange, start, end, owner, repo, path, }: { url: string; lang: string; lines: string[]; hasLineRange: boolean; start: number; end: number; owner: string; repo: string; path: string; }) => { lang = lang.toLowerCase(); let value: string; const code = lines.slice(start - 1, end).join('\n'); if (prism.languages[lang]) { value = prism.highlight(code, prism.languages[lang], lang); } else { value = code; lang = ''; } const filePath = `${owner}/${repo}/${path}`; const lineNumbers = Array.from({ length: end - start + 1 }, (_, i) => { return `<span>${start + i}</span>`; }).join('\n'); return `<div class="github-code-block"> <div class="header"> <div class="github-logo-area"><img src="/github-mark-white.svg" alt="GitHubのMark" class="github-logo" /></div> <p class="link-area"><a href="${escapeHtml(url)}">${escapeHtml(filePath)}</a></p> <p class="details-area">${hasLineRange ? `Lines ${start} to ${end}` : ''}</p> </div> <div class="code-area"> <div class="line-numbers" onScroll='this.nextElementSibling.scrollTop=this.scrollTop'>${lineNumbers}</div> <code class="${lang !== '' ? `language-${lang}` : ''}" } onScroll='this.previousElementSibling.scrollTop=this.scrollTop'>${value}</code> </div> </div>`; }; const hasExpanded = (tokens: any[], idx: number): boolean => { const content = tokens[idx]?.content; return ( isGithubPermaLink(content) && // NOTE: 同一行にテキストが複数ある場合はデフォルトの展開しない tokens.filter((token) => token.type === 'text').length === 1 ); }; export const githubPermaLinkEmbedPlugin = (md: MarkdownIt) => { const defaultTextRender = md.renderer.rules.text || function (tokens, idx) { return escapeHtml(tokens[idx].content); }; md.renderer.rules.text = (...[tokens, idx, options, env, self]) => { const url = tokens[idx].content; if (!hasExpanded(tokens, idx)) { return defaultTextRender(tokens, idx, options, env, self); } const { lines, language, owner, repo, path } = fetchPermalink(url); const lineRange = getLineRange(url); const { start, end } = lineRange ?? { start: 1, end: lines.length, }; return generateGitHubCodeBlock({ url, lang: language, lines, hasLineRange: Boolean(lineRange), start, end, owner, repo, path, }); }; // NOTE: linkifyで付与されるリンクを無効化する const defaultLinkOpenRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // @ts-ignore md.renderer.rules.link_open = (...[tokens, idx, options, env, self]) => { if (tokens[idx].markup !== 'linkify') { return defaultLinkCloseRender(tokens, idx, options, env, self); } if (hasExpanded(tokens, idx + 1)) { return ''; } return defaultLinkOpenRender(tokens, idx, options, env, self); }; const defaultLinkCloseRender = md.renderer.rules.link_close || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // @ts-ignore md.renderer.rules.link_close = (...[tokens, idx, options, env, self]) => { if (tokens[idx].markup !== 'linkify') { return defaultLinkCloseRender(tokens, idx, options, env, self); } if (hasExpanded(tokens, idx - 1)) { return ''; } return defaultLinkOpenRender(tokens, idx, options, env, self); }; };

データ取得部分はこんな感じです。markdownItでは多分非同期処理は使えないんで、fetchが使えないので、sync-fetchという同期的にfetchするライブラリを活用しています。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180
import fetchSync from 'sync-fetch'; import MarkdownIt from 'markdown-it'; import { escapeHtml } from '@blog/libs/escapeHtml'; import prism from '@blog/libs/prism'; const fetchPermalink = ( permalink: string ): { lines: string[]; language: string; path: string; owner: string; repo: string; } => { const response = fetchSync(permalink, { headers: { Accept: 'application/json', }, }); const data = response.json(); const { payload } = data; return { lines: payload.blob.rawLines, language: payload.blob.language, path: payload.path, owner: payload.repo.ownerLogin, repo: payload.repo.name, }; }; const githubPermalinkRegexp = /^https:\/\/github.com\/[^/]+\/[^/]+\/blob\/.+/i; const isGithubPermaLink = (url: string): boolean => { return githubPermalinkRegexp.test(url); }; const getLineRange = (url: string): { start: number; end: number } | null => { const m = url.match(/#L(\d+)(?:C\d+)?-L(\d+)(?:C\d+)?$/); if (!m) { return null; } return { start: Number(m[1]), end: Number(m[2]), }; }; const generateGitHubCodeBlock = ({ url, lang, lines, hasLineRange, start, end, owner, repo, path, }: { url: string; lang: string; lines: string[]; hasLineRange: boolean; start: number; end: number; owner: string; repo: string; path: string; }) => { lang = lang.toLowerCase(); let value: string; const code = lines.slice(start - 1, end).join('\n'); if (prism.languages[lang]) { value = prism.highlight(code, prism.languages[lang], lang); } else { value = code; lang = ''; } const filePath = `${owner}/${repo}/${path}`; const lineNumbers = Array.from({ length: end - start + 1 }, (_, i) => { return `<span>${start + i}</span>`; }).join('\n'); return `<div class="github-code-block"> <div class="header"> <div class="github-logo-area"><img src="/github-mark-white.svg" alt="GitHubのMark" class="github-logo" /></div> <p class="link-area"><a href="${escapeHtml(url)}">${escapeHtml(filePath)}</a></p> <p class="details-area">${hasLineRange ? `Lines ${start} to ${end}` : ''}</p> </div> <div class="code-area"> <div class="line-numbers" onScroll='this.nextElementSibling.scrollTop=this.scrollTop'>${lineNumbers}</div> <code class="${lang !== '' ? `language-${lang}` : ''}" } onScroll='this.previousElementSibling.scrollTop=this.scrollTop'>${value}</code> </div> </div>`; }; const hasExpanded = (tokens: any[], idx: number): boolean => { const content = tokens[idx]?.content; return ( isGithubPermaLink(content) && // NOTE: 同一行にテキストが複数ある場合はデフォルトの展開しない tokens.filter((token) => token.type === 'text').length === 1 ); }; export const githubPermaLinkEmbedPlugin = (md: MarkdownIt) => { const defaultTextRender = md.renderer.rules.text || function (tokens, idx) { return escapeHtml(tokens[idx].content); }; md.renderer.rules.text = (...[tokens, idx, options, env, self]) => { const url = tokens[idx].content; if (!hasExpanded(tokens, idx)) { return defaultTextRender(tokens, idx, options, env, self); } const { lines, language, owner, repo, path } = fetchPermalink(url); const lineRange = getLineRange(url); const { start, end } = lineRange ?? { start: 1, end: lines.length, }; return generateGitHubCodeBlock({ url, lang: language, lines, hasLineRange: Boolean(lineRange), start, end, owner, repo, path, }); }; // NOTE: linkifyで付与されるリンクを無効化する const defaultLinkOpenRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // @ts-ignore md.renderer.rules.link_open = (...[tokens, idx, options, env, self]) => { if (tokens[idx].markup !== 'linkify') { return defaultLinkCloseRender(tokens, idx, options, env, self); } if (hasExpanded(tokens, idx + 1)) { return ''; } return defaultLinkOpenRender(tokens, idx, options, env, self); }; const defaultLinkCloseRender = md.renderer.rules.link_close || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // @ts-ignore md.renderer.rules.link_close = (...[tokens, idx, options, env, self]) => { if (tokens[idx].markup !== 'linkify') { return defaultLinkCloseRender(tokens, idx, options, env, self); } if (hasExpanded(tokens, idx - 1)) { return ''; } return defaultLinkOpenRender(tokens, idx, options, env, self); }; };

MarkdownItでは linkfy という仕組みによりただのURLもリンクに変換します。ただpermalinkもリンクになってしまうので、linkfyで付与されたリンクを削除しています。

142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180
// NOTE: linkifyで付与されるリンクを無効化する const defaultLinkOpenRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // @ts-ignore md.renderer.rules.link_open = (...[tokens, idx, options, env, self]) => { if (tokens[idx].markup !== 'linkify') { return defaultLinkCloseRender(tokens, idx, options, env, self); } if (hasExpanded(tokens, idx + 1)) { return ''; } return defaultLinkOpenRender(tokens, idx, options, env, self); }; const defaultLinkCloseRender = md.renderer.rules.link_close || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; // @ts-ignore md.renderer.rules.link_close = (...[tokens, idx, options, env, self]) => { if (tokens[idx].markup !== 'linkify') { return defaultLinkCloseRender(tokens, idx, options, env, self); } if (hasExpanded(tokens, idx - 1)) { return ''; } return defaultLinkOpenRender(tokens, idx, options, env, self); }; };

そして無理やりhtmlにしています。GitHubっぽく行数を表示したかったのですが、prismのLine Numbersプラグインはどうやらクライアント側で処理をしないといけないようで、SSGによる静的生成にこだわっているこのアプリでは対応できませんでした。

天邪鬼なのでできないと思ったらやれるようになるまで頑張ってしまう性格。そのため色々頑張ったら、結局onscrollイベントに頼ることになりましたが(ラインの部分とコードの部分が別のエリアになっているので、スクロールをシンクする必要があったので使っている)、htmlのみで完結したので良しとしています。

88 89 90 91 92 93 94 95 96 97 98 99
return `<div class="github-code-block"> <div class="header"> <div class="github-logo-area"><img src="/github-mark-white.svg" alt="GitHubのMark" class="github-logo" /></div> <p class="link-area"><a href="${escapeHtml(url)}">${escapeHtml(filePath)}</a></p> <p class="details-area">${hasLineRange ? `Lines ${start} to ${end}` : ''}</p> </div> <div class="code-area"> <div class="line-numbers" onScroll='this.nextElementSibling.scrollTop=this.scrollTop'>${lineNumbers}</div> <code class="${lang !== '' ? `language-${lang}` : ''}" } onScroll='this.previousElementSibling.scrollTop=this.scrollTop'>${value}</code> </div> </div>`; };

permalinkマシマシの記事となりました。