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/app/lib/docs.ts b/app/lib/docs.ts index a59c81c6..f51d0732 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -4,12 +4,15 @@ import { join } from "node:path"; import yaml from "js-yaml"; import { isCloudflare } from "./detectCloudflare"; import { notFound } from "next/navigation"; +import crypto from "node:crypto"; export interface MarkdownSection { + file: string; // ファイル名 id: string; level: number; title: string; rawContent: string; // 見出しも含めたもとのmarkdownの内容 + md5: string; // mdファイル全体のmd5 } export interface PageEntry { @@ -36,6 +39,17 @@ interface IndexYml { }[]; } +export interface RevisionYmlEntry { + lang?: string; + page?: string; + rev: SectionRevision[]; +} +export interface SectionRevision { + md5: string; // mdファイル全体のmd5 + commit: string; + path: string; +} + async function readPublicFile(path: string): Promise { try { if (isCloudflare()) { @@ -59,8 +73,8 @@ async function readPublicFile(path: string): 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 string[]; } else { const docsDir = join(process.cwd(), "public", "docs"); const entries = await readdir(docsDir, { withFileTypes: true }); @@ -90,6 +104,45 @@ export async function getPagesList(): Promise { ); } +export async function getSectionsList( + lang: string, + pageId: string +): Promise { + if (isCloudflare()) { + const sectionsJson = await readPublicFile( + `docs/${lang}/${pageId}/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, pageId))) + .filter((f) => f.endsWith(".md")) + .sort(naturalSortMdFiles); + } +} + +export async function getRevisions( + sectionId: string +): Promise { + const revisionsYml = await readPublicFile(`docs/revisions.yml`); + return (yaml.load(revisionsYml) as Record)[ + sectionId + ]; +} + /** * public/docs/{lang}/{pageId}/ 以下のmdファイルを結合して MarkdownSection[] を返す。 */ @@ -97,10 +150,7 @@ export async function getMarkdownSections( lang: string, pageId: string ): Promise { - const sectionsYml = await readPublicFile( - `docs/${lang}/${pageId}/sections.yml` - ); - const files = yaml.load(sectionsYml) as string[]; + const files = await getSectionsList(lang, pageId); const sections: MarkdownSection[] = []; for (const file of files) { @@ -108,10 +158,12 @@ export async function getMarkdownSections( if (file === "-intro.md") { // イントロセクションはフロントマターなし・見出しなし sections.push({ + file, id: `${lang}-${pageId}-intro`, level: 1, title: "", rawContent: raw, + md5: "", }); } else { sections.push(parseFrontmatter(raw, file)); @@ -140,9 +192,39 @@ function parseFrontmatter(content: string, file: string): MarkdownSection { // TODO: validation of frontmatter using zod const rawContent = content.slice(endIdx + 5); return { + file, id: fm?.id ?? "", title: fm?.title ?? "", level: fm?.level ?? 2, rawContent, + md5: crypto.createHash("md5").update(content).digest("base64"), }; } + +export async function getRevisionOfMarkdownSection( + sectionId: string, + 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.commit}/${targetRevision.path}` + ); + if (rawRes.ok) { + const raw = await rawRes.text(); + return parseFrontmatter( + raw, + `${targetRevision.commit}/${targetRevision.path}` + ); + } else { + throw new Error( + `Failed to fetch ${targetRevision.commit}/${targetRevision.path}. ${rawRes.status}: ${await rawRes.text()}` + ); + } + } else { + throw new Error( + `Revision for sectionId=${sectionId}, md5=${md5} not found` + ); + } +} diff --git a/package.json b/package.json index 963fbb91..b6bbbf95 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,13 @@ "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", + "dev": "npm run cf-typegen && npm run generateDocsMeta && npm run copyAllDTSFiles && npm run removeHinting && next dev", + "build": "npm run cf-typegen && npm run generateDocsMeta && npm run copyAllDTSFiles && npm run removeHinting && 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", + "generateDocsMeta": "tsx ./scripts/generateDocsMeta.ts", "copyAllDTSFiles": "tsx ./scripts/copyAllDTSFiles.ts", "removeHinting": "tsx ./scripts/removeHinting.ts", "cf-preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview --port 3000", 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/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)` - ); - } -} diff --git a/scripts/updateDocsRevisions.ts b/scripts/updateDocsRevisions.ts new file mode 100644 index 00000000..c5b6786b --- /dev/null +++ b/scripts/updateDocsRevisions.ts @@ -0,0 +1,75 @@ +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"; + +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 id in revisions) { + delete revisions[id].lang; + delete revisions[id].page; +} + +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.file === "-intro.md") continue; + if (section.id in revisions) { + revisions[section.id].lang = lang.id; + revisions[section.id].page = page.slug; + if (!revisions[section.id].rev.some((r) => r.md5 === section.md5)) { + // ドキュメントが変更された場合 + console.log(`${section.id} has new md5: ${section.md5}`); + revisions[section.id].rev.push({ + md5: section.md5, + commit, + path: `public/docs/${lang.id}/${page.slug}/${section.file}`, + }); + } + } else { + // ドキュメントが新規追加された場合 + console.log(`${section.id} is new, adding to revisions.yml`); + revisions[section.id] = { + rev: [ + { + md5: section.md5, + commit, + path: `public/docs/${lang.id}/${page.slug}/${section.file}`, + }, + ], + lang: lang.id, + page: page.slug, + }; + } + } + } +} + +const revisionsYml = yaml.dump(revisions); +await writeFile( + join(docsDir, "revisions.yml"), + "# This file will be updated by scripts/updateDocsRevisions.ts. Do not edit manually.\n" + + revisionsYml, + "utf-8" +);