Skip to content

Commit 1574c69

Browse files
authored
Merge pull request #178 from ut-code/docs-meta-script
ドキュメントのメタデータ生成をシンプルに & バージョン管理を追加
2 parents 2f30fb4 + d5fef5c commit 1574c69

File tree

8 files changed

+204
-84
lines changed

8 files changed

+204
-84
lines changed

.gitignore

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
/.open-next
44
/cloudflare-env.d.ts
55

6-
# generated docs section file lists (regenerated by npm run generateSections)
7-
/public/docs/**/sections.yml
8-
9-
# generated languages list (regenerated by npm run generateLanguages)
10-
/public/docs/languages.yml
6+
# generated docs section file lists (regenerated by npm run generateDocsMeta)
7+
/public/docs/**/sections.json
8+
/public/docs/languages.json
119

1210
# dependencies
1311
/node_modules

app/lib/docs.ts

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { join } from "node:path";
44
import yaml from "js-yaml";
55
import { isCloudflare } from "./detectCloudflare";
66
import { notFound } from "next/navigation";
7+
import crypto from "node:crypto";
78

89
export interface MarkdownSection {
10+
file: string; // ファイル名
911
id: string;
1012
level: number;
1113
title: string;
1214
rawContent: string; // 見出しも含めたもとのmarkdownの内容
15+
md5: string; // mdファイル全体のmd5
1316
}
1417

1518
export interface PageEntry {
@@ -36,6 +39,16 @@ interface IndexYml {
3639
}[];
3740
}
3841

