diff --git a/README.md b/README.md index 0a01927..76d6793 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,56 @@ npm run lint ``` でコードをチェックします。出てくるwarningやerrorはできるだけ直しましょう。 +## markdown仕様 + +```` +```言語名-repl +>>> コマンド +実行結果例 +``` +```` + +でターミナルを埋め込む。 +* ターミナル表示部は app/terminal/terminal.tsx +* コマンド入力処理は app/terminal/repl.tsx +* 各言語の実行環境は app/terminal/言語名/ 内に書く。 +* 実行結果はSectionContextにも送られ、section.tsxからアクセスできる + +```` +```言語名:ファイル名 +ファイルの内容 +``` +```` + +でテキストエディターを埋め込む。 +* app/terminal/editor.tsx +* editor.tsx内で `import "ace-builds/src-min-noconflict/mode-言語名";` を追加すればその言語に対応した色付けがされる。 + * importできる言語の一覧は https://github.com/ajaxorg/ace-builds/tree/master/src-noconflict +* 編集した内容は app/terminal/file.tsx のFileContextで管理される。 + * 編集中のコードはFileContextに即時送られる + * FileContextが書き換えられたら即時すべてのエディターに反映される +* 編集したファイルの一覧はSectionContextにも送られ、section.tsxからアクセスできる + +```` +```言語名-readonly:ファイル名 +ファイルの内容 +``` +```` + +で同様にテキストエディターを埋め込むが、編集不可になる + +```` +```言語名-exec:ファイル名 +実行結果例 +``` +```` + +で実行ボタンを表示する +* 実行ボタンを押した際にFileContextからファイルを読み、実行し、結果を表示する +* app/terminal/exec.tsx に各言語ごとの実装を書く (それぞれ app/terminal/言語名/ 内に定義した関数を呼び出す) +* 実行結果はSectionContextにも送られ、section.tsxからアクセスできる + + ## 技術スタック・ドキュメント・メモ - [Next.js](https://nextjs.org/docs) diff --git a/app/[docs_id]/markdown.tsx b/app/[docs_id]/markdown.tsx index 71b3611..55aec9f 100644 --- a/app/[docs_id]/markdown.tsx +++ b/app/[docs_id]/markdown.tsx @@ -1,7 +1,10 @@ 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"; import { Heading } from "./section"; +import { EditorComponent } from "../terminal/editor"; +import { ExecFile } from "../terminal/exec"; export function StyledMarkdown({ content }: { content: string }) { return ( @@ -31,19 +34,72 @@ const components: Components = { strong: ({ node, ...props }) => ( ), + hr: ({ node, ...props }) =>
, + pre: ({ node, ...props }) => props.children, code: ({ node, className, ref, style, ...props }) => { - const match = /language-(\w+)/.exec(className || ""); + const match = /^language-(\w+)(-repl|-exec|-readonly)?\:?(.+)?$/.exec( + className || "" + ); if (match) { - // block + if (match[2] === "-exec" && match[3]) { + /* + ```python-exec:main.py + hello, world! + ``` + ↓ + --------------------------- + [▶ 実行] `python main.py` + hello, world! + --------------------------- + */ + return ( +
+ +
+ ); + } else if (match[3]) { + // ファイル名指定がある場合、ファイルエディター + return ( +
+ +
+ ); + } else if (match[2] === "-repl") { + // repl付きの言語指定 + // 現状はPythonのみ対応 + switch (match[1]) { + case "python": + return ( +
+ +
+ ); + default: + console.warn(`Unsupported language for repl: ${match[1]}`); + break; + } + } return ( - {String(props.children).replace(/\n$/, "")} + {String(props.children || "").replace(/\n$/, "")} ); } else if (String(props.children).includes("\n")) { @@ -51,28 +107,21 @@ const components: Components = { return ( - {String(props.children).replace(/\n$/, "")} + {String(props.children || "").replace(/\n$/, "")} ); } else { // inline return ( ); } }, - pre: ({ node, ...props }) => ( -
-  ),
-  hr: ({ node, ...props }) => 
, }; diff --git a/app/[docs_id]/page.tsx b/app/[docs_id]/page.tsx index cf80393..cd09acd 100644 --- a/app/[docs_id]/page.tsx +++ b/app/[docs_id]/page.tsx @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { MarkdownSection, splitMarkdown } from "./splitMarkdown"; import { Section } from "./section"; +import * as pyodideLock from "pyodide/pyodide-lock.json"; export default async function Page({ params, @@ -30,6 +31,11 @@ export default async function Page({ notFound(); } + mdContent = mdContent.replaceAll( + "{process.env.PYODIDE_PYTHON_VERSION}", + String(pyodideLock.info.python) + ); + const splitMdContent: MarkdownSection[] = await splitMarkdown(mdContent); return ( diff --git a/app/[docs_id]/section.tsx b/app/[docs_id]/section.tsx index e477c90..138e93a 100644 --- a/app/[docs_id]/section.tsx +++ b/app/[docs_id]/section.tsx @@ -1,22 +1,88 @@ "use client"; -import { ReactNode } from "react"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useState, +} from "react"; import { type MarkdownSection } from "./splitMarkdown"; import { StyledMarkdown } from "./markdown"; import { ChatForm } from "./chatForm"; +import { ReplCommand, ReplOutput } from "../terminal/repl"; +import { useFile } from "../terminal/file"; + +// セクション内に埋め込まれているターミナルとファイルエディターの内容をSection側から取得できるよう、 +// Contextに保存する +interface ISectionCodeContext { + addReplOutput: (command: string, output: ReplOutput[]) => void; + addFile: (filename: string) => void; + setExecResult: (filename: string, output: ReplOutput[]) => void; +} +const SectionCodeContext = createContext(null); +export const useSectionCode = () => useContext(SectionCodeContext); // 1つのセクションのタイトルと内容を表示する。内容はMarkdownとしてレンダリングする export function Section({ section }: { section: MarkdownSection }) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [replOutputs, setReplOutputs] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [execResults, setExecResults] = useState>( + {} + ); + const [filenames, setFilenames] = useState([]); + const { files } = useFile(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const fileContents: { name: string; content: string }[] = filenames.map( + (name) => ({ name, content: files[name] || "" }) + ); + const addReplOutput = useCallback( + (command: string, output: ReplOutput[]) => + setReplOutputs((outs) => [...outs, { command, output }]), + [] + ); + const addFile = useCallback( + (filename: string) => + setFilenames((filenames) => + filenames.includes(filename) ? filenames : [...filenames, filename] + ), + [] + ); + const setExecResult = useCallback( + (filename: string, output: ReplOutput[]) => + setExecResults((results) => { + results[filename] = output; + return results; + }), + [] + ); + + // replOutputs: section内にあるターミナルにユーザーが入力したコマンドとその実行結果 + // fileContents: section内にあるファイルエディターの内容 + // execResults: section内にあるファイルの実行結果 + // console.log(section.title, replOutputs, fileContents, execResults); + return ( -
- {section.title} - - -
+ +
+ {section.title} + + +
+
); } -export function Heading({ level, children }: { level: number; children: ReactNode }) { +export function Heading({ + level, + children, +}: { + level: number; + children: ReactNode; +}) { switch (level) { case 1: return

{children}

; diff --git a/app/globals.css b/app/globals.css index 4c1b0c2..b9dbd27 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,2 +1,20 @@ @import "tailwindcss"; @plugin "daisyui"; + +/* inconsolata-latin-wght-normal */ +@font-face { + font-family: "Inconsolata Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url(https://cdn.jsdelivr.net/fontsource/fonts/inconsolata: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: "Inconsolata Variable", monospace; +} diff --git a/app/layout.tsx b/app/layout.tsx index 5a0f953..1165301 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,8 @@ import "./globals.css"; import { Navbar } from "./navbar"; import { Sidebar } from "./sidebar"; import { ReactNode } from "react"; +import { PyodideProvider } from "./terminal/python/pyodide"; +import { FileProvider } from "./terminal/file"; export const metadata: Metadata = { title: "Create Next App", @@ -19,7 +21,9 @@ export default function RootLayout({
- {children} + + {children} +