diff --git a/.gitignore b/.gitignore
index a96a1a90..265b49a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
# documentation
/public/docs/**/sections.*
+/public/docs/**/termDefinitions.*
/public/docs/languages.*
/public/docs/revisions-dev.yml
/public/docs/commits-dev.yml
diff --git a/README.md b/README.md
index 28ae57c5..3ef60816 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,10 @@ Cloudflare Worker のビルドログとステータス表示が見れますが
- このセクションに関連する、想定される質問例。
# question: 自体を省略すると、GitHub ActionがGeminiを呼び出して質問例を生成しpushします。
# 質問例が不要な場合は question: [] にしてください。
+ term:
+ - キーワード
+ - きーわーど
+ # 他のページで [[キーワード]] と書いた場合に、このセクションの内容がポップアップで表示される。
```
* コード例はそれが配置されているセクションの内容と関連するようにする。
````md
@@ -185,7 +189,38 @@ Cloudflare Worker のビルドログとステータス表示が見れますが
````
- 練習問題のファイル名は不都合がなければ `practice(章番号)_(問題番号).拡張子` で統一。空でもよいのでファイルコードブロックとexecコードブロックを置く
-## markdown仕様
+### Alert
+
+GitHubのalert記法の、`NOTE` `TIP` `WARNING` `CAUTION` が使えます。
+`IMPORTANT` はwarningに置き換えられます。
+
+```
+> [!NOTE]
+> Highlights information that users should take into account, even when skimming.
+```
+
+### 内部リンク
+
+```
+[[用語]]
+```
+と書くと、`term: - 用語` が定義されている別のセクションへのリンクになり、ポップアップでその内容も表示されます。
+
+markdown書式も利用可能です。 `[[ほげ**ふが**]]` は `term: - ほげふが` へのリンクになり、表示は「ほげ**ふが**」になります。ただし `**[[ふが]]**` はリンクの色が優先されるのに対し `[[**ふが**]]` は強調の色が優先されます。
+
+```
+[[./1-foo]]
+[[./1]]
+```
+と書くと、同じ言語のページID `1-foo` またはindex `1` (ページのindexはindex.ymlの記述順で0-based) へのリンクになり、表示は「第1章」になります
+
+```
+[[./prev]]
+[[./next]]
+```
+と書くと、同じ言語の前のページ・次のページへのリンクになり、表示は「第1章」(実際の章番号)になります
+
+### コード実行環境仕様
実行環境の説明は ./packages/runtime/README.md を参照
diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx
index e3300cc9..0f5413b0 100644
--- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx
+++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx
@@ -4,9 +4,10 @@ import { ChatAreaStateUpdater } from "@/(docs)/chatAreaState";
import { useStreamingChatContext } from "@/(docs)/streamingChatContext";
import { deleteChatAction } from "@/actions/deleteChat";
import { ChatWithMessages } from "@/lib/chatHistory";
-import { LanguageEntry, MarkdownSection, PageEntry } from "@/lib/docs";
+import { LangId, MarkdownSection, PageSlug } from "@/lib/docs";
import { Heading } from "@/markdown/heading";
import { StyledMarkdown } from "@/markdown/markdown";
+import { usePagesListForLang } from "@/pagesListContext";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -70,12 +71,15 @@ export function ChatAreaContainer(props: {
interface Props {
chatId: string;
chatData: ChatWithMessages;
- targetLang: LanguageEntry | undefined;
- targetPage: PageEntry | undefined;
+ langId: LangId;
+ pageSlug: PageSlug;
targetSection: MarkdownSection | undefined;
}
export function ChatAreaContent(props: Props) {
- const { chatId, chatData, targetLang, targetPage, targetSection } = props;
+ const { chatId, chatData, langId, pageSlug, targetSection } = props;
+
+ const langEntry = usePagesListForLang(langId);
+ const pageEntry = langEntry?.pages.find((p) => p.slug === pageSlug);
const messagesAndDiffs = [
...chatData.messages.map((msg) => ({ type: "message" as const, ...msg })),
@@ -97,13 +101,13 @@ export function ChatAreaContent(props: Props) {
-
-
- {targetLang?.name}
+
+ {langEntry?.name}
-
- {targetPage?.index}. {targetPage?.name}
+ {pageEntry?.index}. {pageEntry?.name}
-
diff --git a/app/(docs)/@chat/chat/[chatId]/page.tsx b/app/(docs)/@chat/chat/[chatId]/page.tsx
index a2bc364b..726fbc6b 100644
--- a/app/(docs)/@chat/chat/[chatId]/page.tsx
+++ b/app/(docs)/@chat/chat/[chatId]/page.tsx
@@ -4,7 +4,11 @@ import {
getChatOne,
initContext,
} from "@/lib/chatHistory";
-import { getMarkdownSections, getPagesList } from "@/lib/docs";
+import {
+ getMarkdownSections,
+ LangId,
+ PageSlug,
+} from "@/lib/docs";
import { ChatAreaContainer, ChatAreaContent } from "./chatArea";
import { cacheLife, cacheTag } from "next/cache";
import { isCloudflare } from "@/lib/detectCloudflare";
@@ -29,17 +33,11 @@ export default async function ChatPage({
);
}
- const pagesList = await getPagesList();
- const targetLang = pagesList.find(
- (lang) => lang.id === chatData.section.pagePath.split("/")[0]
- );
- const targetPage = targetLang?.pages.find(
- (page) => page.slug === chatData.section.pagePath.split("/")[1]
- );
- const sections =
- targetLang && targetPage
- ? await getMarkdownSections(targetLang.id, targetPage.slug)
- : [];
+ const [langId, pageSlug] = chatData.section.pagePath.split("/") as [
+ LangId,
+ PageSlug,
+ ];
+ const sections = await getMarkdownSections(langId, pageSlug);
const targetSection = sections.find((sec) => sec.id === chatData.sectionId);
return (
@@ -47,8 +45,8 @@ export default async function ChatPage({
diff --git a/app/(docs)/@docs/[lang]/[pageId]/page.tsx b/app/(docs)/@docs/[lang]/[pageId]/page.tsx
index 524ddd20..842e0509 100644
--- a/app/(docs)/@docs/[lang]/[pageId]/page.tsx
+++ b/app/(docs)/@docs/[lang]/[pageId]/page.tsx
@@ -9,7 +9,8 @@ import {
} from "@/lib/chatHistory";
import {
getMarkdownSections,
- getPagesList,
+ getPagesListForLang,
+ getTermDefinitions,
LangId,
PagePath,
PageSlug,
@@ -18,6 +19,7 @@ import { cacheLife, cacheTag } from "next/cache";
import { isCloudflare } from "@/lib/detectCloudflare";
import { DocsAutoRedirect } from "./autoRedirect";
import { dateReviver } from "@/lib/dateReviver";
+import { TermDefinitionProvider } from "@/markdown/term";
export async function generateMetadata({
params,
@@ -25,8 +27,7 @@ export async function generateMetadata({
params: Promise<{ lang: LangId; pageId: PageSlug }>;
}): Promise {
const { lang, pageId } = await params;
- const pagesList = await getPagesList();
- const langEntry = pagesList.find((l) => l.id === lang);
+ const langEntry = await getPagesListForLang(lang);
const pageEntry = langEntry?.pages.find((p) => p.slug === pageId);
if (!langEntry || !pageEntry) notFound();
@@ -45,15 +46,6 @@ export default async function Page({
params: Promise<{ lang: LangId; pageId: PageSlug }>;
}) {
const { lang, pageId } = await params;
- const pagesList = await getPagesList();
- const langEntry = pagesList.find((l) => l.id === lang);
- const pageEntryIndex =
- langEntry?.pages.findIndex((p) => p.slug === pageId) ?? -1;
- const pageEntry = langEntry?.pages[pageEntryIndex];
- if (!langEntry || !pageEntry) notFound();
-
- const prevPage = langEntry.pages[pageEntryIndex - 1];
- const nextPage = langEntry.pages[pageEntryIndex + 1];
// server componentなのでuseMemoいらない
const path = { lang: lang, page: pageId };
@@ -62,17 +54,23 @@ export default async function Page({
const context = await initContext();
const chatHistories = await getChatFromCache(path, context.userId);
+ const termDefinitions = await getTermDefinitions(lang);
+
return (
<>
-
+
+
+
>
);
diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx
index 29bc52d3..e94d1501 100644
--- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx
+++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx
@@ -8,29 +8,35 @@ import clsx from "clsx";
import { PageTransition } from "./pageTransition";
import {
DynamicMarkdownSection,
- LanguageEntry,
+ LangId,
MarkdownSection,
- PageEntry,
PagePath,
+ PageSlug,
SectionId,
} from "@/lib/docs";
import { Heading } from "@/markdown/heading";
import Link from "next/link";
import { useChatId } from "@/(docs)/chatAreaState";
import { ChatWithMessages } from "@/lib/chatHistory";
+import { usePagesListForLang } from "@/pagesListContext";
interface PageContentProps {
splitMdContent: MarkdownSection[];
- langEntry: LanguageEntry;
- pageEntry: PageEntry;
- prevPage?: PageEntry;
- nextPage?: PageEntry;
+ langId: LangId;
+ pageSlug: PageSlug;
path: PagePath;
chatHistories: ChatWithMessages[];
}
export function PageContent(props: PageContentProps) {
const { setSidebarMdContent } = useSidebarMdContext();
- const { splitMdContent, pageEntry, path, chatHistories } = props;
+ const { splitMdContent, langId, pageSlug, path, chatHistories } = props;
+
+ const langEntry = usePagesListForLang(langId);
+ const pageEntryIndex =
+ langEntry?.pages.findIndex((p) => p.slug === pageSlug) ?? -1;
+ const pageEntry = langEntry?.pages[pageEntryIndex];
+ const prevPage = langEntry?.pages[pageEntryIndex - 1];
+ const nextPage = langEntry?.pages[pageEntryIndex + 1];
const [sectionInView, setSectionInView] = useState([]);
const sectionRefs = useRef>([]);
@@ -143,7 +149,7 @@ export function PageContent(props: PageContentProps) {
}}
>
- 第{pageEntry.index}章: {pageEntry.title}
+ 第{pageEntry?.index}章: {pageEntry?.title}
{dynamicMdContent.map((section, index) => (
@@ -159,6 +165,7 @@ export function PageContent(props: PageContentProps) {
@@ -172,8 +179,8 @@ export function PageContent(props: PageContentProps) {
))}
@@ -183,7 +190,7 @@ export function PageContent(props: PageContentProps) {
setIsFormVisible(false)}
/>
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index a4c182cf..cca53f93 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -9,7 +9,7 @@ import {
import {
DynamicMarkdownSectionSchema,
getMarkdownSections,
- getPagesList,
+ getPagesListForLang,
introSectionId,
PagePathSchema,
SectionId,
@@ -57,8 +57,7 @@ export async function POST(request: NextRequest) {
execResults,
} = parseResult.data;
- const pagesList = await getPagesList();
- const langEntry = pagesList.find((lang) => lang.id === path.lang);
+ const langEntry = await getPagesListForLang(path.lang);
const langName = langEntry?.name ?? path.lang;
let targetPath = path;
let targetSectionContent = sectionContent;
diff --git a/app/daisyAlertIcon.tsx b/app/daisyAlertIcon.tsx
index ebeef52e..7c3351c5 100644
--- a/app/daisyAlertIcon.tsx
+++ b/app/daisyAlertIcon.tsx
@@ -1,12 +1,17 @@
+import clsx from "clsx";
+
// https://daisyui.com/components/alert/ のコード例のsvgのコピペ
-export function DaisyInfoIcon() {
+/**
+ * 色を変える場合は のように使う
+ */
+export function DaisyInfoIcon({ className }: { className?: string }) {
return (
diff --git a/app/layout.tsx b/app/layout.tsx
index e5903737..785a0e70 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -14,6 +14,7 @@ import { SidebarMdProvider } from "./sidebar";
import { RuntimeProvider } from "@my-code/runtime/context";
import { getPagesList } from "@/lib/docs";
import { Footer } from "./footer";
+import { PagesListContextProvider } from "./pagesListContext";
export const metadata: Metadata = {
title: {
@@ -41,6 +42,7 @@ export default async function RootLayout({
+
-
+
+
diff --git a/app/lib/docs.ts b/app/lib/docs.ts
index 54d1be1d..9c8e9b95 100644
--- a/app/lib/docs.ts
+++ b/app/lib/docs.ts
@@ -43,6 +43,10 @@ export const SectionFrontMatterSchema = z.object({
* scripts/questionExample.ts で生成する
*/
question: z.array(z.string()).optional(),
+ /**
+ * 他のページで [[キーワード]] と書いた場合に、このセクションの内容がポップアップで表示される。
+ */
+ term: z.array(z.string()).optional(),
});
export type SectionFrontMatter = z.output;
export const MarkdownSectionSchema = SectionFrontMatterSchema.extend({
@@ -61,6 +65,14 @@ export const MarkdownSectionSchema = SectionFrontMatterSchema.extend({
});
export type MarkdownSection = z.output;
+export interface TermDefinition {
+ alias: string[];
+ page: PageSlug;
+ id: SectionId;
+ title: string;
+ rawContentWithoutCode: string;
+}
+
export const ReplacedRangeSchema = z.object({
start: z.number(),
end: z.number(),
@@ -85,6 +97,9 @@ export type DynamicMarkdownSection = z.output<
/**
* 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型
+ *
+ * getPagesList() で取得できるが、クライアントコンポーネントで頻繁に使うので、
+ * layout.tsxでcontextを初期化しておりusePagesList()で取得することもできる (pagesListContext.tsx)
*/
export interface LanguageEntry {
/**
@@ -197,22 +212,27 @@ async function getLanguageIds(): Promise {
export async function getPagesList(): Promise {
const langIds = await getLanguageIds();
- return await Promise.all(
- langIds.map(async (langId) => {
- const raw = await readPublicFile(`docs/${langId}/index.yml`);
- const data = yaml.load(raw) as IndexYml;
- return {
- id: langId,
- name: data.name as LangName,
- description: data.description,
- pages: data.pages.map((p, index) => ({
- ...p,
- slug: p.slug as PageSlug,
- index,
- })),
- };
- })
- );
+ return await Promise.all(langIds.map(getPagesListForLang));
+}
+export async function getPagesListForLang(
+ langId: LangId
+): Promise {
+ if (!(await getLanguageIds()).includes(langId)) {
+ notFound();
+ }
+
+ const raw = await readPublicFile(`docs/${langId}/index.yml`);
+ const data = yaml.load(raw) as IndexYml;
+ return {
+ id: langId,
+ name: data.name as LangName,
+ description: data.description,
+ pages: data.pages.map((p, index) => ({
+ ...p,
+ slug: p.slug as PageSlug,
+ index,
+ })),
+ };
}
export async function getRevisions(
@@ -254,6 +274,13 @@ export async function getMarkdownSections(
lang: LangId,
page: PageSlug
): Promise {
+ if (
+ /*!(await getLanguageIds()).includes(lang) || // getPagesListForLangのなかでチェック */
+ !(await getPagesListForLang(lang)).pages.some((p) => p.slug === page)
+ ) {
+ notFound();
+ }
+
if (isCloudflare()) {
const sectionsJson = await readPublicFile(
`docs/${lang}/${page}/sections.json`
@@ -328,6 +355,7 @@ function parseFrontmatter(content: string, file: string): MarkdownSection {
title: fm.title,
level: fm.level,
question: fm.question,
+ term: fm.term,
rawContent,
md5: crypto.createHash("md5").update(rawContent).digest("base64"),
};
@@ -360,3 +388,51 @@ export async function getRevisionOfMarkdownSection(
);
}
}
+
+export async function getTermDefinitions(
+ langId: LangId
+): Promise {
+ if (!(await getLanguageIds()).includes(langId)) {
+ notFound();
+ }
+
+ if (isCloudflare()) {
+ const termsJson = await readPublicFile(
+ `docs/${langId}/termDefinitions.json`
+ );
+ return JSON.parse(termsJson) as TermDefinition[];
+ } else {
+ // これに関してはの表示のたびに毎回全ファイルを読むのは非効率なので、
+ // cloudflareでない場合でも事前生成済みファイルがあれば利用
+ try {
+ const termsJson = await readPublicFile(
+ `docs/${langId}/termDefinitions.json`
+ );
+ return JSON.parse(termsJson) as TermDefinition[];
+ } catch (e) {
+ // not found?
+ console.warn(`failed to read docs/${langId}/termDefinitions.json:`, e);
+ }
+ const terms: TermDefinition[] = [];
+ const langEntry = await getPagesListForLang(langId);
+ const headingRegex = /^#+(.*)$/m;
+ const codeBlockRegex = /^(`{3,})(.*)\n([\s\S]*?)\n^\1/gm;
+ for (const page of langEntry.pages) {
+ const sections = await getMarkdownSections(langId, page.slug);
+ for (const section of sections) {
+ if (section.term && section.term.length >= 1) {
+ terms.push({
+ alias: section.term,
+ page: page.slug,
+ id: section.id,
+ title: section.title,
+ rawContentWithoutCode: section.rawContent
+ .replace(codeBlockRegex, "")
+ .replace(headingRegex, ""),
+ });
+ }
+ }
+ }
+ return terms;
+ }
+}
diff --git a/app/markdown/codeBlock.tsx b/app/markdown/codeBlock.tsx
index 9e2164e7..18f96038 100644
--- a/app/markdown/codeBlock.tsx
+++ b/app/markdown/codeBlock.tsx
@@ -9,18 +9,19 @@ import { ExtraProps } from "react-markdown";
import { StyledSyntaxHighlighter } from "./styledSyntaxHighlighter";
export function AutoCodeBlock({
+ interactive,
node,
className,
ref,
style,
...props
-}: JSX.IntrinsicElements["code"] & ExtraProps) {
+}: JSX.IntrinsicElements["code"] & ExtraProps & { interactive: boolean }) {
const match = /^language-(\w+)(-repl|-exec|-readonly)?\:?(.+)?$/.exec(
className || ""
);
if (match) {
const language = langConstants(match[1] as MarkdownLang | undefined);
- if (match[2] === "-exec" && match[3]) {
+ if (interactive && match[2] === "-exec" && match[3]) {
/*
```python-exec:main.py
hello, world!
@@ -40,7 +41,7 @@ export function AutoCodeBlock({
/>
);
}
- } else if (match[2] === "-repl") {
+ } else if (interactive && match[2] === "-repl") {
// repl付きの言語指定
if (!match[3]) {
console.error(
@@ -56,7 +57,7 @@ export function AutoCodeBlock({
/>
);
}
- } else if (match[3]) {
+ } else if (interactive && match[3]) {
// ファイル名指定がある場合、ファイルエディター
return (
{props.content}
@@ -30,7 +41,8 @@ export function StyledMarkdown(props: {
}
// TailwindCSSがh1などのタグのスタイルを消してしまうので、手動でスタイルを指定する必要がある
-const components: Components = {
+// チャット回答、term定義内などではコードブロックの操作やtermリンクを無効化したbaseComponentを使用
+const baseComponents: Components = {
h1: ({ children }) => {children},
h2: ({ children }) => {children},
h3: ({ children }) => {children},
@@ -55,7 +67,59 @@ const components: Components = {
),
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/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..c11b21cd
--- /dev/null
+++ b/app/markdown/term.tsx
@@ -0,0 +1,202 @@
+"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";
+
+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/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..b6b7e28d 100644
--- a/app/terminal/exec.tsx
+++ b/app/terminal/exec.tsx
@@ -139,7 +139,7 @@ export function ExecFile(props: ExecProps) {
return (
diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx
index c7b57a2c..ba9a32ec 100644
--- a/app/terminal/page.tsx
+++ b/app/terminal/page.tsx
@@ -308,7 +308,7 @@ function MochaTest() {
) : (
-
+
テストが完了しました
)}
diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx
index ca90efeb..3af039ce 100644
--- a/app/terminal/repl.tsx
+++ b/app/terminal/repl.tsx
@@ -462,7 +462,7 @@ export function ReplTerminal({
return (
diff --git a/package-lock.json b/package-lock.json
index 74249a68..f83ab58a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
],
"dependencies": {
"@better-auth/drizzle-adapter": "^1.6.23",
+ "@floating-ui/react": "^0.27.20",
"@fontsource-variable/inconsolata": "^5.2.7",
"@fontsource/m-plus-rounded-1c": "^5.2.9",
"@google/genai": "^1.21.0",
@@ -33,6 +34,7 @@
"prismjs": "^1.30.0",
"react": "^19",
"react-ace": "^14.0.1",
+ "react-children-utilities": "^2.10.0",
"react-dom": "^19",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
@@ -3077,6 +3079,59 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.27.20",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
+ "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.9",
+ "@floating-ui/utils": "^0.2.12",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
+ "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.8.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+ "license": "MIT"
+ },
"node_modules/@fontsource-variable/inconsolata": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inconsolata/-/inconsolata-5.2.8.tgz",
@@ -11788,6 +11843,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
@@ -18437,6 +18493,15 @@
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/react-children-utilities": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/react-children-utilities/-/react-children-utilities-2.10.0.tgz",
+ "integrity": "sha512-9naSkrOHACjoDsHDtAQR5mGMp3ffv1DkUSQPDa8iJFP0+lXloS4fw44hMHaWeNF3qL4B3GPiJ9032Oo309oa9g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=15"
+ }
+ },
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -20241,6 +20306,12 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/tabbable": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
+ "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -22280,6 +22351,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/webpack/node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "license": "MIT"
+ },
"node_modules/webpack/node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
diff --git a/package.json b/package.json
index 826783b9..dff83441 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
},
"dependencies": {
"@better-auth/drizzle-adapter": "^1.6.23",
+ "@floating-ui/react": "^0.27.20",
"@fontsource-variable/inconsolata": "^5.2.7",
"@fontsource/m-plus-rounded-1c": "^5.2.9",
"@google/genai": "^1.21.0",
@@ -44,6 +45,7 @@
"prismjs": "^1.30.0",
"react": "^19",
"react-ace": "^14.0.1",
+ "react-children-utilities": "^2.10.0",
"react-dom": "^19",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
diff --git a/scripts/generateDocsMeta.ts b/scripts/generateDocsMeta.ts
index aa7744c0..18e33dad 100644
--- a/scripts/generateDocsMeta.ts
+++ b/scripts/generateDocsMeta.ts
@@ -1,9 +1,14 @@
// Generates public/docs/{lang}/{pageId}/sections.yml for each page directory.
// Each sections.yml lists the .md files in that directory in display order.
-import { writeFile } from "node:fs/promises";
+import { unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
-import { getMarkdownSections, getPagesList } from "@/lib/docs";
+import {
+ getMarkdownSections,
+ getPagesList,
+ getTermDefinitions,
+} from "@/lib/docs";
+import { existsSync } from "node:fs";
const docsDir = join(process.cwd(), "public", "docs");
@@ -16,6 +21,19 @@ console.log(
);
for (const lang of langEntries) {
+ if (existsSync(join(docsDir, lang.id, "termDefinitions.json"))) {
+ await unlink(join(docsDir, lang.id, "termDefinitions.json"));
+ }
+ const terms = await getTermDefinitions(lang.id);
+ await writeFile(
+ join(docsDir, lang.id, "termDefinitions.json"),
+ JSON.stringify(terms),
+ "utf-8"
+ );
+ console.log(
+ `Generated ${lang.id}/termDefinitions.json (${terms.length} definitions, ${terms.reduce((sum, td) => sum + td.alias.length, 0)} terms)`
+ );
+
for (const page of lang.pages) {
const sections = await getMarkdownSections(lang.id, page.slug);
await writeFile(