Skip to content

Commit 925350d

Browse files
aster-voidclaude
andcommitted
fix(migration): フラット .md ファイル対応と slug 計算修正
旧 utcode.net の projects コンテンツは 2 形式混在: - A: projects/<slug>/index.md (例: coursemate/index.md) - B: projects/hackathon/<date>/<slug>.md (例: hackathon/2023-08-17/call-paper.md) `findMarkdownFiles` が index.md/index.mdx 縛りで形式 B 9 件 (call-paper, denigma, music-app, todo, bowling, shift-syncer, typing-script, gemmit, hack-shooter) を silent に取りこぼしていた。articles 側の挙動を維持するため pattern オプション ("index" | "all") を追加し、projects/images だけ "all" に。 slug 計算 `basename(dirname(file))` も形式 B では日付になり衝突するため、 ファイル名が index.{md,mdx} なら親ディレクトリ名、それ以外なら拡張子除いた ファイル名、という分岐を `deriveProjectSlug` に切り出した。 migrate-images.server.ts にも同じ 2 つのバグがあったので併せて修正。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c590cb6 commit 925350d

4 files changed

Lines changed: 95 additions & 9 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { deriveProjectSlug } from "./helpers.server";
3+
4+
describe("deriveProjectSlug", () => {
5+
// Form A: <slug>/index.md
6+
test("returns parent dir name for index.md", () => {
7+
expect(deriveProjectSlug("/repo/contents/projects/coursemate/index.md")).toBe("coursemate");
8+
});
9+
10+
test("returns parent dir name for index.mdx", () => {
11+
expect(deriveProjectSlug("/repo/contents/projects/coursemate/index.mdx")).toBe("coursemate");
12+
});
13+
14+
// Form B: hackathon/<date>/<slug>.md
15+
test("returns file basename for flat .md (regression: 9 projects silently skipped)", () => {
16+
expect(deriveProjectSlug("/repo/contents/projects/hackathon/2023-08-17/call-paper.md")).toBe(
17+
"call-paper",
18+
);
19+
});
20+
21+
test("returns file basename for flat .mdx", () => {
22+
expect(deriveProjectSlug("/repo/contents/projects/hackathon/2024-01-01/hack-shooter.mdx")).toBe(
23+
"hack-shooter",
24+
);
25+
});
26+
27+
test("disambiguates siblings under same date dir", () => {
28+
const a = deriveProjectSlug("/repo/contents/projects/hackathon/2023-08-17/denigma.md");
29+
const b = deriveProjectSlug("/repo/contents/projects/hackathon/2023-08-17/music-app.md");
30+
expect(a).toBe("denigma");
31+
expect(b).toBe("music-app");
32+
expect(a).not.toBe(b);
33+
});
34+
});

src/lib/server/services/migration/helpers.server.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Helper functions for migration
33
*/
44
import { readdir, stat } from "node:fs/promises";
5-
import { extname, join } from "node:path";
5+
import { basename, dirname, extname, join } from "node:path";
66
import * as v from "valibot";
77
import { parse as parseYaml } from "yaml";
88
import type { ProjectCategory } from "$lib/shared/models/schema";
@@ -24,7 +24,20 @@ export function parseFrontmatter<T>(
2424
return { frontmatter, body: bodyStr.trim() };
2525
}
2626

