GitHubではコメントにpermalinkを貼り付けることで、該当のコードを埋め込めます。これを私のブログで再現してぇなと思いました。
こんな感じです。
{
"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のプラグイン化したのが下記です。が、かなり私のブログに特化している上に読みづらいし変なことやっているのであまり参考にしない方が良いです。雰囲気はつかめると思います。
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するライブラリを活用しています。
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
で付与されたリンクを削除しています。
// 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のみで完結したので良しとしています。
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マシマシの記事となりました。