Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ jobs:
- run: npm ci
- run: npm run tsc

check-docs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run checkDocs

test-js-eval:
runs-on: ubuntu-latest
strategy:
Expand Down
8 changes: 3 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
/.open-next
/cloudflare-env.d.ts

# generated docs section file lists (regenerated by npm run generateSections)
/public/docs/**/sections.yml

# generated languages list (regenerated by npm run generateLanguages)
/public/docs/languages.yml
# generated docs section file lists (regenerated by npm run generateDocsMeta)
/public/docs/**/sections.json
/public/docs/languages.json

# dependencies
/node_modules
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ COPY --from=dependencies /app/node_modules ./node_modules
# Copy application source code
COPY . .

ENV NODE_ENV=production
# Stop if documentation has any change that is not reflected to revisions.yml and database.
RUN npx tsx ./scripts/checkDocs.ts --check-diff

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ npm run lint
```
でコードをチェックします。出てくるwarningやerrorはできるだけ直しましょう。

### データベースのスキーマ

* データベースのスキーマ(./app/schema/hoge.ts)を編集した場合、 `npx drizzle-kit generate` でmigrationファイルを作成し、 `npx drizzle-kit migrate` でデータベースに反映します。
* また、mainにマージする際に本番環境のデータベースにもmigrateをする必要があります
* スキーマのファイルを追加した場合は app/lib/drizzle.ts でimportを追加する必要があります(たぶん)
Expand All @@ -72,6 +74,11 @@ Cloudflare Worker のビルドログとステータス表示が見れますが

## ドキュメント

```bash
npm run checkDocs
```
でドキュメントの読み込み時にエラーにならないか確認できます (index.ymlの間違いなど)

* ドキュメントはセクション(見出し)ごとにわけ、 public/docs/言語id/ページid/並び替え用連番-セクション名.md に置く。
* ページはディレクトリの名前によらず 言語id/index.yml に書かれている順で表示される。
* セクションはセクションIDによらずファイル名順で表示される。
Expand Down Expand Up @@ -111,6 +118,7 @@ Cloudflare Worker のビルドログとステータス表示が見れますが
* REPLのコード例は1セクションに最大1つまで。
* コードエディターとコード実行ブロックはいくつでも置けます。
* ページ0以外の各ページの最後はレベル2見出し「この章のまとめ」と、レベル3見出し「練習問題n」を置く
* ドキュメントに変更を加えたものをmainブランチにpushした際、public/docs/revisions.ymlが更新されます。基本的には手動でこのファイルを編集する必要はありません。

### ベースとなるドキュメントの作り方

Expand Down Expand Up @@ -141,18 +149,17 @@ Cloudflare Worker のビルドログとステータス表示が見れますが
- Canvasを使われた場合はやり直す。(Canvasはファイル名付きコードブロックで壊れる)
- 太字がなぜか `**キーワード**` の代わりに `\*\*キーワード\*\*` となっている場合がある。 `\*\*` → `**` の置き換えで対応
- 見出しの前に `-----` (水平線)が入る場合がある。my.code();は水平線の表示に対応しているが、消す方向で統一
- `言語名-repl` にはページ内で一意なIDを追加する (例: `言語名-repl:1`)
- REPLの出力部分に書かれたコメントは消えるので修正する
- ダメな例
````
```js-repl:1
```js-repl
> console.log("Hello")
Hello // 文字列を表示する
```
````
- 以下のようにすればok
````
```js-repl:1
```js-repl
> console.log("Hello") // 文字列を表示する
Hello