27-
export async function findMarkdownFiles(basePath: string): Promise<string[]> {
27+
/**
28+
* Walk `basePath` and collect markdown files.
29+
*
30+
* - `pattern: "index"` (default): only `index.md` / `index.mdx`. Used for the
31+
* directory-per-entry layout (members, articles, project form A like
32+
* `coursemate/index.md`).
33+
* - `pattern: "all"`: every `.md` / `.mdx`. Used for project form B
34+
* (`hackathon/<date>/<slug>.md`) where multiple flat files share a parent
35+
* directory. See migrate-projects.server.ts for slug derivation.
36+
*/
37+
export async function findMarkdownFiles(
38+
basePath: string,
39+
pattern: "index" | "all" = "index",
40+
): Promise<string[]> {
2841
const files: string[] = [];
2942
async function walk(dir: string): Promise<void> {
3043
try {
@@ -33,7 +46,7 @@ export async function findMarkdownFiles(basePath: string): Promise<string[]> {
3346
const fullPath = join(dir, entry.name);
3447
if (entry.isDirectory()) {
3548
await walk(fullPath);
36-
} else if (entry.name === "index.md" || entry.name === "index.mdx") {
49+
} else if (matchesPattern(entry.name, pattern)) {
3750
files.push(fullPath);
3851
}
3952
}
@@ -45,6 +58,36 @@ export async function findMarkdownFiles(basePath: string): Promise<string[]> {
4558
return files.sort();
4659
}
4760

61+
function matchesPattern(name: string, pattern: "index" | "all"): boolean {
62+
switch (pattern) {
63+
case "index":
64+
return name === "index.md" || name === "index.mdx";
65+
case "all":
66+
return name.endsWith(".md") || name.endsWith(".mdx");
67+
default:
68+
return pattern satisfies never;
69+
}
70+
}
71+
72+
/**
73+
* Derive project slug from a markdown file path.
74+
*
75+
* Legacy content has two layouts:
76+
* - Form A: `<slug>/index.md` (e.g. `coursemate/index.md`) -> parent dir name
77+
* - Form B: `hackathon/<date>/<slug>.md` (e.g. `hackathon/2023-08-17/call-paper.md`) -> file basename
78+
*
79+
* Previously slug was always `basename(dirname(file))`, which collapsed all
80+
* Form B siblings under one date dir into the same slug and silently skipped
81+
* 9 projects on prod migration.
82+
*/
83+
export function deriveProjectSlug(file: string): string {
84+
const filename = basename(file);
85+
if (filename === "index.md" || filename === "index.mdx") {
86+
return basename(dirname(file));
87+
}
88+
return basename(file, extname(file));
89+
}
90+
4891
export function generateArticleSlug(dirPath: string): string {
4992
const parts = dirPath.split("/");
5093
const year = parts.at(-2) ?? "";

src/lib/server/services/migration/migrate-images.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { db } from "$lib/server/drivers/db";
1111
import { article, member, project } from "$lib/shared/models/schema";
1212
import type { MigrationResult } from "$lib/shared/types/migration";
1313
import {
14+
deriveProjectSlug,
1415
fileExists,
1516
findMarkdownFiles,
1617
generateArticleSlug,
@@ -165,11 +166,13 @@ export async function migrateImages(repoPath: string, log: Logger): Promise<Migr
165166
// Project images
166167
log("Processing project images...");
167168
const projectsPath = join(repoPath, "contents/projects");
168-
const projectFiles = await findMarkdownFiles(projectsPath);
169+
// Match migrate-projects.server.ts: walk all .md/.mdx so flat layouts
170+
// (e.g. hackathon/<date>/<slug>.md) are picked up too.
171+
const projectFiles = await findMarkdownFiles(projectsPath, "all");
169172

170173
for (const file of projectFiles) {
171174
const dirPath = dirname(file);
172-
const slug = basename(dirPath);
175+
const slug = deriveProjectSlug(file);
173176

174177
try {
175178
const content = await readFile(file, "utf-8");

src/lib/server/services/migration/migrate-projects.server.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@
22
* Project migration worker
33
*/
44
import { readFile } from "node:fs/promises";
5-
import { basename, dirname, join } from "node:path";
5+
import { dirname, join } from "node:path";
66
import { eq } from "drizzle-orm";
77
import { db } from "$lib/server/drivers/db";
88
import { member, project, projectMember } from "$lib/shared/models/schema";
99
import type { MigrationResult } from "$lib/shared/types/migration";
10-
import { findMarkdownFiles, type Logger, mapCategory, parseFrontmatter } from "./helpers.server";
10+
import {
11+
deriveProjectSlug,
12+
findMarkdownFiles,
13+
type Logger,
14+
mapCategory,
15+
parseFrontmatter,
16+
} from "./helpers.server";
1117
import { processContentImages } from "./image-processor.server";
1218
import { ProjectFrontmatterSchema } from "./schemas.server";
1319

1420
export async function migrateProjects(repoPath: string, log: Logger): Promise<MigrationResult> {
1521
log("--- Migrating Projects ---");
1622
const projectsPath = join(repoPath, "contents/projects");
17-
const files = await findMarkdownFiles(projectsPath);
23+
const files = await findMarkdownFiles(projectsPath, "all");
1824
log(`Found ${files.length} project files`);
1925

2026
let created = 0;
@@ -23,7 +29,7 @@ export async function migrateProjects(repoPath: string, log: Logger): Promise<Mi
2329

2430
for (const file of files) {
2531
const dirPath = dirname(file);
26-
const slug = basename(dirPath);
32+
const slug = deriveProjectSlug(file);
2733

2834
try {
2935
const content = await readFile(file, "utf-8");

0 commit comments

Comments
 (0)