+
),
hr: () => null,
- pre: ({ node, ...props }) => props.children,
- code: AutoCodeBlock,
+ blockquote: ({ node, className, children, ...props }) => (
+
+ がmx-2 my-2を設定するので、その分広げて相殺
+ className="flex-1 w-full -m-2"
+ >
+ {children}
+
+
+ ),
+ aside: ({ node, className, children, ...props }) => (
+ // remarkGitHubAlerts.ts でalertをasideタグにしている
+
+ ),
+ pre: ({ children }) => children,
+ code: (props) =>
,
+ ins: ({ children }) => children,
+ q: ({ children }) => children,
+};
+// ドキュメント本文で使うフルバージョン:
+const interactiveComponents: Components = {
+ ...baseComponents,
+ code: (props) =>
,
ins: MultiHighlightTag,
+ q: Term,
};
diff --git a/app/markdown/multiHighlight.tsx b/app/markdown/multiHighlight.tsx
index b118c0eb..c4e7ba19 100644
--- a/app/markdown/multiHighlight.tsx
+++ b/app/markdown/multiHighlight.tsx
@@ -106,7 +106,7 @@ export function MultiHighlightTag({
// classNameにチャットidが入っている。
// 選択しているチャットに対応するdiffのみ濃いハイライトにするなど (TODO)
className,
- "underline decoration-dashed underline-offset-[0.2rem] decoration-secondary/50",
+ "underline decoration-dashed decoration-secondary/50",
currentChatId && thisChatIds.includes(currentChatId)
? "bg-secondary/10"
: "",
diff --git a/app/markdown/remarkGithubAlerts.ts b/app/markdown/remarkGithubAlerts.ts
new file mode 100644
index 00000000..40c0d11f
--- /dev/null
+++ b/app/markdown/remarkGithubAlerts.ts
@@ -0,0 +1,145 @@
+import { visit } from "unist-util-visit";
+import type { Plugin } from "unified";
+import type { Root, PhrasingContent } from "mdast";
+
+/*
+https://github.com/jaywcjlove/remark-github-blockquote-alert のコピペ、改変
+
+MIT License
+
+Copyright (c) 2025 Kenny Wang(小弟调调™)
(https://github.com/jaywcjlove)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+const alertRegex = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i;
+const alertLegacyRegex = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)(\/.*)?\]/i;
+
+type Option = {
+ /**
+ * Use the legacy title format, which includes a slash and a title after the alert type.
+ *
+ * Enabling legacyTitle allows modifying the title, but this is not GitHub standard.
+ */
+ legacyTitle?: boolean;
+ /**
+ * The tag name of the alert container. default is `div`.
+ * or you can use `blockquote` for semantic HTML.
+ */
+ tagName?: string;
+ /**
+ * Custom class names for the alert container appending to the end.
+ */
+ classNames?: string;
+};
+
+/**
+ * Alerts are a Markdown extension based on the blockquote syntax that you can use to emphasize critical information.
+ * On GitHub, they are displayed with distinctive colors and icons to indicate the significance of the content.
+ * https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
+ */
+export const remarkAlert: Plugin<[Option?], Root> = ({
+ legacyTitle = false,
+ tagName = "aside",
+ classNames = "",
+} = {}) => {
+ return (tree) => {
+ visit(tree, "blockquote", (node /*, index, parent*/) => {
+ let alertType = "";
+ // let title = "";
+ let isNext = true;
+ const child = node.children.map((item) => {
+ if (isNext && item.type === "paragraph") {
+ const firstNode = item.children[0];
+ const text = firstNode.type === "text" ? firstNode.value : "";
+ const reg = legacyTitle ? alertLegacyRegex : alertRegex;
+ const match = text.match(reg);
+ if (match) {
+ isNext = false;
+ alertType = match[1].toLocaleLowerCase();
+ // title = legacyTitle
+ // ? match[2] || alertType.toLocaleUpperCase()
+ // : alertType.toLocaleUpperCase();
+ if (text.includes("\n")) {
+ item.children[0] = {
+ type: "text",
+ value: text.replace(reg, "").replace(/^\n+/, ""),
+ };
+ }
+
+ if (!text.includes("\n")) {
+ const itemChild: Array = [];
+ item.children.forEach((item, idx) => {
+ if (idx == 0) return;
+ if (idx == 1 && item.type === "break") {
+ return;
+ }
+ itemChild.push(item);
+ });
+ item.children = [...itemChild];
+ }
+ }
+ }
+ return item;
+ });
+
+ if (!!alertType) {
+ const daisyUIAlertClass = {
+ note: "alert-info",
+ tip: "alert-success",
+ important: "alert-warning",
+ warning: "alert-warning",
+ caution: "alert-error",
+ }[alertType]!;
+ node.data = {
+ hName: tagName,
+ hProperties: {
+ className: [
+ // "markdown-alert",
+ // `markdown-alert-${alertType}`,
+ daisyUIAlertClass,
+ ...classNames.split(" ").filter((s) => s.length),
+ ],
+ dir: "auto",
+ },
+ };
+ // アイコンの追加は別途jsxで行う
+ // child.unshift({
+ // type: "paragraph",
+ // children: [
+ // getAlertIcon(alertType as IconType),
+ // {
+ // type: "text",
+ // value: title.replace(/^\//, ""),
+ // },
+ // ],
+ // data: {
+ // hProperties: {
+ // className: "markdown-alert-title",
+ // dir: "auto",
+ // },
+ // },
+ // });
+ }
+ node.children = [...child];
+ });
+ };
+};
+
+export default remarkAlert;
diff --git a/app/markdown/remarkTerm.ts b/app/markdown/remarkTerm.ts
new file mode 100644
index 00000000..69da4ac4
--- /dev/null
+++ b/app/markdown/remarkTerm.ts
@@ -0,0 +1,94 @@
+import type { Plugin } from "unified";
+import type { Nodes, PhrasingContent, Root, RootContent } from "mdast";
+import { phrasing } from "mdast-util-phrasing";
+
+/**
+ * `[[用語]]`を`用語`に変換するプラグイン。
+ *
+ * https://github.com/ut-code/utcode-learn/blob/main/src/remark/remark-term.ts からコピペ
+ * Copyright (c) 2023 ut.code();
+ *
+ * @example
+ * // returns "**HTML**とCSS、そしてJavaScriptです。"
+ * String(
+ * await remark()
+ * .use(remarkMdx)
+ * .use(remarkTerm)
+ * .process("[[**HTML**]]と[[CSS]]、そして[[JavaScript]]です。"),
+ * );
+ */
+const remarkTerm: Plugin<[], Root> = () => (tree) => transform(tree);
+
+export default remarkTerm;
+
+function isParent(node: Nodes) {
+ return "children" in node;
+}
+
+function transform(node: Nodes) {
+ if (!isParent(node)) return;
+
+ for (const child of node.children) {
+ transform(child);
+ }
+
+ node.children = wrapDelimitedPhrasingContentsAsTerm(
+ node.children.flatMap((child) => isolateTermDelimiters(child))
+ );
+}
+
+function isolateTermDelimiters(node: RootContent): RootContent[] {
+ if (node.type !== "text") return [node];
+
+ return node.value
+ .split(/(\[\[|\]\])/)
+ .filter((segment) => segment !== "")
+ .map((segment) => ({
+ type: "text",
+ value: segment,
+ }));
+}
+
+function wrapDelimitedPhrasingContentsAsTerm(
+ children: RootContent[]
+): RootContent[] {
+ const result: RootContent[] = [];
+ const buffer: PhrasingContent[] = [];
+
+ for (const child of children) {
+ if (buffer.length === 0) {
+ if (child.type === "text" && child.value === "[[") {
+ buffer.push(child);
+ continue;
+ }
+ result.push(child);
+ continue;
+ }
+
+ if (child.type === "text" && child.value === "]]") {
+ // 修正ポイント:MDXノードの代わりに、hNameを持った汎用ノードを作成
+ result.push({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ type: "term" as any,
+ data: {
+ hName: "q",
+ },
+ children: buffer.slice(1),
+ });
+ buffer.length = 0;
+ continue;
+ }
+
+ if (phrasing(child)) {
+ buffer.push(child);
+ continue;
+ }
+
+ result.push(...buffer, child);
+ buffer.length = 0;
+ }
+
+ result.push(...buffer);
+
+ return result;
+}
diff --git a/app/markdown/term.tsx b/app/markdown/term.tsx
new file mode 100644
index 00000000..1550dacb
--- /dev/null
+++ b/app/markdown/term.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { createContext, JSX, ReactNode, useContext, useState } from "react";
+import { ExtraProps } from "react-markdown";
+import { onlyText } from "react-children-utilities";
+import { LangId, PageEntry, PageSlug, TermDefinition } from "@/lib/docs";
+import Link from "next/link";
+import { StyledMarkdown } from "./markdown";
+import {
+ useFloating,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ useHover,
+ useFocus,
+ useDismiss,
+ useRole,
+ useInteractions,
+ FloatingPortal,
+} from "@floating-ui/react";
+import clsx from "clsx";
+import { usePagesListForLang } from "@/pagesListContext";
+import { WithAutoTooltipPosition } from "./tooltipPosition";
+
+const TermDefinitionContext = createContext<{
+ lang: LangId;
+ page: PageSlug;
+ termDefinitions: TermDefinition[];
+} | null>(null);
+export function TermDefinitionProvider({
+ lang,
+ page,
+ termDefinitions,
+ children,
+}: {
+ lang: LangId;
+ page: PageSlug;
+ termDefinitions: TermDefinition[];
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * https://github.com/ut-code/utcode-learn/blob/main/src/components/Term/index.tsx をもとに独自実装
+ * Copyright (c) 2023 ut.code();
+ */
+export default function Term(props: JSX.IntrinsicElements["q"] & ExtraProps) {
+ // termDefinitionの取得がasync関数であり、clientコンポーネントから直接取得できないので、
+ // @docs/lang/pageId/page.tsx で取得したものをcontextに渡してそれを取得する
+ const { lang, page, termDefinitions } =
+ useContext(TermDefinitionContext) ?? {};
+
+ const langEntry = usePagesListForLang(lang);
+
+ // 1. Manage the tooltip's open state
+ const [isOpen, setIsOpen] = useState(false);
+
+ // 2. Setup Floating UI
+ const { refs, floatingStyles, context } = useFloating({
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ placement: "top", // Preferred placement
+ // Make sure the tooltip stays anchored to the trigger when scrolling/resizing
+ whileElementsMounted: autoUpdate,
+ middleware: [
+ offset(2), // Gap between trigger and tooltip
+ flip(), // Flip to bottom if no space on top
+ shift(), // Keep tooltip on screen
+ ],
+ });
+
+ // 3. Setup interactions (trigger on hover, focus, and dismiss on click outside/escape)
+ const hover = useHover(context, { move: false });
+ const focus = useFocus(context);
+ const dismiss = useDismiss(context);
+ const role = useRole(context, { role: "tooltip" });
+
+ // Merge the interactions into prop getters
+ const { getReferenceProps, getFloatingProps } = useInteractions([
+ hover,
+ focus,
+ dismiss,
+ role,
+ ]);
+
+ if (!termDefinitions) {
+ return props.children;
+ }
+
+ const termText = onlyText(props.children);
+ const term = termDefinitions.find((t) => t.alias.includes(termText));
+ if (!term) {
+ const internalLink = (pageEntry: PageEntry) => (
+
+ 第{pageEntry.index}章
+
+ );
+
+ // ./1, ./1-foo, ./next, ./prev →同じ言語のドキュメントへのリンクで、「第n章」
+ const pageIndexMatch = termText.match(/^.\/(\d+)$/);
+ const pageSlugMatch = termText.match(/^.\/([0-9a-zA-Z_-]+)$/);
+ if (
+ pageIndexMatch &&
+ langEntry &&
+ Number(pageIndexMatch[1]) < langEntry.pages.length
+ ) {
+ return internalLink(langEntry.pages[Number(pageIndexMatch[1])]);
+ }
+ if (
+ pageSlugMatch &&
+ langEntry?.pages.find((p) => p.slug === pageSlugMatch[1])
+ ) {
+ return internalLink(
+ langEntry.pages.find((p) => p.slug === pageSlugMatch[1])!
+ );
+ }
+ const currentPageIndex = langEntry?.pages.findIndex((p) => p.slug === page);
+ if (
+ pageSlugMatch &&
+ langEntry &&
+ pageSlugMatch[1] === "prev" &&
+ currentPageIndex !== undefined
+ ) {
+ // ./prev → 前のページ
+ return internalLink(langEntry.pages[currentPageIndex - 1]);
+ }
+ if (
+ pageSlugMatch &&
+ langEntry &&
+ pageSlugMatch[1] === "next" &&
+ currentPageIndex !== undefined
+ ) {
+ // ./next → 次のページ
+ return internalLink(langEntry.pages[currentPageIndex + 1]);
+ }
+
+ console.error(`'${termText}' という用語は定義されていません`);
+ return (
+
+ {props.children}
+
+ );
+ }
+
+ const pageEntry = langEntry?.pages.find((p) => p.slug === term.page);
+
+ return (
+ <>
+
+ {props.children}
+
+ {isOpen && (
+
+
+
+
+
+ -
+ {pageEntry?.index}. {pageEntry?.name}
+
+ - {term.title}
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/app/markdown/tooltipPosition.tsx b/app/markdown/tooltipPosition.tsx
new file mode 100644
index 00000000..c4692bd3
--- /dev/null
+++ b/app/markdown/tooltipPosition.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { ComponentPropsWithoutRef, ElementType } from "react";
+
+/**
+ * レンダリングのたびに、この要素の左端と右端のスペースを計算し、画面内に収まるようtooltipを水平に平行移動する。
+ */
+function updateTooltipPosition(node: HTMLElement | null) {
+ if (node) {
+ const rect = node.getBoundingClientRect();
+ node.style.setProperty(
+ "--tt-trans",
+ `clamp(${-(rect.left + rect.width / 2)}px, -50%, calc(${document.body.clientWidth - rect.right + rect.width / 2}px - 100%))`
+ );
+ }
+}
+
+type Props = { as: T } & Omit<
+ ComponentPropsWithoutRef,
+ "as"
+>;
+export function WithAutoTooltipPosition({
+ as,
+ ...props
+}: Props) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const Component = as as React.ElementType;
+ return (
+ updateTooltipPosition(node)}
+ {...props}
+ />
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 8d53dbd3..4aec1ad5 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -128,7 +128,7 @@ export default async function Home() {
my.code();
ならあなたがまだ触ったことがない言語を気軽に体験することができます。
-
+
プログラミング未経験の方、何から始めればいいかわからない...という方は、
diff --git a/app/pagesListContext.tsx b/app/pagesListContext.tsx
new file mode 100644
index 00000000..d57be245
--- /dev/null
+++ b/app/pagesListContext.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { createContext, ReactNode, useContext } from "react";
+import { LangId, LanguageEntry } from "./lib/docs";
+
+const PagesListContext = createContext(null!);
+
+export function PagesListContextProvider({
+ pagesList,
+ children,
+}: {
+ pagesList: LanguageEntry[];
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const usePagesList = () => useContext(PagesListContext);
+export function usePagesListForLang(lang?: LangId) {
+ const pagesList = useContext(PagesListContext);
+ return pagesList.find((p) => p.id === lang);
+}
diff --git a/app/sidebar.tsx b/app/sidebar.tsx
index ac1787d3..1615ee93 100644
--- a/app/sidebar.tsx
+++ b/app/sidebar.tsx
@@ -1,13 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import {
- DynamicMarkdownSection,
- LangId,
- LanguageEntry,
- PagePath,
- PageSlug,
-} from "@/lib/docs";
+import { DynamicMarkdownSection, LangId, PagePath, PageSlug } from "@/lib/docs";
import { AccountMenu } from "./accountMenu";
import { ThemeToggle } from "./themeToggle";
import {
@@ -20,6 +14,7 @@ import {
import clsx from "clsx";
import { LanguageIcon } from "@/terminal/icons";
import { RuntimeLang } from "@my-code/runtime/languages";
+import { usePagesList } from "./pagesListContext";
export interface ISidebarMdContext {
loadedPath: PagePath | null;
@@ -78,11 +73,12 @@ export function SidebarMdProvider({ children }: { children: ReactNode }) {
);
}
-export function Sidebar({ pagesList }: { pagesList: LanguageEntry[] }) {
+export function Sidebar() {
const pathname = usePathname();
const pathnameMatch = pathname.match(/^\/([\w-_]+)\/([\w-_]+).*?/);
const currentLang = pathnameMatch?.[1] as LangId;
const currentPageId = pathnameMatch?.[2] as PageSlug;
+ const pagesList = usePagesList();
const sidebarContext = useSidebarMdContext();
// sidebarMdContextの情報が古かったら使わない
const sidebarMdContent =
diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx
index aef2e0f6..14d2547b 100644
--- a/app/terminal/editor.tsx
+++ b/app/terminal/editor.tsx
@@ -86,7 +86,7 @@ export function EditorComponent(props: EditorProps) {
return (
diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx
index 7bf12e59..842b496c 100644
--- a/app/terminal/exec.tsx
+++ b/app/terminal/exec.tsx
@@ -15,6 +15,7 @@ import { LangConstants } from "@my-code/runtime/languages";
import { useRuntime } from "@my-code/runtime/context";
import { captureException } from "@sentry/nextjs";
import { MinMaxButton, Modal } from "./modal";
+import { WithAutoTooltipPosition } from "@/markdown/tooltipPosition";
function handleRuntimeError(error: unknown) {
captureException(error);
@@ -139,11 +140,16 @@ export function ExecFile(props: ExecProps) {
return (
-
+
) : (
-
+
テストが完了しました
)}
diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx
index ca90efeb..35f35b82 100644
--- a/app/terminal/repl.tsx
+++ b/app/terminal/repl.tsx
@@ -26,6 +26,7 @@ import {
import { useRuntime } from "@my-code/runtime/context";
import { MinMaxButton, Modal } from "./modal";
import { StopButtonContent } from "./exec";
+import { WithAutoTooltipPosition } from "@/markdown/tooltipPosition";
function handleRuntimeError(error: unknown) {
captureException(error);
@@ -462,11 +463,15 @@ export function ReplTerminal({
return (
-
+