Expand All @@ -162,7 +169,6 @@ Cloudflare Worker のビルドログとステータス表示が見れますが
```
````
- 練習問題のファイル名は不都合がなければ `practice(章番号)_(問題番号).拡張子` で統一。空でもよいのでファイルコードブロックとexecコードブロックを置く
- 1章にはたぶん練習問題要らない。

## markdown仕様

Expand Down
18 changes: 7 additions & 11 deletions app/[lang]/[pageId]/chatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,15 @@ import { DynamicMarkdownSection } from "./pageContent";
import { useEmbedContext } from "@/terminal/embedContext";
import { useChatHistoryContext } from "./chatHistory";
import { askAI } from "@/actions/chatActions";
import { PagePath } from "@/lib/docs";

interface ChatFormProps {
docs_id: string;
documentContent: string;
path: PagePath;
sectionContent: DynamicMarkdownSection[];
close: () => void;
}

export function ChatForm({
docs_id,
documentContent,
sectionContent,
close,
}: ChatFormProps) {
export function ChatForm({ path, sectionContent, close }: ChatFormProps) {
// const [messages, updateChatHistory] = useChatHistory(sectionId);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -80,9 +75,8 @@ export function ChatForm({
// }

const result = await askAI({
path,
userQuestion,
docsId: docs_id,
documentContent,
sectionContent,
replOutputs,
files,
Expand All @@ -94,7 +88,9 @@ export function ChatForm({
console.log(result.error);
} else {
addChat(result.chat);
// TODO: chatIdが指す対象の回答にフォーカス
document.getElementById(result.chat.sectionId)?.scrollIntoView({
behavior: "smooth",
});
setInputValue("");
close();
}
Expand Down
7 changes: 4 additions & 3 deletions app/[lang]/[pageId]/chatHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { ChatWithMessages, getChat } from "@/lib/chatHistory";
import { PagePath } from "@/lib/docs";
import {
createContext,
ReactNode,
Expand Down Expand Up @@ -28,11 +29,11 @@ export function useChatHistoryContext() {

export function ChatHistoryProvider({
children,
docs_id,
path,
initialChatHistories,
}: {
children: ReactNode;
docs_id: string;
path: PagePath;
initialChatHistories: ChatWithMessages[];
}) {
const [chatHistories, setChatHistories] =
Expand All @@ -43,7 +44,7 @@ export function ChatHistoryProvider({
}, [initialChatHistories]);
// その後、クライアント側で最新のchatHistoriesを改めて取得して更新する
const { data: fetchedChatHistories } = useSWR<ChatWithMessages[]>(
docs_id,
path,
getChat,
{
// リクエストは古くても構わないので1回でいい
Expand Down
27 changes: 14 additions & 13 deletions app/[lang]/[pageId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import { notFound } from "next/navigation";
import { PageContent } from "./pageContent";
import { ChatHistoryProvider } from "./chatHistory";
import { getChatFromCache, initContext } from "@/lib/chatHistory";
import { getMarkdownSections, getPagesList } from "@/lib/docs";
import {
getMarkdownSections,
getPagesList,
LangId,
PageSlug,
} from "@/lib/docs";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string; pageId: string }>;
params: Promise<{ lang: LangId; pageId: PageSlug }>;
}): Promise<Metadata> {
const { lang, pageId } = await params;
const pagesList = await getPagesList();
Expand All @@ -28,35 +33,31 @@ export async function generateMetadata({
export default async function Page({
params,
}: {
params: Promise<{ lang: string; pageId: string }>;
params: Promise<{ lang: LangId; pageId: PageSlug }>;
}) {
const { lang, pageId } = await params;
const pagesList = await getPagesList();
const langEntry = pagesList.find((l) => l.id === lang);
const pageEntry = langEntry?.pages.find((p) => p.slug === pageId);
if (!langEntry || !pageEntry) notFound();

const docsId = `${lang}/${pageId}`;
// server componentなのでuseMemoいらない
const path = { lang: lang, page: pageId };
const sections = await getMarkdownSections(lang, pageId);

// AI用のドキュメント全文(rawContentを結合)
const documentContent = sections.map((s) => s.rawContent).join("\n");

const context = await initContext();
const initialChatHistories = await getChatFromCache(docsId, context);
const initialChatHistories = await getChatFromCache(path, context);

return (
<ChatHistoryProvider
initialChatHistories={initialChatHistories}
docs_id={docsId}
path={path}
>
<PageContent
documentContent={documentContent}
splitMdContent={sections}
langEntry={langEntry}
pageEntry={pageEntry}
docs_id={docsId}
lang={lang}
pageId={pageId}
path={path}
/>
</ChatHistoryProvider>
);
Expand Down
47 changes: 25 additions & 22 deletions app/[lang]/[pageId]/pageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,47 @@ import { Heading, StyledMarkdown } from "./markdown";
import { useChatHistoryContext } from "./chatHistory";
import { useSidebarMdContext } from "@/sidebar";
import clsx from "clsx";
import { MarkdownSection, PageEntry } from "@/lib/docs";
import {
LanguageEntry,
MarkdownSection,
PageEntry,
PagePath,
} from "@/lib/docs";

// MarkdownSectionに追加で、ユーザーが今そのセクションを読んでいるかどうか、などの動的な情報を持たせる
export type DynamicMarkdownSection = MarkdownSection & {
inView: boolean;
};

interface PageContentProps {
documentContent: string;
splitMdContent: MarkdownSection[];
langEntry: LanguageEntry;
pageEntry: PageEntry;
lang: string;
pageId: string;
// TODO: チャット周りのid管理をsectionIdに移行し、docs_idパラメータを削除
docs_id: string;
path: PagePath;
}
export function PageContent(props: PageContentProps) {
const { setSidebarMdContent } = useSidebarMdContext();
const { splitMdContent, pageEntry, path } = props;

// SSR用のローカルstate
const [dynamicMdContent, setDynamicMdContent] = useState<
DynamicMarkdownSection[]
>(
props.splitMdContent.map((section) => ({
splitMdContent.map((section) => ({
...section,
inView: false,
}))
);

useEffect(() => {
// props.splitMdContentが変わったときにローカルstateとcontextの両方を更新
const newContent = props.splitMdContent.map((section) => ({
const newContent = splitMdContent.map((section) => ({
...section,
inView: false,
}));
setDynamicMdContent(newContent);
setSidebarMdContent(props.lang, props.pageId, newContent);
}, [props.splitMdContent, props.lang, props.pageId, setSidebarMdContent]);
setSidebarMdContent(path, newContent);
}, [splitMdContent, path, setSidebarMdContent]);

const sectionRefs = useRef<Array<HTMLDivElement | null>>([]);
// sectionRefsの長さをsplitMdContentに合わせる
Expand All @@ -70,14 +73,14 @@ export function PageContent(props: PageContentProps) {

// ローカルstateとcontextの両方を更新
setDynamicMdContent(updateContent);
setSidebarMdContent(props.lang, props.pageId, updateContent);
setSidebarMdContent(path, updateContent);
};
window.addEventListener("scroll", handleScroll);
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [setSidebarMdContent, props.lang, props.pageId]);
}, [setSidebarMdContent, path]);

const [isFormVisible, setIsFormVisible] = useState(false);

Expand All @@ -91,7 +94,7 @@ export function PageContent(props: PageContentProps) {
}}
>
<Heading level={1}>
第{props.pageEntry.index}章: {props.pageEntry.title}
第{pageEntry.index}章: {pageEntry.title}
</Heading>
<div />
{dynamicMdContent.map((section, index) => (
Expand All @@ -104,17 +107,18 @@ export function PageContent(props: PageContentProps) {
}}
>
{/* ドキュメントのコンテンツ */}
<StyledMarkdown
content={section.rawContent.replace(
/-repl\s*\n/,
`-repl:${section.id}\n`
)}
/>
<StyledMarkdown content={section.rawContent} />
</div>
<div>
{/* 右側に表示するチャット履歴欄 */}
{chatHistories
.filter((c) => c.sectionId === section.id)
.filter(
(c) =>
c.sectionId === section.id ||
// 対象のセクションが存在しないものは、introセクション(index=0)にフォールバックする
(index === 0 &&
dynamicMdContent.every((sec) => c.sectionId !== sec.id))
)
.map(({ chatId, messages }) => (
<div
key={chatId}
Expand Down Expand Up @@ -150,9 +154,8 @@ export function PageContent(props: PageContentProps) {
// replがz-10を使用することからそれの上にするためz-20
<div className="fixed bottom-4 right-4 left-4 lg:left-84 z-20">
<ChatForm
documentContent={props.documentContent}
path={path}
sectionContent={dynamicMdContent}
docs_id={props.docs_id}
close={() => setIsFormVisible(false)}
/>
</div>
Expand Down
Loading