Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 | import { visit } from 'unist-util-visit';
/**
* シェルプロンプト表示プラグイン。
* rehype-prism-plusの後で実行。
*
* data-prompt-user / data-prompt-host 属性を持つ <code> を検出し、
* 各 .code-line の先頭に `user@host $ ` プロンプトspanを挿入する。
* プロンプト部分は user-select: none でコピー対象外。
*
* $ プレフィックス方式:
* - 行が「$ 」で始まる場合: コマンド行としてプロンプト挿入、「$ 」は除去
* - 行が「$ 」で始まらない場合: 出力行として shell-output クラスを付与
* - どの行にも「$ 」がない場合: 後方互換として全行にプロンプト挿入
*/
export default function rehypeShellPrompt() {
return (tree) => {
visit(tree, 'element', (node, index, parent) => {
if (node.tagName !== 'code') return;
const promptUser = node.properties?.['data-prompt-user'] || node.properties?.dataPromptUser;
const promptHost = node.properties?.['data-prompt-host'] || node.properties?.dataPromptHost;
if (!promptUser) return;
// <pre> 親要素に shell-dark クラスを追加
if (parent && parent.tagName === 'pre') {
if (!parent.properties) parent.properties = {};
if (!parent.properties.className) parent.properties.className = [];
if (Array.isArray(parent.properties.className) && !parent.properties.className.includes('shell-dark')) {
parent.properties.className.push('shell-dark');
}
}
const promptText = promptHost
? `${promptUser}@${promptHost} $ `
: `${promptUser} $ `;
const promptSpan = {
type: 'element',
tagName: 'span',
properties: { className: ['shell-prompt'] },
children: [{ type: 'text', value: promptText }],
};
// code直下の .code-line を探す
const codeLines = node.children?.filter(
(c) => c.type === 'element' && c.tagName === 'span' && c.properties?.className?.includes('code-line')
) || [];
if (codeLines.length > 0) {
// $ プレフィックスの有無を判定
const hasDollarPrefix = codeLines.some((line) => {
const text = getTextContent(line);
return text.startsWith('$ ');
});
for (const child of codeLines) {
const text = getTextContent(child);
if (hasDollarPrefix) {
// 新方式: $ プレフィックスでコマンド/出力を区別
if (text.startsWith('$ ')) {
// コマンド行: 「$ 」を除去してプロンプトを挿入
stripDollarPrefix(child);
child.children.unshift({ ...promptSpan });
} else if (text.trim().length > 0) {
// 出力行: shell-output クラスを追加
if (!child.properties.className) child.properties.className = [];
child.properties.className.push('shell-output');
}
} else {
// 後方互換: 全行にプロンプトを挿入
if (text.trim().length > 0) {
child.children.unshift({ ...promptSpan });
}
}
}
return;
}
// code-lineが見つからない場合(rehype-prism-plusが行分割しなかった場合)
// テキストノードを直接処理(後方互換動作)
if (node.children) {
const newChildren = [];
for (const child of node.children) {
if (child.type === 'text') {
const lines = child.value.split('\n');
lines.forEach((line, i) => {
if (i > 0) newChildren.push({ type: 'text', value: '\n' });
if (line.length > 0) {
newChildren.push({ ...promptSpan });
newChildren.push({ type: 'text', value: line });
}
});
} else {
newChildren.push(child);
}
}
node.children = newChildren;
}
});
};
}
/**
* code-line spanのテキスト内容を再帰的に取得
*/
function getTextContent(node) {
if (node.type === 'text') return node.value || '';
if (node.children) return node.children.map(getTextContent).join('');
return '';
}
/**
* code-line span先頭の「$ 」テキストを除去する。
* Prismトークン化後のDOMを考慮し、最初のテキストノードから「$ 」を除去する。
*/
function stripDollarPrefix(codeLine) {
if (!codeLine.children) return;
for (let i = 0; i < codeLine.children.length; i++) {
const child = codeLine.children[i];
if (child.type === 'text' && child.value) {
if (child.value.startsWith('$ ')) {
child.value = child.value.substring(2);
if (child.value === '') {
codeLine.children.splice(i, 1);
}
return;
}
// テキストが空でない場合はここで終了($ が先頭にない)
if (child.value.trim().length > 0) return;
}
// Prismのspan要素内のテキストもチェック
if (child.type === 'element' && child.children) {
const innerText = getTextContent(child);
if (innerText.startsWith('$ ')) {
stripDollarPrefixRecursive(child);
return;
}
if (innerText.trim().length > 0) return;
}
}
}
/**
* ネストされた要素から再帰的に「$ 」を除去
*/
function stripDollarPrefixRecursive(node) {
if (!node.children) return;
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (child.type === 'text' && child.value && child.value.startsWith('$ ')) {
child.value = child.value.substring(2);
if (child.value === '') {
node.children.splice(i, 1);
}
return;
}
if (child.type === 'element') {
stripDollarPrefixRecursive(child);
return;
}
}
}
|