From 0e4c270ff8161ff1a51a5488ea6ca24c0589115a Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 9 Aug 2025 16:55:08 +0900 Subject: [PATCH 01/26] =?UTF-8?q?Python=E3=81=AEREPL=E3=81=8C=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=A7=E3=81=8D=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/python/page.tsx | 18 ++++++ app/terminal/python/pyodide.ts | 98 ++++++++++++++++++++++++++++++ app/terminal/terminal.tsx | 105 +++++++++++++++++++++++++++++++++ next.config.ts | 4 ++ package-lock.json | 30 ++++++++++ package.json | 3 + 6 files changed, 258 insertions(+) create mode 100644 app/terminal/python/page.tsx create mode 100644 app/terminal/python/pyodide.ts create mode 100644 app/terminal/terminal.tsx diff --git a/app/terminal/python/page.tsx b/app/terminal/python/page.tsx new file mode 100644 index 0000000..fe0f196 --- /dev/null +++ b/app/terminal/python/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { TerminalComponent } from "../terminal"; +import { usePyodide } from "./pyodide"; + +export default function PythonPage() { + const { isPyodideReady, runPython } = usePyodide(); + return ( +
+ +
+ ); +} diff --git a/app/terminal/python/pyodide.ts b/app/terminal/python/pyodide.ts new file mode 100644 index 0000000..3c2a8b4 --- /dev/null +++ b/app/terminal/python/pyodide.ts @@ -0,0 +1,98 @@ +// Nextjsではドキュメント通りにpyodideをimportすると動かない? typeのインポートだけはできる +import { type PyodideAPI } from "pyodide"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { TerminalOutput } from "../terminal"; + +declare global { + interface Window { + loadPyodide: (options: { indexURL: string }) => Promise; + } +} + +export function usePyodide() { + const pyodideRef = useRef(null); + const [isPyodideReady, setIsPyodideReady] = useState(false); + const pyodideOutput = useRef([]); + + useEffect(() => { + // next.config.ts 内でpyodideをimportし、バージョンを取得している + const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${process.env.PYODIDE_VERSION}/full/`; + + const initPyodide = () => { + window + .loadPyodide({ + indexURL: PYODIDE_CDN, + }) + .then((pyodide) => { + pyodideRef.current = pyodide; + + // 標準出力とエラーをハンドリングする設定 + pyodide.setStdout({ + batched: (str) => { + pyodideOutput.current.push({ type: "stdout", message: str }); + }, + }); + pyodide.setStderr({ + batched: (str) => { + pyodideOutput.current.push({ type: "stderr", message: str }); + }, + }); + + setIsPyodideReady(true); + }); + }; + + // スクリプトタグを動的に追加 + if ("loadPyodide" in window) { + initPyodide(); + } else { + const script = document.createElement("script"); + script.src = `${PYODIDE_CDN}pyodide.js`; + script.async = true; + script.onload = initPyodide; + script.onerror = () => { + // TODO + }; + document.body.appendChild(script); + + // コンポーネントのクリーンアップ時にスクリプトタグを削除 + return () => { + document.body.removeChild(script); + }; + } + }, []); + + const runPython = useCallback<(code: string) => Promise>( + async (code: string) => { + const pyodide = pyodideRef.current; + if (!pyodide) { + return [{ type: "error", message: "Pyodide is not ready yet." }]; + } + try { + const result = await pyodide.runPythonAsync(code); + if (result !== undefined) { + pyodideOutput.current.push({ + type: "return", + message: String(result), + }); + } else { + // 標準出力/エラーがない場合 + } + } catch (e) { + console.log(e); + if (e instanceof Error) { + pyodideOutput.current.push({ type: "error", message: e.message }); + } else { + pyodideOutput.current.push({ type: "error", message: String(e) }); + } + } + const output = [...pyodideOutput.current]; + pyodideOutput.current = []; // 出力をクリア + return output; + }, + [] + ); + + // 外部に公開する値と関数 + return { isPyodideReady, runPython }; +} diff --git a/app/terminal/terminal.tsx b/app/terminal/terminal.tsx new file mode 100644 index 0000000..8180e29 --- /dev/null +++ b/app/terminal/terminal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; + +export interface TerminalOutput { + type: "stdout" | "stderr" | "error" | "return"; // 出力の種類 + message: string; // 出力メッセージ +} +interface TerminalComponentProps { + ready: boolean; + initMessage: string; // ターミナル初期化時のメッセージ + prompt: string; // プロンプト文字列 + sendCommand: (command: string) => Promise; // コマンド実行時のコールバック関数 +} +export function TerminalComponent(props: TerminalComponentProps) { + const terminalRef = useRef(null!); + const terminalInstanceRef = useRef(null); + const [termReady, setTermReady] = useState(false); + const inputBuffer = useRef(""); + + const [initMessage] = useState(props.initMessage); + const [prompt] = useState(props.prompt); + const sendCommand = useRef<(command: string) => Promise>( + props.sendCommand + ); + + useEffect(() => { + if (terminalInstanceRef.current && termReady && props.ready) { + // 初期メッセージとプロンプトを表示 + terminalInstanceRef.current.writeln(initMessage); + terminalInstanceRef.current.write(prompt); + } + }, [initMessage, prompt, props.ready, termReady]); + + // ターミナルの初期化処理 + useEffect(() => { + const term = new Terminal({ + cursorBlink: true, + convertEol: true, + }); + terminalInstanceRef.current = term; + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(terminalRef.current); + fitAddon.fit(); + + setTermReady(true); + // TODO: loadingメッセージ + // TODO: ターミナルのサイズ変更に対応する + + const onOutput = (outputs: TerminalOutput[]) => { + for (const output of outputs) { + // 出力内容に応じて色を変える + const message = String(output.message).replace(/\n/g, "\r\n"); + switch (output.type) { + case "stderr": + case "error": + term.writeln(`\x1b[1;31m${message}\x1b[0m`); + break; + default: + term.writeln(message); + break; + } + } + // 出力が終わったらプロンプトを表示 + term.write(prompt); + }; + + // キー入力のハンドリング + const onDataHandler = term.onData((key) => { + const code = key.charCodeAt(0); + + if (code === 13) { + // Enter + term.writeln(""); + if (inputBuffer.current.trim().length > 0) { + sendCommand.current(inputBuffer.current).then(onOutput); + inputBuffer.current = ""; + } + // 新しいプロンプトは外部からのoutputを待ってから表示する + } else if (code === 127) { + // Backspace + if (inputBuffer.current.length > 0) { + term.write("\b \b"); + inputBuffer.current = inputBuffer.current.slice(0, -1); + } + } else if (code >= 32) { + inputBuffer.current += key; + term.write(key); + } + }); + + // アンマウント時のクリーンアップ + return () => { + onDataHandler.dispose(); + term.dispose(); + }; + }, [initMessage, prompt]); + + return
; +} diff --git a/next.config.ts b/next.config.ts index a73bb20..8757724 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,6 @@ import type { NextConfig } from "next"; import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; +import { version as pyodideVersion } from "pyodide"; initOpenNextCloudflareForDev(); @@ -11,6 +12,9 @@ const nextConfig: NextConfig = { typescript: { ignoreBuildErrors: true, }, + env: { + PYODIDE_VERSION: pyodideVersion, + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ac9abef..145c1ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,10 @@ "dependencies": { "@google/generative-ai": "^0.24.1", "@opennextjs/cloudflare": "^1.6.3", + "@xterm/addon-fit": "^0.11.0-beta.115", + "@xterm/xterm": "^5.6.0-beta.115", "next": "<15.4", + "pyodide": "^0.28.1", "react": "19.1.0", "react-dom": "19.1.0", "react-markdown": "^10.1.0", @@ -11883,6 +11886,21 @@ "win32" ] }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0-beta.115", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0-beta.115.tgz", + "integrity": "sha512-L9o6SHQdY6gOapiwFhg5HbeVQHskq3dgDX3OECX+SUKCHjKZDFgN/pgfngdMyK0LxKDzJu3vl5FnyrEtpyuyTg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.6.0-beta.115" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.6.0-beta.115", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.115.tgz", + "integrity": "sha512-EJXAW6dbxPuwQnLfTmPB5R3M5uu8qp24ltHdjCcfwGpudKxQRoDEbq1IeGrVLIuRc/8TbnT1U07dXUX7kyGYEQ==", + "license": "MIT" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -18003,6 +18021,18 @@ "node": ">=6" } }, + "node_modules/pyodide": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.1.tgz", + "integrity": "sha512-7O1jZdfUc4/9PAKzEIyLOh3yhxknTWA8xQaCfZ4R56pOnchS909x2sqt2Wh+qHf+b7MzyB8igE5ZzYdP1pZN5w==", + "license": "MPL-2.0", + "dependencies": { + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/package.json b/package.json index 522e949..c839ab3 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "dependencies": { "@google/generative-ai": "^0.24.1", "@opennextjs/cloudflare": "^1.6.3", + "@xterm/addon-fit": "^0.11.0-beta.115", + "@xterm/xterm": "^5.6.0-beta.115", "next": "<15.4", + "pyodide": "^0.28.1", "react": "19.1.0", "react-dom": "19.1.0", "react-markdown": "^10.1.0", From aa4b9b29178bba5948d5c04564bf92962e0c6202 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 9 Aug 2025 18:02:50 +0900 Subject: [PATCH 02/26] =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E5=85=A5?= =?UTF-8?q?=E5=8A=9B=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/python/page.tsx | 4 +- app/terminal/python/pyodide.ts | 53 +++++++++++++++++++++++-- app/terminal/terminal.tsx | 70 +++++++++++++++++++++++++--------- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/app/terminal/python/page.tsx b/app/terminal/python/page.tsx index fe0f196..049c171 100644 --- a/app/terminal/python/page.tsx +++ b/app/terminal/python/page.tsx @@ -4,14 +4,16 @@ import { TerminalComponent } from "../terminal"; import { usePyodide } from "./pyodide"; export default function PythonPage() { - const { isPyodideReady, runPython } = usePyodide(); + const { isPyodideReady, runPython, checkSyntax } = usePyodide(); return (
); diff --git a/app/terminal/python/pyodide.ts b/app/terminal/python/pyodide.ts index 3c2a8b4..49cbb50 100644 --- a/app/terminal/python/pyodide.ts +++ b/app/terminal/python/pyodide.ts @@ -1,7 +1,7 @@ // Nextjsではドキュメント通りにpyodideをimportすると動かない? typeのインポートだけはできる import { type PyodideAPI } from "pyodide"; import { useState, useEffect, useRef, useCallback } from "react"; -import { TerminalOutput } from "../terminal"; +import { SyntaxStatus, TerminalOutput } from "../terminal"; declare global { interface Window { @@ -9,6 +9,29 @@ declare global { } } +// Python側で実行する構文チェックのコード +// codeop.compile_commandは、コードが不完全な場合はNoneを返します。 +const CHECK_SYNTAX_CODE = ` +def __check_syntax(): + import codeop + import js + + code = js.__code_to_check + compiler = codeop.compile_command + try: + # compile_commandは、コードが完結していればコンパイルオブジェクトを、 + # 不完全(まだ続きがある)であればNoneを返す + if compiler(code) is not None: + return "complete" + else: + return "incomplete" + except (SyntaxError, ValueError, OverflowError): + # 明らかな構文エラーの場合 + return "invalid" + +__check_syntax() +`; + export function usePyodide() { const pyodideRef = useRef(null); const [isPyodideReady, setIsPyodideReady] = useState(false); @@ -65,7 +88,7 @@ export function usePyodide() { const runPython = useCallback<(code: string) => Promise>( async (code: string) => { const pyodide = pyodideRef.current; - if (!pyodide) { + if (!pyodide || !isPyodideReady) { return [{ type: "error", message: "Pyodide is not ready yet." }]; } try { @@ -90,9 +113,31 @@ export function usePyodide() { pyodideOutput.current = []; // 出力をクリア return output; }, - [] + [isPyodideReady] + ); + + /** + * Pythonコードの構文が完結しているかチェックする + */ + const checkSyntax = useCallback<(code: string) => Promise>( + async (code) => { + const pyodide = pyodideRef.current; + if (!pyodide || !isPyodideReady) return 'invalid'; + + // グローバルスコープにチェック対象のコードを渡す + (window as any).__code_to_check = code + try { + // Pythonのコードを実行して結果を受け取る + const status = await pyodide.runPythonAsync(CHECK_SYNTAX_CODE); + return status; + } catch (e) { + console.error("Syntax check error:", e); + return 'invalid'; + } + }, + [isPyodideReady] ); // 外部に公開する値と関数 - return { isPyodideReady, runPython }; + return { isPyodideReady, runPython, checkSyntax }; } diff --git a/app/terminal/terminal.tsx b/app/terminal/terminal.tsx index 8180e29..cee7404 100644 --- a/app/terminal/terminal.tsx +++ b/app/terminal/terminal.tsx @@ -9,31 +9,45 @@ export interface TerminalOutput { type: "stdout" | "stderr" | "error" | "return"; // 出力の種類 message: string; // 出力メッセージ } +export type SyntaxStatus = "complete" | "incomplete" | "invalid"; // 構文チェックの結果 + interface TerminalComponentProps { ready: boolean; initMessage: string; // ターミナル初期化時のメッセージ prompt: string; // プロンプト文字列 - sendCommand: (command: string) => Promise; // コマンド実行時のコールバック関数 + promptMore?: string; + // コマンド実行時のコールバック関数 + sendCommand: (command: string) => Promise; + // 構文チェックのコールバック関数 + // incompleteの場合は次の行に続くことを示す + checkSyntax?: (code: string) => Promise; } export function TerminalComponent(props: TerminalComponentProps) { const terminalRef = useRef(null!); const terminalInstanceRef = useRef(null); const [termReady, setTermReady] = useState(false); - const inputBuffer = useRef(""); + const inputBuffer = useRef([""]); - const [initMessage] = useState(props.initMessage); - const [prompt] = useState(props.prompt); + const initMessage = useRef(null!); + initMessage.current = props.initMessage; + const prompt = useRef(null!); + prompt.current = props.prompt; + const promptMore = useRef(null!); + promptMore.current = props.promptMore || props.prompt; const sendCommand = useRef<(command: string) => Promise>( - props.sendCommand + null! ); + sendCommand.current = props.sendCommand; + const checkSyntax = useRef<(code: string) => Promise>(null!); + checkSyntax.current = props.checkSyntax || (async () => "complete"); useEffect(() => { if (terminalInstanceRef.current && termReady && props.ready) { // 初期メッセージとプロンプトを表示 - terminalInstanceRef.current.writeln(initMessage); - terminalInstanceRef.current.write(prompt); + terminalInstanceRef.current.writeln(initMessage.current); + terminalInstanceRef.current.write(prompt.current); } - }, [initMessage, prompt, props.ready, termReady]); + }, [props.ready, termReady]); // ターミナルの初期化処理 useEffect(() => { @@ -67,29 +81,47 @@ export function TerminalComponent(props: TerminalComponentProps) { } } // 出力が終わったらプロンプトを表示 - term.write(prompt); + term.write(prompt.current); }; // キー入力のハンドリング - const onDataHandler = term.onData((key) => { + const onDataHandler = term.onData(async (key) => { const code = key.charCodeAt(0); + // inputBufferは必ず1行以上ある状態にする if (code === 13) { // Enter - term.writeln(""); - if (inputBuffer.current.trim().length > 0) { - sendCommand.current(inputBuffer.current).then(onOutput); - inputBuffer.current = ""; + const hasContent = + inputBuffer.current[inputBuffer.current.length - 1].trim().length > 0; + const status = await checkSyntax.current( + inputBuffer.current.join("\n") + ); + if ( + (inputBuffer.current.length === 1 && status === "incomplete") || + (inputBuffer.current.length >= 2 && hasContent) + ) { + // 次の行に続く + term.writeln(""); + term.write(promptMore.current); + inputBuffer.current.push(""); + } else { + // 実行 + term.writeln(""); + const outputs = await sendCommand.current( + inputBuffer.current.join("\n").trim() + ); + onOutput(outputs); + inputBuffer.current = [""]; } - // 新しいプロンプトは外部からのoutputを待ってから表示する } else if (code === 127) { // Backspace - if (inputBuffer.current.length > 0) { + if (inputBuffer.current[inputBuffer.current.length - 1].length > 0) { term.write("\b \b"); - inputBuffer.current = inputBuffer.current.slice(0, -1); + inputBuffer.current[inputBuffer.current.length - 1] = + inputBuffer.current[inputBuffer.current.length - 1].slice(0, -1); } } else if (code >= 32) { - inputBuffer.current += key; + inputBuffer.current[inputBuffer.current.length - 1] += key; term.write(key); } }); @@ -99,7 +131,7 @@ export function TerminalComponent(props: TerminalComponentProps) { onDataHandler.dispose(); term.dispose(); }; - }, [initMessage, prompt]); + }, [initMessage, prompt, promptMore]); return
; } From 8e369f9f8ed20b822d0682cb93039b36eed51fc1 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:56:23 +0900 Subject: [PATCH 03/26] =?UTF-8?q?=E3=82=B7=E3=83=B3=E3=82=BF=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=B9=E3=83=8F=E3=82=A4=E3=83=A9=E3=82=A4=E3=83=88?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=80=81=E3=82=B3=E3=83=94=E3=83=9A=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=AA=E3=81=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/highlight.ts | 71 ++++++++++ app/terminal/python/page.tsx | 2 + app/terminal/terminal.tsx | 243 +++++++++++++++++++++++------------ package-lock.json | 52 +++++--- package.json | 3 + 5 files changed, 270 insertions(+), 101 deletions(-) create mode 100644 app/terminal/highlight.ts diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts new file mode 100644 index 0000000..46e8fe6 --- /dev/null +++ b/app/terminal/highlight.ts @@ -0,0 +1,71 @@ +import Prism from "prismjs"; +import chalk from "chalk"; +// Python言語定義をインポート +import "prismjs/components/prism-python"; + +const nothing = (text: string): string => text; + +// PrismのトークンクラスとANSIコードをマッピング +const prismToAnsi: Record string> = { + keyword: chalk.bold.cyan, + function: chalk.bold.yellow, + string: chalk.green, + number: chalk.yellow, + boolean: chalk.yellow, + comment: chalk.dim, + operator: chalk.magenta, + punctuation: nothing, + "class-name": chalk.bold.blue, + // 必要に応じて他のトークンも追加 +}; + +/** + * Prism.jsでハイライトされたHTMLを解析し、ANSIエスケープシーケンスを含む文字列に変換する + * @param {string} code ハイライト対象のPythonコード + * @returns {string} ANSIで色付けされた文字列 + */ +export function highlightCodeToAnsi(code: string, language: string): string { + // Prismでハイライト処理を行い、HTML文字列を取得 + const highlightedHtml = Prism.highlight( + code, + Prism.languages[language], + language + ); + + // 一時的なDOM要素を作成してパース + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = highlightedHtml; + + // DOMノードを再帰的にトラバースしてANSI文字列を構築 + function traverseNodes(node: Node): string { + // テキストノードの場合、そのままテキストを追加 + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ""; + } + + // 要素ノード()の場合 + if (node.nodeType === Node.ELEMENT_NODE) { + const tokenType = (node as Element).className.replace("token ", ""); + if (!(tokenType in prismToAnsi)) { + console.warn(`Unknown token type: ${tokenType}`); + } + const withHighlight: (text: string) => string = + prismToAnsi[tokenType] ?? nothing; + + // 子ノードを再帰的に処理 + return withHighlight( + Array.from(node.childNodes).reduce( + (acc, child) => acc + traverseNodes(child), + "" + ) + ); + } + + return ""; + } + + return Array.from(tempDiv.childNodes).reduce( + (acc, child) => acc + traverseNodes(child), + "" + ); +} diff --git a/app/terminal/python/page.tsx b/app/terminal/python/page.tsx index 049c171..d423e4a 100644 --- a/app/terminal/python/page.tsx +++ b/app/terminal/python/page.tsx @@ -12,6 +12,8 @@ export default function PythonPage() { initMessage="Welcome to Pyodide Terminal!" prompt=">>> " promptMore="... " + language="python" + tabSize={4} sendCommand={runPython} checkSyntax={checkSyntax} /> diff --git a/app/terminal/terminal.tsx b/app/terminal/terminal.tsx index cee7404..beb870b 100644 --- a/app/terminal/terminal.tsx +++ b/app/terminal/terminal.tsx @@ -1,9 +1,11 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; +import { highlightCodeToAnsi } from "./highlight"; +import chalk from "chalk"; export interface TerminalOutput { type: "stdout" | "stderr" | "error" | "return"; // 出力の種類 @@ -16,6 +18,8 @@ interface TerminalComponentProps { initMessage: string; // ターミナル初期化時のメッセージ prompt: string; // プロンプト文字列 promptMore?: string; + language?: string; + tabSize: number; // コマンド実行時のコールバック関数 sendCommand: (command: string) => Promise; // 構文チェックのコールバック関数 @@ -26,28 +30,10 @@ export function TerminalComponent(props: TerminalComponentProps) { const terminalRef = useRef(null!); const terminalInstanceRef = useRef(null); const [termReady, setTermReady] = useState(false); - const inputBuffer = useRef([""]); + const inputBuffer = useRef([]); - const initMessage = useRef(null!); - initMessage.current = props.initMessage; - const prompt = useRef(null!); - prompt.current = props.prompt; - const promptMore = useRef(null!); - promptMore.current = props.promptMore || props.prompt; - const sendCommand = useRef<(command: string) => Promise>( - null! - ); - sendCommand.current = props.sendCommand; - const checkSyntax = useRef<(code: string) => Promise>(null!); - checkSyntax.current = props.checkSyntax || (async () => "complete"); - - useEffect(() => { - if (terminalInstanceRef.current && termReady && props.ready) { - // 初期メッセージとプロンプトを表示 - terminalInstanceRef.current.writeln(initMessage.current); - terminalInstanceRef.current.write(prompt.current); - } - }, [props.ready, termReady]); + const { prompt, promptMore, language, tabSize, sendCommand, checkSyntax } = + props; // ターミナルの初期化処理 useEffect(() => { @@ -56,6 +42,7 @@ export function TerminalComponent(props: TerminalComponentProps) { convertEol: true, }); terminalInstanceRef.current = term; + initDone.current = false; const fitAddon = new FitAddon(); term.loadAddon(fitAddon); @@ -66,72 +53,168 @@ export function TerminalComponent(props: TerminalComponentProps) { // TODO: loadingメッセージ // TODO: ターミナルのサイズ変更に対応する - const onOutput = (outputs: TerminalOutput[]) => { - for (const output of outputs) { - // 出力内容に応じて色を変える - const message = String(output.message).replace(/\n/g, "\r\n"); - switch (output.type) { - case "stderr": - case "error": - term.writeln(`\x1b[1;31m${message}\x1b[0m`); - break; - default: - term.writeln(message); - break; + return () => { + term.dispose(); + terminalInstanceRef.current = null; + }; + }, []); + + // bufferを更新し、画面に描画する + const updateBuffer = useCallback( + (newBuffer: () => string[]) => { + if (terminalInstanceRef.current) { + // カーソル非表示 + terminalInstanceRef.current.write("\x1b[?25l"); + // バッファの行数分カーソルを戻す + if (inputBuffer.current.length >= 2) { + terminalInstanceRef.current.write( + `\x1b[${inputBuffer.current.length - 1}A` + ); + } + terminalInstanceRef.current.write("\r"); + // バッファの内容をクリア + terminalInstanceRef.current.write("\x1b[0J"); + // 新しいバッファの内容を表示 + inputBuffer.current = newBuffer(); + for (let i = 0; i < inputBuffer.current.length; i++) { + terminalInstanceRef.current.write( + i === 0 ? prompt : promptMore || prompt + ); + if (language) { + terminalInstanceRef.current.write( + highlightCodeToAnsi(inputBuffer.current[i], language) + ); + } else { + terminalInstanceRef.current.write(inputBuffer.current[i]); + } + if (i < inputBuffer.current.length - 1) { + terminalInstanceRef.current.writeln(""); + } } + // カーソルを表示 + terminalInstanceRef.current.write("\x1b[?25h"); } - // 出力が終わったらプロンプトを表示 - term.write(prompt.current); - }; + }, + [prompt, promptMore, language] + ); - // キー入力のハンドリング - const onDataHandler = term.onData(async (key) => { - const code = key.charCodeAt(0); + const initDone = useRef(false); + useEffect(() => { + if ( + terminalInstanceRef.current && + termReady && + props.ready && + !initDone.current + ) { + // 初期メッセージとプロンプトを表示 + terminalInstanceRef.current.writeln(props.initMessage); + initDone.current = true; + updateBuffer(() => [""]); + } + }, [props.ready, termReady, props.initMessage, updateBuffer]); - // inputBufferは必ず1行以上ある状態にする - if (code === 13) { - // Enter - const hasContent = - inputBuffer.current[inputBuffer.current.length - 1].trim().length > 0; - const status = await checkSyntax.current( - inputBuffer.current.join("\n") - ); - if ( - (inputBuffer.current.length === 1 && status === "incomplete") || - (inputBuffer.current.length >= 2 && hasContent) - ) { - // 次の行に続く - term.writeln(""); - term.write(promptMore.current); - inputBuffer.current.push(""); - } else { - // 実行 - term.writeln(""); - const outputs = await sendCommand.current( - inputBuffer.current.join("\n").trim() - ); - onOutput(outputs); - inputBuffer.current = [""]; + // ランタイムからの出力を処理し、bufferをリセット + const onOutput = useCallback( + (outputs: TerminalOutput[]) => { + if (terminalInstanceRef.current) { + for (const output of outputs) { + // 出力内容に応じて色を変える + const message = String(output.message).replace(/\n/g, "\r\n"); + switch (output.type) { + case "stderr": + case "error": + terminalInstanceRef.current.writeln(chalk.red(message)); + break; + default: + terminalInstanceRef.current.writeln(message); + break; + } } - } else if (code === 127) { - // Backspace - if (inputBuffer.current[inputBuffer.current.length - 1].length > 0) { - term.write("\b \b"); - inputBuffer.current[inputBuffer.current.length - 1] = - inputBuffer.current[inputBuffer.current.length - 1].slice(0, -1); + // 出力が終わったらプロンプトを表示 + updateBuffer(() => [""]); + } + }, + [updateBuffer] + ); + + const keyHandler = useCallback( + async (key: string) => { + if (terminalInstanceRef.current) { + for (let i = 0; i < key.length; i++) { + const code = key.charCodeAt(i); + const isLastChar = i === key.length - 1; + + // inputBufferは必ず1行以上ある状態にする + if (code === 13) { + // Enter + const hasContent = + inputBuffer.current[inputBuffer.current.length - 1].trim() + .length > 0; + const status = checkSyntax + ? await checkSyntax(inputBuffer.current.join("\n")) + : "complete"; + if ( + (inputBuffer.current.length === 1 && status === "incomplete") || + (inputBuffer.current.length >= 2 && hasContent) || + !isLastChar + ) { + // 次の行に続く + updateBuffer(() => [...inputBuffer.current, ""]); + } else { + // 実行 + terminalInstanceRef.current.writeln(""); + const command = inputBuffer.current.join("\n").trim(); + inputBuffer.current = []; + const outputs = await sendCommand(command); + onOutput(outputs); + } + } else if (code === 127) { + // Backspace + if ( + inputBuffer.current[inputBuffer.current.length - 1].length > 0 + ) { + updateBuffer(() => { + const newBuffer = [...inputBuffer.current]; + newBuffer[newBuffer.length - 1] = newBuffer[ + newBuffer.length - 1 + ].slice(0, -1); + return newBuffer; + }); + } + } else if (code === 9) { + // Tab + // タブをスペースに変換 + const spaces = " ".repeat(tabSize); + updateBuffer(() => { + const newBuffer = [...inputBuffer.current]; + // 最後の行にスペースを追加 + newBuffer[newBuffer.length - 1] += spaces; + return newBuffer; + }); + } else if (code >= 32) { + updateBuffer(() => { + const newBuffer = [...inputBuffer.current]; + // 最後の行にキーを追加 + newBuffer[newBuffer.length - 1] += key[i]; + return newBuffer; + }); + } } - } else if (code >= 32) { - inputBuffer.current[inputBuffer.current.length - 1] += key; - term.write(key); } - }); + }, + [updateBuffer, sendCommand, onOutput, checkSyntax, tabSize] + ); + useEffect(() => { + if (terminalInstanceRef.current && termReady && props.ready) { + // キー入力のハンドリング + const onDataHandler = terminalInstanceRef.current.onData(keyHandler); - // アンマウント時のクリーンアップ - return () => { - onDataHandler.dispose(); - term.dispose(); - }; - }, [initMessage, prompt, promptMore]); + // アンマウント時のクリーンアップ + return () => { + onDataHandler.dispose(); + }; + } + }, [keyHandler, termReady, props.ready]); return
; } diff --git a/package-lock.json b/package-lock.json index 145c1ea..ad21e69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "@opennextjs/cloudflare": "^1.6.3", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", + "chalk": "^5.5.0", "next": "<15.4", + "prismjs": "^1.30.0", "pyodide": "^0.28.1", "react": "19.1.0", "react-dom": "19.1.0", @@ -24,6 +26,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/prismjs": "^1.26.5", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", @@ -9398,18 +9401,6 @@ "open-next": "dist/index.js" } }, - "node_modules/@opennextjs/aws/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@opennextjs/cloudflare": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/@opennextjs/cloudflare/-/cloudflare-1.6.3.tgz", @@ -11282,6 +11273,13 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", @@ -12463,17 +12461,12 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -13723,6 +13716,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", diff --git a/package.json b/package.json index c839ab3..c228a20 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@opennextjs/cloudflare": "^1.6.3", "@xterm/addon-fit": "^0.11.0-beta.115", "@xterm/xterm": "^5.6.0-beta.115", + "chalk": "^5.5.0", "next": "<15.4", + "prismjs": "^1.30.0", "pyodide": "^0.28.1", "react": "19.1.0", "react-dom": "19.1.0", @@ -30,6 +32,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/prismjs": "^1.26.5", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", From 4ed4cd4b75369be1c3ea84473cbca3e2ce2929aa Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 10 Aug 2025 01:13:53 +0900 Subject: [PATCH 04/26] =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=9E=E3=82=AA?= =?UTF-8?q?=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/terminal.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/terminal/terminal.tsx b/app/terminal/terminal.tsx index beb870b..15317a3 100644 --- a/app/terminal/terminal.tsx +++ b/app/terminal/terminal.tsx @@ -40,6 +40,18 @@ export function TerminalComponent(props: TerminalComponentProps) { const term = new Terminal({ cursorBlink: true, convertEol: true, + cursorStyle: "bar", + cursorInactiveStyle: "none", + theme: { + // DaisyUIの変数を使用してテーマを設定している + // TODO: ダークテーマ/ライトテーマを切り替えたときに再設定する? + // TODO: red, green, blueなどの色も設定する + background: window.getComputedStyle(document.body).getPropertyValue("--color-base-300"), + foreground: window.getComputedStyle(document.body).getPropertyValue("--color-base-content"), + cursor: window.getComputedStyle(document.body).getPropertyValue("--color-base-content"), + selectionBackground: window.getComputedStyle(document.body).getPropertyValue("--color-primary"), + selectionForeground: window.getComputedStyle(document.body).getPropertyValue("--color-primary-content"), + }, }); terminalInstanceRef.current = term; initDone.current = false; From 91d4ae62c4c7a7afd5a8a3399cb33ddd512d940f Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 10 Aug 2025 12:06:39 +0900 Subject: [PATCH 05/26] =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=9E=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/highlight.ts | 62 ++++++++++++++++++++++++++++++--------- app/terminal/terminal.tsx | 30 +++++++++++++++---- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/app/terminal/highlight.ts b/app/terminal/highlight.ts index 46e8fe6..fea4b8a 100644 --- a/app/terminal/highlight.ts +++ b/app/terminal/highlight.ts @@ -7,16 +7,42 @@ const nothing = (text: string): string => text; // PrismのトークンクラスとANSIコードをマッピング const prismToAnsi: Record string> = { - keyword: chalk.bold.cyan, - function: chalk.bold.yellow, - string: chalk.green, - number: chalk.yellow, - boolean: chalk.yellow, + // comment: chalk.dim, + prolog: chalk.dim, + cdata: chalk.dim, + doctype: chalk.dim, + punctuation: chalk.dim, + entity: chalk.dim, + // + keyword: chalk.magenta, operator: chalk.magenta, - punctuation: nothing, - "class-name": chalk.bold.blue, - // 必要に応じて他のトークンも追加 + // + builtin: chalk.cyan, + url: chalk.cyan, + // + "attr-name": chalk.blue, + "class-name": chalk.blue, + variable: chalk.blue, + function: chalk.blue, + // + boolean: chalk.yellow, + constant: chalk.yellow, + number: chalk.yellow, + atrule: chalk.yellow, + // + property: chalk.red, + tag: chalk.red, + symbol: chalk.red, + deleted: chalk.red, + important: chalk.red, + // + selector: chalk.green, + string: chalk.green, + char: chalk.green, + inserted: chalk.green, + regex: chalk.green, + "attr-value": chalk.green, }; /** @@ -45,15 +71,23 @@ export function highlightCodeToAnsi(code: string, language: string): string { // 要素ノード()の場合 if (node.nodeType === Node.ELEMENT_NODE) { - const tokenType = (node as Element).className.replace("token ", ""); - if (!(tokenType in prismToAnsi)) { - console.warn(`Unknown token type: ${tokenType}`); + const tokenTypes = (node as Element).className + .replace("token ", "") + .split(" "); + let highlight: ((text: string) => string) | undefined = undefined; + for (const tokenType of tokenTypes) { + // トークンタイプに対応するANSIコードを取得 + if (tokenType in prismToAnsi) { + highlight = prismToAnsi[tokenType]; + break; // 最初に見つかったものを使用 + } + } + if (!highlight) { + console.warn(`Unknown token type: ${tokenTypes}`); } - const withHighlight: (text: string) => string = - prismToAnsi[tokenType] ?? nothing; // 子ノードを再帰的に処理 - return withHighlight( + return (highlight || nothing)( Array.from(node.childNodes).reduce( (acc, child) => acc + traverseNodes(child), "" diff --git a/app/terminal/terminal.tsx b/app/terminal/terminal.tsx index 15317a3..dc24f15 100644 --- a/app/terminal/terminal.tsx +++ b/app/terminal/terminal.tsx @@ -37,6 +37,9 @@ export function TerminalComponent(props: TerminalComponentProps) { // ターミナルの初期化処理 useEffect(() => { + const fromCSS = (varName: string) => + window.getComputedStyle(document.body).getPropertyValue(varName); + // "--color-" + color_name のように文字列を分割するとTailwindCSSが認識せずCSSの値として出力されない場合があるので注意 const term = new Terminal({ cursorBlink: true, convertEol: true, @@ -45,12 +48,27 @@ export function TerminalComponent(props: TerminalComponentProps) { theme: { // DaisyUIの変数を使用してテーマを設定している // TODO: ダークテーマ/ライトテーマを切り替えたときに再設定する? - // TODO: red, green, blueなどの色も設定する - background: window.getComputedStyle(document.body).getPropertyValue("--color-base-300"), - foreground: window.getComputedStyle(document.body).getPropertyValue("--color-base-content"), - cursor: window.getComputedStyle(document.body).getPropertyValue("--color-base-content"), - selectionBackground: window.getComputedStyle(document.body).getPropertyValue("--color-primary"), - selectionForeground: window.getComputedStyle(document.body).getPropertyValue("--color-primary-content"), + background: fromCSS("--color-base-300"), + foreground: fromCSS("--color-base-content"), + cursor: fromCSS("--color-base-content"), + selectionBackground: fromCSS("--color-primary"), + selectionForeground: fromCSS("--color-primary-content"), + black: fromCSS("--color-black"), + brightBlack: fromCSS("--color-neutral-500"), + red: fromCSS("--color-red-600"), + brightRed: fromCSS("--color-red-400"), + green: fromCSS("--color-green-600"), + brightGreen: fromCSS("--color-green-400"), + yellow: fromCSS("--color-yellow-700"), + brightYellow: fromCSS("--color-yellow-400"), + blue: fromCSS("--color-indigo-600"), + brightBlue: fromCSS("--color-indigo-400"), + magenta: fromCSS("--color-fuchsia-600"), + brightMagenta: fromCSS("--color-fuchsia-400"), + cyan: fromCSS("--color-cyan-600"), + brightCyan: fromCSS("--color-cyan-400"), + white: fromCSS("--color-base-100"), + brightWhite: fromCSS("--color-white"), }, }); terminalInstanceRef.current = term; From c793a6fb330dbb4016d55dc488a50033a6cb13c8 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 10 Aug 2025 14:51:42 +0900 Subject: [PATCH 06/26] =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=83=9F=E3=83=8A?= =?UTF-8?q?=E3=83=AB=E3=82=92=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E5=86=85=E3=81=AB=E5=9F=8B=E3=82=81=E8=BE=BC=E3=82=80?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[docs_id]/markdown.tsx | 35 +-- app/globals.css | 18 ++ app/layout.tsx | 3 +- app/terminal/python/embedded.tsx | 48 ++++ .../python/{pyodide.ts => pyodide.tsx} | 86 +++++++- app/terminal/terminal.tsx | 208 ++++++++++++------ 6 files changed, 305 insertions(+), 93 deletions(-) create mode 100644 app/terminal/python/embedded.tsx rename app/terminal/python/{pyodide.ts => pyodide.tsx} (65%) diff --git a/app/[docs_id]/markdown.tsx b/app/[docs_id]/markdown.tsx index 800366a..2e5f631 100644 --- a/app/[docs_id]/markdown.tsx +++ b/app/[docs_id]/markdown.tsx @@ -1,6 +1,7 @@ import Markdown, { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { PythonEmbeddedTerminal } from "../terminal/python/embedded"; export function StyledMarkdown({ content }: { content: string }) { return ( @@ -39,24 +40,28 @@ const components: Components = { code: ({ node, className, ref, style, ...props }) => { const match = /language-(\w+)/.exec(className || ""); if (match) { - // block - return ( - - {String(props.children).replace(/\n$/, "")} - - ); + switch (match[1]) { + case "python": + return ; + default: + return ( + + {String(props.children).replace(/\n$/, "")} + + ); + } } else if (String(props.children).includes("\n")) { // 言語指定なしコードブロック return ( @@ -67,7 +72,7 @@ const components: Components = { // inline return ( ); @@ -75,7 +80,7 @@ const components: Components = { }, pre: ({ node, ...props }) => (
   ),
diff --git a/app/globals.css b/app/globals.css
index 4c1b0c2..3bb351d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,2 +1,20 @@
 @import "tailwindcss";
 @plugin "daisyui";
+
+/* fira-code-latin-wght-normal */
+@font-face {
+  font-family: "Fira Code Variable";
+  font-style: normal;
+  font-display: swap;
+  font-weight: 300 700;
+  src: url(https://cdn.jsdelivr.net/fontsource/fonts/fira-code:vf@latest/latin-wght-normal.woff2)
+    format("woff2-variations");
+  unicode-range:
+    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
+    U+2215, U+FEFF, U+FFFD;
+}
+
+@theme {
+  --font-mono: "Fira Code Variable", monospace;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 5a0f953..df211e0 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,7 @@ import "./globals.css";
 import { Navbar } from "./navbar";
 import { Sidebar } from "./sidebar";
 import { ReactNode } from "react";
+import { PyodideProvider } from "./terminal/python/pyodide";
 
 export const metadata: Metadata = {
   title: "Create Next App",
@@ -19,7 +20,7 @@ export default function RootLayout({
           
           
- {children} + {children}