42+
export interface RevisionYmlEntry {
43+
page: string;
44+
rev: SectionRevision[];
45+
}
46+
export interface SectionRevision {
47+
md5: string; // mdファイル全体のmd5
48+
git: string; // git上のコミットハッシュ
49+
path: string;
50+
}
51+
3952
async function readPublicFile(path: string): Promise<string> {
4053
try {
4154
if (isCloudflare()) {
@@ -59,8 +72,8 @@ async function readPublicFile(path: string): Promise<string> {
5972

6073
async function getLanguageIds(): Promise<string[]> {
6174
if (isCloudflare()) {
62-
const raw = await readPublicFile("docs/languages.yml");
63-
return yaml.load(raw) as string[];
75+
const raw = await readPublicFile("docs/languages.json");
76+
return JSON.parse(raw) as string[];
6477
} else {
6578
const docsDir = join(process.cwd(), "public", "docs");
6679
const entries = await readdir(docsDir, { withFileTypes: true });
@@ -90,28 +103,66 @@ export async function getPagesList(): Promise<LanguageEntry[]> {
90103
);
91104
}
92105

106+
export async function getSectionsList(
107+
lang: string,
108+
pageId: string
109+
): Promise<string[]> {
110+
if (isCloudflare()) {
111+
const sectionsJson = await readPublicFile(
112+
`docs/${lang}/${pageId}/sections.json`
113+
);
114+
return JSON.parse(sectionsJson) as string[];
115+
} else {
116+
function naturalSortMdFiles(a: string, b: string): number {
117+
// -intro.md always comes first
118+
if (a === "-intro.md") return -1;
119+
if (b === "-intro.md") return 1;
120+
// Sort numerically by leading N1-N2 prefix
121+
const aMatch = a.match(/^(\d+)-(\d+)/);
122+
const bMatch = b.match(/^(\d+)-(\d+)/);
123+
if (aMatch && bMatch) {
124+
const n1Diff = parseInt(aMatch[1]) - parseInt(bMatch[1]);
125+
if (n1Diff !== 0) return n1Diff;
126+
return parseInt(aMatch[2]) - parseInt(bMatch[2]);
127+
}
128+
return a.localeCompare(b);
129+
}
130+
return (await readdir(join(process.cwd(), "public", "docs", lang, pageId)))
131+
.filter((f) => f.endsWith(".md"))
132+
.sort(naturalSortMdFiles);
133+
}
134+
}
135+
136+
export async function getRevisions(
137+
sectionId: string
138+
): Promise<RevisionYmlEntry | undefined> {
139+
const revisionsYml = await readPublicFile(`docs/revisions.yml`);
140+
return (yaml.load(revisionsYml) as Record<string, RevisionYmlEntry>)[
141+
sectionId
142+
];
143+
}
144+
93145
/**
94146
* public/docs/{lang}/{pageId}/ 以下のmdファイルを結合して MarkdownSection[] を返す。
95147
*/
96148
export async function getMarkdownSections(
97149
lang: string,
98150
pageId: string
99151
): Promise<MarkdownSection[]> {
100-
const sectionsYml = await readPublicFile(
101-
`docs/${lang}/${pageId}/sections.yml`
102-
);
103-
const files = yaml.load(sectionsYml) as string[];
152+
const files = await getSectionsList(lang, pageId);
104153

105154
const sections: MarkdownSection[] = [];
106155
for (const file of files) {
107156
const raw = await readPublicFile(`docs/${lang}/${pageId}/${file}`);
108157
if (file === "-intro.md") {
109158
// イントロセクションはフロントマターなし・見出しなし
110159
sections.push({
160+
file,
111161
id: `${lang}-${pageId}-intro`,
112162
level: 1,
113163
title: "",
114164
rawContent: raw,
165+
md5: crypto.createHash("md5").update(raw).digest("base64"),
115166
});
116167
} else {
117168
sections.push(parseFrontmatter(raw, file));
@@ -143,9 +194,39 @@ function parseFrontmatter(content: string, file: string): MarkdownSection {
143194
.slice(endIdx + 5)
144195
.replace(/-repl\s*\n/, `-repl:${fm?.id ?? ""}\n`);
145196
return {
197+
file,
146198
id: fm?.id ?? "",
147199
title: fm?.title ?? "",
148200
level: fm?.level ?? 2,
149201
rawContent,
202+
md5: crypto.createHash("md5").update(rawContent).digest("base64"),
150203
};
151204
}
205+
206+
export async function getRevisionOfMarkdownSection(
207+
sectionId: string,
208+
md5: string
209+
): Promise<MarkdownSection> {
210+
const revisions = await getRevisions(sectionId);
211+
const targetRevision = revisions?.rev.find((r) => r.md5 === md5);
212+
if (targetRevision) {
213+
const rawRes = await fetch(
214+
`https://raw.githubusercontent.com/ut-code/my-code/${targetRevision.git}/${targetRevision.path}`
215+
);
216+
if (rawRes.ok) {
217+
const raw = await rawRes.text();
218+
return parseFrontmatter(
219+
raw,
220+
`${targetRevision.git}/${targetRevision.path}`
221+
);
222+
} else {
223+
throw new Error(
224+
`Failed to fetch ${targetRevision.git}/${targetRevision.path}. ${rawRes.status}: ${await rawRes.text()}`
225+
);
226+
}
227+
} else {
228+
throw new Error(
229+
`Revision for sectionId=${sectionId}, md5=${md5} not found`
230+
);
231+
}
232+
}

package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@
77
"packages/*"
88
],
99
"scripts": {
10-
"dev": "npm run cf-typegen && npm run generateLanguages && npm run generateSections && npm run copyAllDTSFiles && npm run removeHinting && next dev",
11-
"build": "npm run cf-typegen && npm run generateLanguages && npm run generateSections && npm run copyAllDTSFiles && npm run removeHinting && next build",
10+
"dev": "npm run cf-typegen && npm run generateDocsMeta && npm run copyAllDTSFiles && npm run removeHinting && next dev",
11+
"build": "npm run cf-typegen && npm run generateDocsMeta && npm run copyAllDTSFiles && npm run removeHinting && next build",
1212
"start": "next start",
1313
"lint": "npm run cf-typegen && next lint",
1414
"tsc": "npm run cf-typegen && tsc",
1515
"format": "prettier --write app/",
16-
"generateLanguages": "tsx ./scripts/generateLanguagesList.ts",
17-
"generateSections": "tsx ./scripts/generateSectionsList.ts",
16+
"generateDocsMeta": "tsx ./scripts/generateDocsMeta.ts",
1817
"copyAllDTSFiles": "tsx ./scripts/copyAllDTSFiles.ts",
1918
"removeHinting": "tsx ./scripts/removeHinting.ts",
2019
"cf-preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview --port 3000",

public/docs/ruby/6-classes/3-0-accessor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
id: ruby-classes-accessor
3-
title: '🔐 アクセサメソッド
3+
title: '🔐 アクセサメソッド'
44
level: 2
55
---
66

scripts/generateDocsMeta.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Generates public/docs/{lang}/{pageId}/sections.yml for each page directory.
2+
// Each sections.yml lists the .md files in that directory in display order.
3+
4+
import { writeFile } from "node:fs/promises";
5+
import { join } from "node:path";
6+
import { getPagesList, getSectionsList } from "@/lib/docs";
7+
8+
const docsDir = join(process.cwd(), "public", "docs");
9+
10+
const langEntries = await getPagesList();
11+
12+
const langIdsJson = JSON.stringify(langEntries.map((lang) => lang.id));
13+
await writeFile(join(docsDir, "languages.json"), langIdsJson, "utf-8");
14+
console.log(
15+
`Generated languages.json (${langEntries.length} languages: ${langEntries.map((lang) => lang.id).join(", ")})`
16+
);
17+
18+
for (const lang of langEntries) {
19+
for (const page of lang.pages) {
20+
const files = await getSectionsList(lang.id, page.slug);
21+
const filesJson = JSON.stringify(files);
22+
await writeFile(
23+
join(docsDir, lang.id, page.slug, "sections.json"),
24+
filesJson,
25+
"utf-8"
26+
);
27+
console.log(
28+
`Generated ${lang.id}/${page.slug}/sections.json (${files.length} files)`
29+
);
30+
}
31+
}

scripts/generateLanguagesList.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

scripts/generateSectionsList.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

scripts/updateDocsRevisions.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { readFile, writeFile } from "node:fs/promises";
2+
import { join } from "node:path";
3+
import {
4+
getMarkdownSections,
5+
getPagesList,
6+
RevisionYmlEntry,
7+
} from "@/lib/docs";
8+
import yaml from "js-yaml";
9+
import { execFileSync } from "node:child_process";
10+
import { existsSync } from "node:fs";
11+
12+
const docsDir = join(process.cwd(), "public", "docs");
13+
14+
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
15+
encoding: "utf8",
16+
}).trim();
17+
18+
const langEntries = await getPagesList();
19+
20+
const revisionsPrevYml = existsSync(join(docsDir, "revisions.yml"))
21+
? await readFile(join(docsDir, "revisions.yml"), "utf-8")
22+
: "{}";
23+
const revisions = yaml.load(revisionsPrevYml) as Record<
24+
string,
25+
RevisionYmlEntry
26+
>;
27+
28+
for (const lang of langEntries) {
29+
for (const page of lang.pages) {
30+
const sections = await getMarkdownSections(lang.id, page.slug);
31+
for (const section of sections) {
32+
if (section.id in revisions) {
33+
revisions[section.id].page = `${lang.id}/${page.slug}`;
34+
if (!revisions[section.id].rev.some((r) => r.md5 === section.md5)) {
35+
// ドキュメントが変更された場合
36+
console.log(`${section.id} has new md5: ${section.md5}`);
37+
revisions[section.id].rev.push({
38+
md5: section.md5,
39+
git: commit,
40+
path: `public/docs/${lang.id}/${page.slug}/${section.file}`,
41+
});
42+
}
43+
} else {
44+
// ドキュメントが新規追加された場合
45+
console.log(`${section.id} is new, adding to revisions.yml`);
46+
revisions[section.id] = {
47+
rev: [
48+
{
49+
md5: section.md5,
50+
git: commit,
51+
path: `public/docs/${lang.id}/${page.slug}/${section.file}`,
52+
},
53+
],
54+
page: `${lang.id}/${page.slug}`,
55+
};
56+
}
57+
}
58+
}
59+
}
60+
61+
for (const id in revisions) {
62+
if (!existsSync(join(docsDir, revisions[id].page))) {
63+
throw new Error(
64+
`The page slug ${revisions[id].page} previously used by section ${id} does not exist. ` +
65+
`Please replace 'page: ${revisions[id].page}' in public/docs/revisions.yml with new page path manually.`
66+
);
67+
}
68+
}
69+
70+
const revisionsYml = yaml.dump(revisions, {
71+
sortKeys: true,
72+
noArrayIndent: true,
73+
});
74+
await writeFile(
75+
join(docsDir, "revisions.yml"),
76+
"# This file will be updated by CI. Do not edit manually, unless CI failed.\n" +
77+
revisionsYml,
78+
"utf-8"
79+
);

0 commit comments

Comments
 (0)