diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index b68ddafa..eed15240 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -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: diff --git a/.gitignore b/.gitignore index 11f8e336..3e4ebaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index 6d5eb348..e8943f20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 656ba756..e66641c3 100644 --- a/README.md +++ b/README.md @@ -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を追加する必要があります(たぶん) @@ -72,6 +74,11 @@ Cloudflare Worker のビルドログとステータス表示が見れますが ## ドキュメント +```bash +npm run checkDocs +``` +でドキュメントの読み込み時にエラーにならないか確認できます (index.ymlの間違いなど) + * ドキュメントはセクション(見出し)ごとにわけ、 public/docs/言語id/ページid/並び替え用連番-セクション名.md に置く。 * ページはディレクトリの名前によらず 言語id/index.yml に書かれている順で表示される。 * セクションはセクションIDによらずファイル名順で表示される。 @@ -111,6 +118,7 @@ Cloudflare Worker のビルドログとステータス表示が見れますが * REPLのコード例は1セクションに最大1つまで。 * コードエディターとコード実行ブロックはいくつでも置けます。 * ページ0以外の各ページの最後はレベル2見出し「この章のまとめ」と、レベル3見出し「練習問題n」を置く +* ドキュメントに変更を加えたものをmainブランチにpushした際、public/docs/revisions.ymlが更新されます。基本的には手動でこのファイルを編集する必要はありません。 ### ベースとなるドキュメントの作り方 @@ -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 @@ -162,7 +169,6 @@ Cloudflare Worker のビルドログとステータス表示が見れますが ``` ```` - 練習問題のファイル名は不都合がなければ `practice(章番号)_(問題番号).拡張子` で統一。空でもよいのでファイルコードブロックとexecコードブロックを置く -- 1章にはたぶん練習問題要らない。 ## markdown仕様 diff --git a/app/[lang]/[pageId]/chatForm.tsx b/app/[lang]/[pageId]/chatForm.tsx index 85d81388..0231dc86 100644 --- a/app/[lang]/[pageId]/chatForm.tsx +++ b/app/[lang]/[pageId]/chatForm.tsx @@ -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); @@ -80,9 +75,8 @@ export function ChatForm({ // } const result = await askAI({ + path, userQuestion, - docsId: docs_id, - documentContent, sectionContent, replOutputs, files, @@ -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(); } diff --git a/app/[lang]/[pageId]/chatHistory.tsx b/app/[lang]/[pageId]/chatHistory.tsx index 184c2ebc..28609087 100644 --- a/app/[lang]/[pageId]/chatHistory.tsx +++ b/app/[lang]/[pageId]/chatHistory.tsx @@ -1,6 +1,7 @@ "use client"; import { ChatWithMessages, getChat } from "@/lib/chatHistory"; +import { PagePath } from "@/lib/docs"; import { createContext, ReactNode, @@ -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] = @@ -43,7 +44,7 @@ export function ChatHistoryProvider({ }, [initialChatHistories]); // その後、クライアント側で最新のchatHistoriesを改めて取得して更新する const { data: fetchedChatHistories } = useSWR( - docs_id, + path, getChat, { // リクエストは古くても構わないので1回でいい diff --git a/app/[lang]/[pageId]/page.tsx b/app/[lang]/[pageId]/page.tsx index d4391ad1..bbdfd117 100644 --- a/app/[lang]/[pageId]/page.tsx +++ b/app/[lang]/[pageId]/page.tsx @@ -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 { const { lang, pageId } = await params; const pagesList = await getPagesList(); @@ -28,7 +33,7 @@ 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(); @@ -36,27 +41,23 @@ export default async function Page({ 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 ( ); diff --git a/app/[lang]/[pageId]/pageContent.tsx b/app/[lang]/[pageId]/pageContent.tsx index ac91c2bd..1c9acbe5 100644 --- a/app/[lang]/[pageId]/pageContent.tsx +++ b/app/[lang]/[pageId]/pageContent.tsx @@ -6,7 +6,12 @@ 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 & { @@ -14,22 +19,20 @@ export type DynamicMarkdownSection = MarkdownSection & { }; 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, })) @@ -37,13 +40,13 @@ export function PageContent(props: PageContentProps) { 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>([]); // sectionRefsの長さをsplitMdContentに合わせる @@ -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); @@ -91,7 +94,7 @@ export function PageContent(props: PageContentProps) { }} > - 第{props.pageEntry.index}章: {props.pageEntry.title} + 第{pageEntry.index}章: {pageEntry.title}
{dynamicMdContent.map((section, index) => ( @@ -104,17 +107,18 @@ export function PageContent(props: PageContentProps) { }} > {/* ドキュメントのコンテンツ */} - +
{/* 右側に表示するチャット履歴欄 */} {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 }) => (
setIsFormVisible(false)} />
diff --git a/app/actions/chatActions.ts b/app/actions/chatActions.ts index dfbb7180..c44b721d 100644 --- a/app/actions/chatActions.ts +++ b/app/actions/chatActions.ts @@ -5,6 +5,7 @@ import { generateContent } from "./gemini"; import { DynamicMarkdownSection } from "../[lang]/[pageId]/pageContent"; import { ReplCommand, ReplOutput } from "../terminal/repl"; import { addChat, ChatWithMessages } from "@/lib/chatHistory"; +import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs"; type ChatResult = | { @@ -17,9 +18,8 @@ type ChatResult = }; type ChatParams = { + path: PagePath; userQuestion: string; - docsId: string; - documentContent: string; sectionContent: DynamicMarkdownSection[]; replOutputs: Record; files: Record; @@ -37,18 +37,22 @@ export async function askAI(params: ChatParams): Promise { // } const { + path, userQuestion, - documentContent, sectionContent, replOutputs, files, execResults, } = params; + const pagesList = await getPagesList(); + const langName = pagesList.find((lang) => lang.id === path.lang)?.name; + const prompt: string[] = []; + prompt.push(`あなたは${langName}言語のチュートリアルの講師をしています。`); prompt.push( - `以下のPythonチュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` + `以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` ); prompt.push(``); const sectionTitlesInView = sectionContent @@ -64,8 +68,13 @@ export async function askAI(params: ChatParams): Promise { prompt.push(``); prompt.push(`# ドキュメント`); prompt.push(``); - prompt.push(documentContent); + for (const section of sectionContent) { + prompt.push(`[セクションid: ${section.id}]`); + prompt.push(section.rawContent.trim()); + prompt.push(``); + } prompt.push(``); + // TODO: 各セクションのドキュメントの直下にそのセクション内のターミナルの情報を加えるべきなのでは? if (Object.keys(replOutputs).length > 0) { prompt.push( `# ターミナルのログ(ユーザーが入力したコマンドとその実行結果)` @@ -75,7 +84,7 @@ export async function askAI(params: ChatParams): Promise { "以下はドキュメント内で実行例を示した各コードブロックの内容に加えてユーザーが追加で実行したコマンドです。" ); prompt.push( - "例えば ```python-repl:1 のコードブロックに対してユーザーが実行したログが ターミナル #1 です。" + "例えば ```python-repl:foo のコードブロックに対してユーザーが実行したログが ターミナル #foo です。" ); prompt.push(``); for (const [replId, replCommands] of Object.entries(replOutputs)) { @@ -125,34 +134,45 @@ export async function askAI(params: ChatParams): Promise { } } - prompt.push("# ユーザーからの質問"); - prompt.push(userQuestion); - prompt.push(``); - prompt.push("# 指示"); + prompt.push(""); + prompt.push( + `- 1行目に、ユーザーの質問ともっとも関連性の高いドキュメント内のセクションのidを回答してください。idのみを出力してください。` + ); prompt.push( - "- 回答はMarkdown形式で記述し、コードブロックを適切に使用してください。" + " - ユーザーの質問がドキュメントのどのセクションとも直接的に関連しない場合は空白でも良いです。" ); - prompt.push("- ドキュメントの内容に基づいて回答してください。"); + prompt.push("- 2行目は水平線 --- を出力してください。"); prompt.push( - "- ユーザーが入力したターミナルのコマンドやファイルの内容、実行結果を参考にして回答してください。" + "- それ以降の行に、ドキュメントの内容に基づいて、ユーザーに伝える回答をMarkdown形式で記述してください。" + ); + prompt.push( + " - ユーザーが入力したターミナルのコマンドやファイルの内容、実行結果を参考にして回答してください。" + ); + prompt.push(" - 必要であれば、具体的なコード例を提示してください。"); + prompt.push( + " - 回答内でコードブロックを使用する際は ```言語名 としてください。" + + "ドキュメント内では ```言語名-repl や ```言語名:ファイル名 、 ```言語名-exec:ファイル名 などが登場しますが、ユーザーへの回答ではこれらの記法は使用しないでください。" + ); + prompt.push( + " - 水平線(---)はシステムが区切りとして認識するので、ユーザーへの回答中に水平線を使用することはできません。" ); - prompt.push("- ユーザーへの回答のみを出力してください。"); - prompt.push("- 必要であれば、具体的なコード例を提示してください。"); console.log(prompt); try { - const result = await generateContent(prompt.join("\n")); + const result = await generateContent(userQuestion, prompt.join("\n")); const text = result.text; if (!text) { throw new Error("AIからの応答が空でした"); } - // TODO: どのセクションへの回答にするかをAIに決めさせる - const targetSectionId = - sectionContent.find((s) => s.inView)?.id || ""; - const newChat = await addChat(params.docsId, targetSectionId, [ + let targetSectionId = text.split(/-{3,}/)[0].trim() as SectionId; + if (!targetSectionId) { + targetSectionId = introSectionId(path); + } + const responseMessage = text.split(/-{3,}/)[1].trim(); + const newChat = await addChat(path, targetSectionId, [ { role: "user", content: userQuestion }, - { role: "ai", content: text }, + { role: "ai", content: responseMessage }, ]); return { error: null, diff --git a/app/actions/gemini.ts b/app/actions/gemini.ts index f89651f1..8dc75394 100644 --- a/app/actions/gemini.ts +++ b/app/actions/gemini.ts @@ -2,10 +2,13 @@ import { GoogleGenAI } from "@google/genai"; -export async function generateContent(prompt: string) { +export async function generateContent(prompt: string, systemInstruction?: string) { const params = { model: "gemini-2.5-flash", contents: prompt, + config: { + systemInstruction, + } }; const ai = new GoogleGenAI({ apiKey: process.env.API_KEY! }); diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index d4f9938e..4be495d3 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -3,12 +3,13 @@ import { headers } from "next/headers"; import { getAuthServer } from "./auth"; import { getDrizzle } from "./drizzle"; -import { chat, message } from "@/schema/chat"; -import { and, asc, eq } from "drizzle-orm"; +import { chat, message, section } from "@/schema/chat"; +import { and, asc, eq, exists } from "drizzle-orm"; import { Auth } from "better-auth"; import { revalidateTag, unstable_cacheLife } from "next/cache"; import { isCloudflare } from "./detectCloudflare"; import { unstable_cacheTag } from "next/cache"; +import { PagePath, SectionId } from "./docs"; export interface CreateChatMessage { role: "user" | "ai" | "error"; @@ -17,6 +18,9 @@ export interface CreateChatMessage { // cacheに使うキーで、実際のURLではない const CACHE_KEY_BASE = "https://my-code.utcode.net/chatHistory"; +function cacheKeyForPage(path: PagePath, userId: string) { + return `${CACHE_KEY_BASE}/getChat?path=${path.lang}/${path.page}&userId=${userId}`; +} interface Context { drizzle: Awaited>; @@ -50,8 +54,8 @@ export async function initContext(ctx?: Partial): Promise { } export async function addChat( - docsId: string, - sectionId: string, + path: PagePath, + sectionId: SectionId, messages: CreateChatMessage[], context?: Partial ) { @@ -63,7 +67,6 @@ export async function addChat( .insert(chat) .values({ userId, - docsId, sectionId, }) .returning(); @@ -79,19 +82,21 @@ export async function addChat( ) .returning(); - revalidateTag(`${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}`); + revalidateTag(cacheKeyForPage(path, userId)); if (isCloudflare()) { const cache = await caches.open("chatHistory"); console.log( - `deleting cache for chatHistory/getChat for user ${userId} and docs ${docsId}` - ); - await cache.delete( - `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}` + `deleting cache for chatHistory/getChat for user ${userId} and docs ${path.lang}/${path.page}` ); + await cache.delete(cacheKeyForPage(path, userId)); } return { ...newChat, + section: { + sectionId, + pagePath: `${path.lang}/${path.page}`, + }, messages: chatMessages, }; } @@ -99,7 +104,7 @@ export async function addChat( export type ChatWithMessages = Awaited>; export async function getChat( - docsId: string, + path: PagePath, context?: Partial ): Promise { const { drizzle, userId } = await initContext(context); @@ -108,8 +113,20 @@ export async function getChat( } const chats = await drizzle.query.chat.findMany({ - where: and(eq(chat.userId, userId), eq(chat.docsId, docsId)), + where: and( + eq(chat.userId, userId), + exists( + drizzle + .select() + .from(section) + .where(and( + eq(section.sectionId, chat.sectionId), + eq(section.pagePath, `${path.lang}/${path.page}`), + )) + ) + ), with: { + section: true, messages: { orderBy: [asc(message.createdAt)], }, @@ -120,35 +137,32 @@ export async function getChat( if (isCloudflare()) { const cache = await caches.open("chatHistory"); await cache.put( - `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}`, + cacheKeyForPage(path, userId), new Response(JSON.stringify(chats), { headers: { "Cache-Control": "max-age=86400, s-maxage=86400" }, }) ); } + // @ts-expect-error なぜかchatsの型にsectionとmessagesが含まれていないことになっているが、正しくwithを指定しているし、console.logしてみるとちゃんと含まれている return chats; } -export async function getChatFromCache(docsId: string, context: Context) { +export async function getChatFromCache(path: PagePath, context: Context) { "use cache"; unstable_cacheLife("days"); // cacheされる関数の中でheader()にはアクセスできない。 // なので外でinitContext()を呼んだものを引数に渡す必要がある。 // しかし、drizzleオブジェクトは外から渡せないのでgetChatの中で改めてinitContext()を呼んでdrizzleだけ再初期化している + // こんな意味不明な仕様になっているのはactionから呼ばれる関数とレンダリング時に呼ばれる関数を1ファイルでまとめて定義し共通化しようとしているせい。あとでなんとかする const { auth, userId } = context; - unstable_cacheTag( - `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}` - ); - if (!userId) { return []; } + unstable_cacheTag(cacheKeyForPage(path, userId)); if (isCloudflare()) { const cache = await caches.open("chatHistory"); - const cachedResponse = await cache.match( - `${CACHE_KEY_BASE}/getChat?docsId=${docsId}&userId=${userId}` - ); + const cachedResponse = await cache.match(cacheKeyForPage(path, userId)); if (cachedResponse) { console.log("Cache hit for chatHistory/getChat"); const data = (await cachedResponse.json()) as ChatWithMessages[]; @@ -157,7 +171,7 @@ export async function getChatFromCache(docsId: string, context: Context) { console.log("Cache miss for chatHistory/getChat"); } } - return await getChat(docsId, { auth, userId }); + return await getChat(path, { auth, userId }); } export async function migrateChatUser(oldUserId: string, newUserId: string) { diff --git a/app/lib/docs.ts b/app/lib/docs.ts index a59c81c6..2f16c47b 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -4,28 +4,82 @@ import { join } from "node:path"; import yaml from "js-yaml"; import { isCloudflare } from "./detectCloudflare"; import { notFound } from "next/navigation"; +import crypto from "node:crypto"; + +/* +Branded Types +文字列に「架空のプロパティ」を交差させることで、コンパイラに別の型として認識させます。 +実際には __brand というプロパティは実行時には存在しませんが、 +コンパイル時のみ「この文字列は Id 用にラベル付けされたものだ」と厳格にチェックしてくれます。 + +ってGeminiが言ってた +*/ +type Brand = K & { readonly __brand: T }; +export type LangId = Brand; +export type LangName = Brand; +export type PageSlug = Brand; +export interface PagePath { + lang: LangId; + page: PageSlug; +} +export type SectionId = Brand; export interface MarkdownSection { - id: string; + /** + * セクションのmdファイル名 + */ + file: string; + /** + * frontmatterに書くセクションid + * (データベース上の sectionId) + */ + id: SectionId; level: number; title: string; - rawContent: string; // 見出しも含めたもとのmarkdownの内容 + /** + * frontmatterを除く、見出しも含めたもとのmarkdownの内容 + */ + rawContent: string; + /** + * rawContentのmd5ハッシュのbase64エンコード + */ + md5: string; } +/** + * 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型 + */ +export interface LanguageEntry { + /** + * public/docs/にある言語のディレクトリ名をidとして用いる + */ + id: LangId; + /** + * 言語の表示名 + */ + name: LangName; + description: string; + pages: PageEntry[]; +} export interface PageEntry { + /** + * 章番号、0からはじまる連番 + */ index: number; - slug: string; + /** + * 章のディレクトリ名。 + */ + slug: PageSlug; + /** + * 章の短いタイトル + */ name: string; + /** + * 章の長いタイトル + */ title: string; } -export interface LanguageEntry { - id: string; - name: string; - description: string; - pages: PageEntry[]; -} - interface IndexYml { name: string; description: string; @@ -36,6 +90,28 @@ interface IndexYml { }[]; } +export interface RevisionYmlEntry { + /** + * `langId/pageSlug` + */ + page: string; + rev: SectionRevision[]; +} +export interface SectionRevision { + /** + * rawContentのmd5ハッシュ + */ + md5: string; + /** + * git上のコミットid + */ + git: string; + /** + * リポジトリのルートからの、セクションのmdファイルのパス + */ + path: string; +} + async function readPublicFile(path: string): Promise { try { if (isCloudflare()) { @@ -57,16 +133,16 @@ async function readPublicFile(path: string): Promise { } } -async function getLanguageIds(): Promise { +async function getLanguageIds(): Promise { if (isCloudflare()) { - const raw = await readPublicFile("docs/languages.yml"); - return yaml.load(raw) as string[]; + const raw = await readPublicFile("docs/languages.json"); + return JSON.parse(raw) as LangId[]; } else { const docsDir = join(process.cwd(), "public", "docs"); const entries = await readdir(docsDir, { withFileTypes: true }); return entries .filter((e) => e.isDirectory()) - .map((e) => e.name) + .map((e) => e.name as LangId) .sort(); } } @@ -79,10 +155,11 @@ export async function getPagesList(): Promise { const data = yaml.load(raw) as IndexYml; return { id: langId, - name: data.name, + name: data.name as LangName, description: data.description, pages: data.pages.map((p, index) => ({ ...p, + slug: p.slug as PageSlug, index, })), }; @@ -90,28 +167,66 @@ export async function getPagesList(): Promise { ); } +export async function getSectionsList( + lang: LangId, + page: PageSlug +): Promise { + if (isCloudflare()) { + const sectionsJson = await readPublicFile( + `docs/${lang}/${page}/sections.json` + ); + return JSON.parse(sectionsJson) as string[]; + } else { + function naturalSortMdFiles(a: string, b: string): number { + // -intro.md always comes first + if (a === "-intro.md") return -1; + if (b === "-intro.md") return 1; + // Sort numerically by leading N1-N2 prefix + const aMatch = a.match(/^(\d+)-(\d+)/); + const bMatch = b.match(/^(\d+)-(\d+)/); + if (aMatch && bMatch) { + const n1Diff = parseInt(aMatch[1]) - parseInt(bMatch[1]); + if (n1Diff !== 0) return n1Diff; + return parseInt(aMatch[2]) - parseInt(bMatch[2]); + } + return a.localeCompare(b); + } + return (await readdir(join(process.cwd(), "public", "docs", lang, page))) + .filter((f) => f.endsWith(".md")) + .sort(naturalSortMdFiles); + } +} + +export async function getRevisions( + sectionId: SectionId +): Promise { + const revisionsYml = await readPublicFile(`docs/revisions.yml`); + return (yaml.load(revisionsYml) as Record)[ + sectionId + ]; +} + /** * public/docs/{lang}/{pageId}/ 以下のmdファイルを結合して MarkdownSection[] を返す。 */ export async function getMarkdownSections( - lang: string, - pageId: string + lang: LangId, + page: PageSlug ): Promise { - const sectionsYml = await readPublicFile( - `docs/${lang}/${pageId}/sections.yml` - ); - const files = yaml.load(sectionsYml) as string[]; + const files = await getSectionsList(lang, page); const sections: MarkdownSection[] = []; for (const file of files) { - const raw = await readPublicFile(`docs/${lang}/${pageId}/${file}`); + const raw = await readPublicFile(`docs/${lang}/${page}/${file}`); if (file === "-intro.md") { // イントロセクションはフロントマターなし・見出しなし sections.push({ - id: `${lang}-${pageId}-intro`, + file, + id: introSectionId({ lang, page }), level: 1, title: "", rawContent: raw, + md5: crypto.createHash("md5").update(raw).digest("base64"), }); } else { sections.push(parseFrontmatter(raw, file)); @@ -119,6 +234,9 @@ export async function getMarkdownSections( } return sections; } +export function introSectionId(path: PagePath) { + return `${path.lang}-${path.page}-intro` as SectionId; +} /** * YAMLフロントマターをパースしてid, title, level, bodyを返す。 @@ -133,16 +251,49 @@ function parseFrontmatter(content: string, file: string): MarkdownSection { throw new Error(`File ${file} has invalid frontmatter`); } const fm = yaml.load(content.slice(4, endIdx)) as { - id?: string; - title?: string; - level?: number; + id: SectionId; + title: string; + level: number; }; // TODO: validation of frontmatter using zod - const rawContent = content.slice(endIdx + 5); + // replコードブロックにはセクションidをターミナルidとして与える。 + const rawContent = content + .slice(endIdx + 5) + .replace(/-repl\s*\n/, `-repl:${fm.id ?? ""}\n`); return { - id: fm?.id ?? "", - title: fm?.title ?? "", - level: fm?.level ?? 2, + file, + id: fm.id, + title: fm.title, + level: fm.level, rawContent, + md5: crypto.createHash("md5").update(rawContent).digest("base64"), }; } + +export async function getRevisionOfMarkdownSection( + sectionId: SectionId, + md5: string +): Promise { + const revisions = await getRevisions(sectionId); + const targetRevision = revisions?.rev.find((r) => r.md5 === md5); + if (targetRevision) { + const rawRes = await fetch( + `https://raw.githubusercontent.com/ut-code/my-code/${targetRevision.git}/${targetRevision.path}` + ); + if (rawRes.ok) { + const raw = await rawRes.text(); + return parseFrontmatter( + raw, + `${targetRevision.git}/${targetRevision.path}` + ); + } else { + throw new Error( + `Failed to fetch ${targetRevision.git}/${targetRevision.path}. ${rawRes.status}: ${await rawRes.text()}` + ); + } + } else { + throw new Error( + `Revision for sectionId=${sectionId}, md5=${md5} not found` + ); + } +} diff --git a/app/schema/chat.ts b/app/schema/chat.ts index 722071de..4aec2c5b 100644 --- a/app/schema/chat.ts +++ b/app/schema/chat.ts @@ -4,11 +4,15 @@ import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; export const chat = pgTable("chat", { chatId: uuid("chatId").primaryKey().defaultRandom(), userId: text("userId").notNull(), - docsId: text("docsId").notNull(), sectionId: text("sectionId").notNull(), createdAt: timestamp("createdAt").notNull().defaultNow(), }); +export const section = pgTable("section", { + sectionId: text("sectionId").primaryKey().notNull(), + pagePath: text("pagePath").notNull(), +}); + export const message = pgTable("message", { id: uuid("id").primaryKey().defaultRandom(), chatId: uuid("chatId").notNull(), @@ -17,8 +21,16 @@ export const message = pgTable("message", { createdAt: timestamp("createdAt").notNull().defaultNow(), }); -export const chatRelations = relations(chat, ({ many }) => ({ +export const chatRelations = relations(chat, ({ many, one }) => ({ messages: many(message), + section: one(section, { + fields: [chat.sectionId], + references: [section.sectionId], + }), +})); + +export const sectionRelations = relations(chat, ({ many }) => ({ + chat: many(chat), })); export const messageRelations = relations(message, ({ one }) => ({ diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 41cffbcd..618034ee 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LanguageEntry } from "@/lib/docs"; +import { LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs"; import { AccountMenu } from "./accountMenu"; import { ThemeToggle } from "./themeToggle"; import { @@ -18,11 +18,10 @@ import { LanguageIcon } from "./terminal/icons"; import { RuntimeLang } from "./terminal/runtime"; export interface ISidebarMdContext { - loadedDocsId: { lang: string; pageId: string } | null; + loadedPath: PagePath | null; sidebarMdContent: DynamicMarkdownSection[]; setSidebarMdContent: ( - lang: string, - pageId: string, + path: PagePath, content: | DynamicMarkdownSection[] | ((prev: DynamicMarkdownSection[]) => DynamicMarkdownSection[]) @@ -49,19 +48,15 @@ export function SidebarMdProvider({ children }: { children: ReactNode }) { const [sidebarMdContent, setSidebarMdContent_] = useState< DynamicMarkdownSection[] >([]); - const [loadedDocsId, setLoadedDocsId] = useState<{ - lang: string; - pageId: string; - } | null>(null); + const [loadedPath, setLoadedPath] = useState(null); const setSidebarMdContent = useCallback( ( - lang: string, - pageId: string, + path: PagePath, content: | DynamicMarkdownSection[] | ((prev: DynamicMarkdownSection[]) => DynamicMarkdownSection[]) ) => { - setLoadedDocsId({ lang, pageId }); + setLoadedPath(path); setSidebarMdContent_(content); }, [] @@ -69,7 +64,7 @@ export function SidebarMdProvider({ children }: { children: ReactNode }) { return ( statement-breakpoint +ALTER TABLE "chat" DROP COLUMN "docsId"; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..36573a4c --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,490 @@ +{ + "id": "192e6df7-07c4-4913-8572-9dc40b41050a", + "prevId": "3717650a-5e0e-4c3a-aa8f-d2ad0bef9bcc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sectionId": { + "name": "sectionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "sectionId": { + "name": "sectionId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pagePath": { + "name": "pagePath", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5fa7ab5c..5cc136f9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1764931132581, "tag": "0002_sad_mindworm", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1772895009610, + "tag": "0003_thin_ben_grimm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3da7831d..f4a56307 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,14 @@ "packages/*" ], "scripts": { - "dev": "npm run cf-typegen && npm run generateLanguages && npm run generateSections && npm run copyAllDTSFiles && npm run removeHinting && next dev", - "build": "npm run cf-typegen && npm run generateLanguages && npm run generateSections && npm run copyAllDTSFiles && npm run removeHinting && next build", + "prebuild": "npm run cf-typegen && tsx ./scripts/checkDocs.ts && tsx ./scripts/generateDocsMeta.ts && tsx ./scripts/copyAllDTSFiles.ts && tsx ./scripts/removeHinting.ts", + "dev": "npm run prebuild && next dev", + "build": "next build", "start": "next start", "lint": "npm run cf-typegen && next lint", "tsc": "npm run cf-typegen && tsc", - "format": "prettier --write app/", - "generateLanguages": "tsx ./scripts/generateLanguagesList.ts", - "generateSections": "tsx ./scripts/generateSectionsList.ts", - "copyAllDTSFiles": "tsx ./scripts/copyAllDTSFiles.ts", - "removeHinting": "tsx ./scripts/removeHinting.ts", + "format": "prettier --write app/ packages/", + "checkDocs": "tsx ./scripts/checkDocs.ts", "cf-preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview --port 3000", "cf-deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" diff --git a/public/docs/ruby/6-classes/3-0-accessor.md b/public/docs/ruby/6-classes/3-0-accessor.md index bde47b3e..7d5289df 100644 --- a/public/docs/ruby/6-classes/3-0-accessor.md +++ b/public/docs/ruby/6-classes/3-0-accessor.md @@ -1,6 +1,6 @@ --- id: ruby-classes-accessor -title: '🔐 アクセサメソッド +title: '🔐 アクセサメソッド' level: 2 --- diff --git a/scripts/checkDocs.ts b/scripts/checkDocs.ts new file mode 100644 index 00000000..0b8f5154 --- /dev/null +++ b/scripts/checkDocs.ts @@ -0,0 +1,137 @@ +/* +mainブランチにpushされた際にGitHub Actionが --write 引数をつけて実行し、 +その場合は public/docs/ 以下のドキュメントの各セクションについて、 +現在のパス、md5ハッシュ、コミットIDなどを ./public/docs/revisions.yml に追記 +セクションIDとページパスの対応関係をデータベースに反映 + +過去に存在したページが削除されている場合、エラーになります。 +その場合は手動でrevisions.ymlを編集し、古いページ名の記述を新しいページ名に置き換える必要がある +(できれば自動化したいが、いい方法が思いつかない) + +Dockerfile内で --check-diff 引数をつけて実行され、 +revisions.ymlが最新の状態でないならexit(1)をし、dockerのビルドを停止します + +なにも引数をつけずに実行した場合(npm run checkDocs)、 +変更があってもなにもせず正常終了します +エラーなく全ドキュメントを取得できるかどうかの確認に使います +*/ + +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + getMarkdownSections, + getPagesList, + RevisionYmlEntry, +} from "@/lib/docs"; +import yaml from "js-yaml"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { getDrizzle } from "@/lib/drizzle"; +import { section as sectionTable } from "@/schema/chat"; +import "dotenv/config"; + +let doWrite = false; +let doCheckDiff = false; +if (process.argv[2] === "--write") { + doWrite = true; +} else if (process.argv[2] === "--check-diff") { + doCheckDiff = true; +} else if (process.argv[2]) { + throw new Error(`Unknown arg: ${process.argv[2]}`); +} + +const docsDir = join(process.cwd(), "public", "docs"); + +const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], { + encoding: "utf8", +}).trim(); + +const langEntries = await getPagesList(); + +const revisionsPrevYml = existsSync(join(docsDir, "revisions.yml")) + ? await readFile(join(docsDir, "revisions.yml"), "utf-8") + : "{}"; +const revisions = yaml.load(revisionsPrevYml) as Record< + string, + RevisionYmlEntry +>; + +for (const lang of langEntries) { + for (const page of lang.pages) { + const sections = await getMarkdownSections(lang.id, page.slug); + for (const section of sections) { + if (section.id in revisions) { + revisions[section.id].page = `${lang.id}/${page.slug}`; + if (!revisions[section.id].rev.some((r) => r.md5 === section.md5)) { + // ドキュメントが変更された場合 + console.warn(`${section.id} has new md5: ${section.md5}`); + if (doWrite) { + revisions[section.id].rev.push({ + md5: section.md5, + git: commit, + path: `public/docs/${lang.id}/${page.slug}/${section.file}`, + }); + } else if (doCheckDiff) { + process.exit(1); + } + } + } else { + // ドキュメントが新規追加された場合 + console.warn(`${section.id} is new section`); + if (doWrite) { + revisions[section.id] = { + rev: [ + { + md5: section.md5, + git: commit, + path: `public/docs/${lang.id}/${page.slug}/${section.file}`, + }, + ], + page: `${lang.id}/${page.slug}`, + }; + } else if (doCheckDiff) { + process.exit(1); + } + } + } + } +} + +for (const id in revisions) { + if (!existsSync(join(docsDir, revisions[id].page))) { + console.warn( + `The page slug ${revisions[id].page} previously used by section ${id} does not exist. ` + + `Please replace 'page: ${revisions[id].page}' in public/docs/revisions.yml with new page path manually.` + ); + if (doWrite || doCheckDiff) { + process.exit(1); + } + } +} + +if (doWrite) { + const drizzle = await getDrizzle(); + for (const id in revisions) { + await drizzle + .insert(sectionTable) + .values({ + sectionId: id, + pagePath: revisions[id].page, + }) + .onConflictDoUpdate({ + target: sectionTable.sectionId, + set: { pagePath: revisions[id].page }, + }); + } + + const revisionsYml = yaml.dump(revisions, { + sortKeys: true, + noArrayIndent: true, + }); + await writeFile( + join(docsDir, "revisions.yml"), + "# This file will be updated by CI. Do not edit manually, unless CI failed.\n" + + revisionsYml, + "utf-8" + ); +} diff --git a/scripts/generateDocsMeta.ts b/scripts/generateDocsMeta.ts new file mode 100644 index 00000000..5650d519 --- /dev/null +++ b/scripts/generateDocsMeta.ts @@ -0,0 +1,31 @@ +// 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 { join } from "node:path"; +import { getPagesList, getSectionsList } from "@/lib/docs"; + +const docsDir = join(process.cwd(), "public", "docs"); + +const langEntries = await getPagesList(); + +const langIdsJson = JSON.stringify(langEntries.map((lang) => lang.id)); +await writeFile(join(docsDir, "languages.json"), langIdsJson, "utf-8"); +console.log( + `Generated languages.json (${langEntries.length} languages: ${langEntries.map((lang) => lang.id).join(", ")})` +); + +for (const lang of langEntries) { + for (const page of lang.pages) { + const files = await getSectionsList(lang.id, page.slug); + const filesJson = JSON.stringify(files); + await writeFile( + join(docsDir, lang.id, page.slug, "sections.json"), + filesJson, + "utf-8" + ); + console.log( + `Generated ${lang.id}/${page.slug}/sections.json (${files.length} files)` + ); + } +} diff --git a/scripts/generateLanguagesList.ts b/scripts/generateLanguagesList.ts deleted file mode 100644 index 7359ef7d..00000000 --- a/scripts/generateLanguagesList.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Generates public/docs/languages.yml listing all language directories. - -import { readdir, writeFile, stat } from "node:fs/promises"; -import { join } from "node:path"; -import yaml from "js-yaml"; - -const docsDir = join(process.cwd(), "public", "docs"); - -const entries = await readdir(docsDir); -const langIds: string[] = []; -for (const entry of entries) { - const entryPath = join(docsDir, entry); - if ((await stat(entryPath)).isDirectory()) { - langIds.push(entry); - } -} -langIds.sort(); - -const yamlContent = yaml.dump(langIds); -await writeFile(join(docsDir, "languages.yml"), yamlContent, "utf-8"); -console.log(`Generated languages.yml (${langIds.length} languages: ${langIds.join(", ")})`); diff --git a/scripts/generateSectionsList.ts b/scripts/generateSectionsList.ts deleted file mode 100644 index 6b294b33..00000000 --- a/scripts/generateSectionsList.ts +++ /dev/null @@ -1,47 +0,0 @@ -// 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 { readdir, writeFile, stat } from "node:fs/promises"; -import { join } from "node:path"; -import yaml from "js-yaml"; - -const docsDir = join(process.cwd(), "public", "docs"); - -function naturalSortMdFiles(a: string, b: string): number { - // -intro.md always comes first - if (a === "-intro.md") return -1; - if (b === "-intro.md") return 1; - // Sort numerically by leading N1-N2 prefix - const aMatch = a.match(/^(\d+)-(\d+)/); - const bMatch = b.match(/^(\d+)-(\d+)/); - if (aMatch && bMatch) { - const n1Diff = parseInt(aMatch[1]) - parseInt(bMatch[1]); - if (n1Diff !== 0) return n1Diff; - return parseInt(aMatch[2]) - parseInt(bMatch[2]); - } - return a.localeCompare(b); -} - -const langEntries = await readdir(docsDir); -for (const langId of langEntries) { - const langDir = join(docsDir, langId); - if (!(await stat(langDir)).isDirectory()) continue; - - const pageEntries = await readdir(langDir); - for (const pageId of pageEntries) { - // Only process page directories (start with a digit to skip index.yml and other metadata files) - if (!/^\d/.test(pageId)) continue; - const pageDir = join(langDir, pageId); - if (!(await stat(pageDir)).isDirectory()) continue; - - const files = (await readdir(pageDir)) - .filter((f) => f.endsWith(".md")) - .sort(naturalSortMdFiles); - - const yamlContent = yaml.dump(files); - await writeFile(join(pageDir, "sections.yml"), yamlContent, "utf-8"); - console.log( - `Generated ${langId}/${pageId}/sections.yml (${files.length} files)` - ); - } -}