Skip to content

Commit 7f6e6e7

Browse files
authored
Merge pull request #10 from ut-code/terminal
ドキュメント内でPythonコードを実行できるようにする
2 parents ea6a9e5 + f111788 commit 7f6e6e7

28 files changed

+2702
-604
lines changed

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,56 @@ npm run lint
2929
```
3030
でコードをチェックします。出てくるwarningやerrorはできるだけ直しましょう。
3131

32+
## markdown仕様
33+
34+
````
35+
```言語名-repl
36+
>>> コマンド
37+
実行結果例
38+
```
39+
````
40+
41+
でターミナルを埋め込む。
42+
* ターミナル表示部は app/terminal/terminal.tsx
43+
* コマンド入力処理は app/terminal/repl.tsx
44+
* 各言語の実行環境は app/terminal/言語名/ 内に書く。
45+
* 実行結果はSectionContextにも送られ、section.tsxからアクセスできる
46+
47+
````
48+
```言語名:ファイル名
49+
ファイルの内容
50+
```
51+
````
52+
53+
でテキストエディターを埋め込む。
54+
* app/terminal/editor.tsx
55+
* editor.tsx内で `import "ace-builds/src-min-noconflict/mode-言語名";` を追加すればその言語に対応した色付けがされる。
56+
* importできる言語の一覧は https://github.com/ajaxorg/ace-builds/tree/master/src-noconflict
57+
* 編集した内容は app/terminal/file.tsx のFileContextで管理される。
58+
* 編集中のコードはFileContextに即時送られる
59+
* FileContextが書き換えられたら即時すべてのエディターに反映される
60+
* 編集したファイルの一覧はSectionContextにも送られ、section.tsxからアクセスできる
61+
62+
````
63+
```言語名-readonly:ファイル名
64+
ファイルの内容
65+
```
66+
````
67+
68+
で同様にテキストエディターを埋め込むが、編集不可になる
69+
70+
````
71+
```言語名-exec:ファイル名
72+
実行結果例
73+
```
74+
````
75+
76+
で実行ボタンを表示する
77+
* 実行ボタンを押した際にFileContextからファイルを読み、実行し、結果を表示する
78+
* app/terminal/exec.tsx に各言語ごとの実装を書く (それぞれ app/terminal/言語名/ 内に定義した関数を呼び出す)
79+
* 実行結果はSectionContextにも送られ、section.tsxからアクセスできる
80+
81+
3282
## 技術スタック・ドキュメント・メモ
3383

3484
- [Next.js](https://nextjs.org/docs)

app/[docs_id]/markdown.tsx

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import Markdown, { Components } from "react-markdown";
22
import remarkGfm from "remark-gfm";
33
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4+
import { PythonEmbeddedTerminal } from "../terminal/python/embedded";
45
import { Heading } from "./section";
6+
import { EditorComponent } from "../terminal/editor";
7+
import { ExecFile } from "../terminal/exec";
58

69
export function StyledMarkdown({ content }: { content: string }) {
710
return (
@@ -31,48 +34,94 @@ const components: Components = {
3134
strong: ({ node, ...props }) => (
3235
<strong className="text-primary" {...props} />
3336
),
37+
hr: ({ node, ...props }) => <hr className="border-primary my-4" {...props} />,
38+
pre: ({ node, ...props }) => props.children,
3439
code: ({ node, className, ref, style, ...props }) => {
35-
const match = /language-(\w+)/.exec(className || "");
40+
const match = /^language-(\w+)(-repl|-exec|-readonly)?\:?(.+)?$/.exec(
41+
className || ""
42+
);
3643
if (match) {
37-
// block
44+
if (match[2] === "-exec" && match[3]) {
45+
/*
46+
```python-exec:main.py
47+
hello, world!
48+
```
49+
50+
---------------------------
51+
[▶ 実行] `python main.py`
52+
hello, world!
53+
---------------------------
54+
*/
55+
return (
56+
<div className="border border-primary border-2 shadow-md m-2 rounded-lg">
57+
<ExecFile
58+
language={match[1]}
59+
filename={match[3]}
60+
content={String(props.children || "").replace(/\n$/, "")}
61+
/>
62+
</div>
63+
);
64+
} else if (match[3]) {
65+
// ファイル名指定がある場合、ファイルエディター
66+
return (
67+
<div className="border border-primary border-2 shadow-md m-2 rounded-lg">
68+
<EditorComponent
69+
language={match[1]}
70+
tabSize={4}
71+
filename={match[3]}
72+
readonly={match[2] === "-readonly"}
73+
initContent={String(props.children || "").replace(/\n$/, "")}
74+
/>
75+
</div>
76+
);
77+
} else if (match[2] === "-repl") {
78+
// repl付きの言語指定
79+
// 現状はPythonのみ対応
80+
switch (match[1]) {
81+
case "python":
82+
return (
83+
<div className="bg-base-300 border border-primary border-2 shadow-md m-2 p-4 pr-1 rounded-lg">
84+
<PythonEmbeddedTerminal
85+
content={String(props.children || "").replace(/\n$/, "")}
86+
/>
87+
</div>
88+
);
89+
default:
90+
console.warn(`Unsupported language for repl: ${match[1]}`);
91+
break;
92+
}
93+
}
3894
return (
3995
<SyntaxHighlighter
4096
language={match[1]}
4197
PreTag="div"
42-
className="px-4! py-4! m-0!"
98+
className="border border-base-300 mx-2 my-2 rounded-lg text-sm! m-2! p-4!"
4399
// style={todo dark theme?}
44100
{...props}
45101
>
46-
{String(props.children).replace(/\n$/, "")}
102+
{String(props.children || "").replace(/\n$/, "")}
47103
</SyntaxHighlighter>
48104
);
49105
} else if (String(props.children).includes("\n")) {
50106
// 言語指定なしコードブロック
51107
return (
52108
<SyntaxHighlighter
53109
PreTag="div"
54-
className="px-4! py-4! m-0!"
110+
className="border border-base-300 mx-2 my-2 rounded-lg text-sm! m-2! p-4!"
55111
// style={todo dark theme?}
56112
{...props}
57113
>
58-
{String(props.children).replace(/\n$/, "")}
114+
{String(props.children || "").replace(/\n$/, "")}
59115
</SyntaxHighlighter>
60116
);
61117
} else {
62118
// inline
63119
return (
64120
<code
65-
className="bg-base-200 border border-base-300 p-1 rounded font-mono "
121+
className="bg-base-200 border border-base-300 px-1 py-0.5 rounded text-sm "
66122
{...props}
67123
/>
68124
);
69125
}
70126
},
71-
pre: ({ node, ...props }) => (
72-
<pre
73-
className="bg-base-200 border border-primary mx-2 my-2 rounded-lg font-mono text-sm overflow-x-auto"
74-
{...props}
75-
/>
76-
),
77-
hr: ({ node, ...props }) => <hr className="border-primary my-4" {...props} />,
78127
};

app/[docs_id]/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
44
import { join } from "node:path";
55
import { MarkdownSection, splitMarkdown } from "./splitMarkdown";
66
import { Section } from "./section";
7+
import * as pyodideLock from "pyodide/pyodide-lock.json";
78

89
export default async function Page({
910
params,
@@ -30,6 +31,11 @@ export default async function Page({
3031
notFound();
3132
}
3233

34+
mdContent = mdContent.replaceAll(
35+
"{process.env.PYODIDE_PYTHON_VERSION}",
36+
String(pyodideLock.info.python)
37+
);
38+
3339
const splitMdContent: MarkdownSection[] = await splitMarkdown(mdContent);
3440

3541
return (

app/[docs_id]/section.tsx

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,88 @@
11
"use client";
22

3-
import { ReactNode } from "react";
3+
import {
4+
createContext,
5+
ReactNode,
6+
useCallback,
7+
useContext,
8+
useState,
9+
} from "react";
410
import { type MarkdownSection } from "./splitMarkdown";
511
import { StyledMarkdown } from "./markdown";
612
import { ChatForm } from "./chatForm";
13+
import { ReplCommand, ReplOutput } from "../terminal/repl";
14+
import { useFile } from "../terminal/file";
15+
16+
// セクション内に埋め込まれているターミナルとファイルエディターの内容をSection側から取得できるよう、
17+
// Contextに保存する
18+
interface ISectionCodeContext {
19+
addReplOutput: (command: string, output: ReplOutput[]) => void;
20+
addFile: (filename: string) => void;
21+
setExecResult: (filename: string, output: ReplOutput[]) => void;
22+
}
23+
const SectionCodeContext = createContext<ISectionCodeContext | null>(null);
24+
export const useSectionCode = () => useContext(SectionCodeContext);
725

826
// 1つのセクションのタイトルと内容を表示する。内容はMarkdownとしてレンダリングする
927
export function Section({ section }: { section: MarkdownSection }) {
28+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
29+
const [replOutputs, setReplOutputs] = useState<ReplCommand[]>([]);
30+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
31+
const [execResults, setExecResults] = useState<Record<string, ReplOutput[]>>(
32+
{}
33+
);
34+
const [filenames, setFilenames] = useState<string[]>([]);
35+
const { files } = useFile();
36+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
37+
const fileContents: { name: string; content: string }[] = filenames.map(
38+
(name) => ({ name, content: files[name] || "" })
39+
);
40+
const addReplOutput = useCallback(
41+
(command: string, output: ReplOutput[]) =>
42+
setReplOutputs((outs) => [...outs, { command, output }]),
43+
[]
44+
);
45+
const addFile = useCallback(
46+
(filename: string) =>
47+
setFilenames((filenames) =>
48+
filenames.includes(filename) ? filenames : [...filenames, filename]
49+
),
50+
[]
51+
);
52+
const setExecResult = useCallback(
53+
(filename: string, output: ReplOutput[]) =>
54+
setExecResults((results) => {
55+
results[filename] = output;
56+
return results;
57+
}),
58+
[]
59+
);
60+
61+
// replOutputs: section内にあるターミナルにユーザーが入力したコマンドとその実行結果
62+
// fileContents: section内にあるファイルエディターの内容
63+
// execResults: section内にあるファイルの実行結果
64+
// console.log(section.title, replOutputs, fileContents, execResults);
65+
1066
return (
11-
<div>
12-
<Heading level={section.level}>{section.title}</Heading>
13-
<StyledMarkdown content={section.content} />
14-
<ChatForm documentContent={section.content} />
15-
</div>
67+
<SectionCodeContext.Provider
68+
value={{ addReplOutput, addFile, setExecResult }}
69+
>
70+
<div>
71+
<Heading level={section.level}>{section.title}</Heading>
72+
<StyledMarkdown content={section.content} />
73+
<ChatForm documentContent={section.content} />
74+
</div>
75+
</SectionCodeContext.Provider>
1676
);
1777
}
1878

19-
export function Heading({ level, children }: { level: number; children: ReactNode }) {
79+
export function Heading({
80+
level,
81+
children,
82+
}: {
83+
level: number;
84+
children: ReactNode;
85+
}) {
2086
switch (level) {
2187
case 1:
2288
return <h1 className="text-2xl font-bold my-4">{children}</h1>;

app/globals.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,20 @@
11
@import "tailwindcss";
22
@plugin "daisyui";
3+
4+
/* inconsolata-latin-wght-normal */
5+
@font-face {
6+
font-family: "Inconsolata Variable";
7+
font-style: normal;
8+
font-display: swap;
9+
font-weight: 200 900;
10+
src: url(https://cdn.jsdelivr.net/fontsource/fonts/inconsolata:vf@latest/latin-wght-normal.woff2)
11+
format("woff2-variations");
12+
unicode-range:
13+
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
14+
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
15+
U+2215, U+FEFF, U+FFFD;
16+
}
17+
18+
@theme {
19+
--font-mono: "Inconsolata Variable", monospace;
20+
}

app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import "./globals.css";
33
import { Navbar } from "./navbar";
44
import { Sidebar } from "./sidebar";
55
import { ReactNode } from "react";
6+
import { PyodideProvider } from "./terminal/python/pyodide";
7+
import { FileProvider } from "./terminal/file";
68

79
export const metadata: Metadata = {
810
title: "Create Next App",
@@ -19,7 +21,9 @@ export default function RootLayout({
1921
<input id="drawer-toggle" type="checkbox" className="drawer-toggle" />
2022
<div className="drawer-content flex flex-col">
2123
<Navbar />
22-
{children}
24+
<FileProvider>
25+
<PyodideProvider>{children}</PyodideProvider>
26+
</FileProvider>
2327
</div>
2428
<div className="drawer-side shadow-md">
2529
<label

app/terminal/editor.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.embedded-editor > .ace_gutter {
2+
border-bottom-left-radius: 0.5rem;
3+
}
4+
.embedded-editor > .ace_scroller {
5+
border-bottom-right-radius: 0.5rem;
6+
border-top-right-radius: 0.5rem;
7+
}
8+
.embedded-editor > .ace_editor {
9+
border-bottom-left-radius: 0.5rem;
10+
border-bottom-right-radius: 0.5rem;
11+
}

0 commit comments

Comments
 (0)