Skip to content

Commit 4a3c54a

Browse files
authored
Merge pull request #1022 from seocylucky/fix/#1021-fix-locales-error
fix: 에셋 경로 해석 공통화(assets resolver), 템플릿/로케일 리졸버 적용
2 parents a75c7ce + 4fbaf66 commit 4a3c54a

File tree

8 files changed

+183
-84
lines changed

8 files changed

+183
-84
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
// 파일 위치 기준 dirname 조회
6+
export function getDirname(metaUrl?: string): string {
7+
// CJS일 떄
8+
if (typeof __dirname === "string") return __dirname as unknown as string;
9+
10+
// ESM일 때
11+
if (metaUrl) {
12+
try {
13+
return path.dirname(fileURLToPath(metaUrl));
14+
} catch {}
15+
}
16+
return process.cwd();
17+
}
18+
19+
// 패키지 루트 탐색
20+
export function findPackageRoot(startDir: string): string {
21+
let dir = startDir;
22+
while (true) {
23+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
24+
const parent = path.dirname(dir);
25+
if (parent === dir) return startDir;
26+
dir = parent;
27+
}
28+
}
29+
30+
// 디렉토리 탐색 옵션
31+
type ResolveDirOpts = {
32+
/** 최우선 ENV 이름 (예: GITHRU_LOCALES_DIR / GITHRU_TEMPLATES_DIR) */
33+
envVar?: string;
34+
/** 호출 파일의 위치(ESM일 때) */
35+
callerMetaUrl?: string;
36+
/** 호출 파일의 위치(CJS일 때) */
37+
callerDirname?: string;
38+
/** 패키지 루트(없으면 callerDirname에서 자동 탐색) */
39+
packageRoot?: string;
40+
/** 모듈(현재 파일) 기준 상대경로: ["resources/locales"] */
41+
moduleAnchors?: string[];
42+
/** 패키지 루트 기준 상대경로: ["dist/resources/locales", "resources/locales", "src/resources/locales"] */
43+
packageAnchors?: string[];
44+
/** 존재 확인용 필수 파일: ["en.json"] */
45+
requiredFiles: string[];
46+
// require.resolve로 위치 역추적 옵션
47+
tryRequireResolve?: { request: string; bases?: string[] };
48+
/** 디버그 로그 토글 ENV */
49+
debugEnvVar?: string;
50+
};
51+
52+
// 디렉토리 위치 탐색
53+
export function resolveAssetDir(opts: ResolveDirOpts): string {
54+
const {
55+
envVar,
56+
callerMetaUrl,
57+
callerDirname = getDirname(),
58+
packageRoot = findPackageRoot(callerDirname),
59+
moduleAnchors = [],
60+
packageAnchors = [],
61+
requiredFiles,
62+
tryRequireResolve,
63+
debugEnvVar,
64+
} = opts;
65+
66+
const debug = debugEnvVar && process.env[debugEnvVar] === "1";
67+
68+
// 0) ENV 최우선
69+
const envDir = envVar ? process.env[envVar]?.trim() : undefined;
70+
71+
// 1) require.resolve(CJS) 역추적
72+
let resolvedByRequire: string | undefined;
73+
if (tryRequireResolve?.request) {
74+
try {
75+
const bases = tryRequireResolve.bases ?? [callerDirname, packageRoot, process.cwd()];
76+
for (const b of bases) {
77+
const p = require.resolve(tryRequireResolve.request, { paths: [b] as any });
78+
resolvedByRequire = path.dirname(p);
79+
break;
80+
}
81+
} catch {}
82+
}
83+
84+
// 2) 모듈 기준 후보
85+
const moduleCandidates = moduleAnchors.map((a) => path.resolve(callerDirname, a));
86+
// 3) 패키지 루트 기준 후보
87+
const pkgCandidates = packageAnchors.map((a) => path.resolve(packageRoot, a));
88+
89+
const candidates = [envDir, resolvedByRequire, ...moduleCandidates, ...pkgCandidates].filter(Boolean) as string[];
90+
91+
for (const dir of candidates) {
92+
const ok = requiredFiles.some((f) => fs.existsSync(path.join(dir, f)));
93+
if (ok) {
94+
if (debug) console.error(`[asset] resolved: ${dir}`);
95+
return dir;
96+
}
97+
}
98+
99+
throw new Error("Cannot locate asset directory. Tried:\n" + candidates.map((p) => ` - ${p}`).join("\n"));
100+
}
101+
102+
// 파일 접근 함수 생성
103+
export function makeAssetResolver(baseDir: string) {
104+
return {
105+
baseDir,
106+
path: (...parts: string[]) => path.join(baseDir, ...parts),
107+
readText: (file: string, enc: BufferEncoding = "utf8") => fs.readFileSync(path.join(baseDir, file), enc),
108+
exists: (file: string) => fs.existsSync(path.join(baseDir, file)),
109+
};
110+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { resolveAssetDir, makeAssetResolver, getDirname } from "./assetResolver.js";
2+
3+
// HTML 템플릿 디렉토리 위치 탐색
4+
const TEMPLATES_DIR = resolveAssetDir({
5+
envVar: "GITHRU_TEMPLATES_DIR",
6+
callerDirname: getDirname(),
7+
moduleAnchors: ["html", "../html"],
8+
packageAnchors: ["dist/html", "html", "src/html"],
9+
requiredFiles: [
10+
"contributors-chart.html",
11+
"feature-impact.html",
12+
"author-work-pattern.html",
13+
"no-contributors.html",
14+
"error-chart.html",
15+
],
16+
});
17+
18+
export const htmlAssets = makeAssetResolver(TEMPLATES_DIR);

packages/mcp/src/common/i18n.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,15 @@
1-
import * as fs from "fs";
2-
import * as path from "path";
3-
4-
import { getDirname } from "./utils.js";
5-
6-
const __dirname = getDirname();
7-
8-
function findLocalesDir(): string {
9-
const cands = [
10-
path.resolve(__dirname, "../resources/locales"),
11-
path.resolve(__dirname, "../../resources/locales"),
12-
path.resolve(process.cwd(), "dist/resources/locales"),
13-
path.resolve(process.cwd(), "src/resources/locales"),
14-
path.resolve(process.cwd(), "resources/locales"),
15-
];
16-
for (const p of cands) {
17-
if (fs.existsSync(path.join(p, "en.json"))) return p;
18-
}
19-
throw new Error("Cannot locate locales directory. Tried:\n" + cands.map((p) => " - " + p).join("\n"));
20-
}
1+
import { getDirname, makeAssetResolver, resolveAssetDir } from "./assetResolver.js";
2+
3+
const LOCALES_DIR = resolveAssetDir({
4+
envVar: "GITHRU_LOCALES_DIR",
5+
callerDirname: getDirname(),
6+
moduleAnchors: ["resources/locales"],
7+
packageAnchors: ["dist/resources/locales", "resources/locales", "src/resources/locales"],
8+
requiredFiles: ["en.json"],
9+
tryRequireResolve: { request: "./resources/locales/en.json" },
10+
});
2111

22-
const LOCALES_DIR = findLocalesDir();
12+
const locales = makeAssetResolver(LOCALES_DIR);
2313

2414
class I18nManager {
2515
private currentLocale = "en";
@@ -32,8 +22,7 @@ class I18nManager {
3222

3323
private loadFallback() {
3424
try {
35-
const fallbackPath = path.join(LOCALES_DIR, "en.json");
36-
const fallbackData = fs.readFileSync(fallbackPath, "utf-8");
25+
const fallbackData = locales.readText("en.json");
3726
this.fallbackTranslations = JSON.parse(fallbackData);
3827
this.translations = this.fallbackTranslations;
3928
} catch (error) {
@@ -52,8 +41,7 @@ class I18nManager {
5241
}
5342

5443
try {
55-
const localePath = path.join(LOCALES_DIR, `${locale}.json`);
56-
const localeData = fs.readFileSync(localePath, "utf-8");
44+
const localeData = locales.readText(`${locale}.json`);
5745
this.translations = JSON.parse(localeData);
5846
} catch (error) {
5947
console.error(`Locale '${locale}' not found, using English fallback`);

packages/mcp/src/common/utils.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import relativeTime from "dayjs/plugin/relativeTime.js";
44
import customParseFormat from "dayjs/plugin/customParseFormat.js";
55
import * as cp from "child_process";
66
import type { GitHubRepoInfo } from "./types.js";
7-
import path from "node:path";
8-
import { fileURLToPath } from "node:url";
97

108
dayjs.extend(relativeTime);
119
dayjs.extend(customParseFormat);
@@ -280,21 +278,3 @@ export const CommonUtils = {
280278
return isNaN(parsed) ? 0 : parsed;
281279
},
282280
};
283-
284-
export function getFilename(): string {
285-
if (typeof __filename !== "undefined") return __filename;
286-
try {
287-
const metaUrl = (0, eval)("import.meta.url");
288-
if (metaUrl) return fileURLToPath(metaUrl);
289-
} catch {}
290-
return path.join(process.cwd(), "index.js");
291-
}
292-
293-
export function getDirname(): string {
294-
if (typeof __dirname !== "undefined") return __dirname;
295-
try {
296-
const metaUrl = (0, eval)("import.meta.url");
297-
if (metaUrl) return path.dirname(fileURLToPath(metaUrl));
298-
} catch {}
299-
return process.cwd();
300-
}

packages/mcp/src/core/authorWorkPattern.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import * as fs from "fs/promises";
2-
import * as path from "path";
3-
import { getDirname } from "../common/utils.js";
42
import type { RestEndpointMethodTypes } from "@octokit/rest";
53
import { GitHubUtils } from "../common/utils.js";
64
import { I18n } from "../common/i18n.js";
7-
import { Config } from "../common/config.js";
8-
9-
const __dirname = getDirname();
5+
import { htmlAssets } from "../common/htmlAssets.js";
106

117
type CommitListItem = RestEndpointMethodTypes["repos"]["listCommits"]["response"]["data"][number];
128
type GetCommitResponse = RestEndpointMethodTypes["repos"]["getCommit"]["response"]["data"];
@@ -47,6 +43,12 @@ function classifyType(msg?: string | null): CommitType {
4743
return "other";
4844
}
4945

46+
const htmlEscape = (s: string) =>
47+
String(s ?? "").replace(
48+
/[&<>"']/g,
49+
(ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] as string
50+
);
51+
5052
export class AuthorWorkPatternAnalyzer {
5153
private owner: string;
5254
private repo: string;
@@ -106,14 +108,13 @@ export class AuthorWorkPatternAnalyzer {
106108
const details: GetCommitResponse[] = await Promise.all(
107109
picked.map(async (c: CommitListItem) => {
108110
const resp = await safeApiCall(() =>
109-
octokit.repos.getCommit({
111+
GitHubUtils.createGitHubAPIClient(this.githubToken).repos.getCommit({
110112
owner: this.owner,
111113
repo: this.repo,
112114
ref: c.sha,
113115
})
114116
);
115-
const { data } = resp;
116-
return data as GetCommitResponse;
117+
return resp.data as GetCommitResponse;
117118
})
118119
);
119120

@@ -155,7 +156,7 @@ export class AuthorWorkPatternAnalyzer {
155156
async generateReport(payload: Awaited<ReturnType<AuthorWorkPatternAnalyzer["analyze"]>>) {
156157
I18n.setLocale(this.locale);
157158

158-
const tplPath = path.join(__dirname, "../html/author-work-pattern.html");
159+
const tplPath = htmlAssets.path("author-work-pattern.html");
159160
const exists = await fs
160161
.access(tplPath)
161162
.then(() => true)
@@ -170,17 +171,22 @@ export class AuthorWorkPatternAnalyzer {
170171
const from = new Date(payload.period.from).toISOString().slice(0, 10);
171172
const to = new Date(payload.period.to).toISOString().slice(0, 10);
172173

173-
const notes = [
174-
I18n.t("notes.author", { author: payload.author }),
175-
I18n.t("notes.repo", { repo: payload.repo }),
176-
I18n.t("notes.period", { from, to }),
177-
].join(" · ");
174+
const titleEsc = htmlEscape(`Author Work Pattern · ${payload.repo} · ${payload.author}`);
175+
const notesEsc = htmlEscape(
176+
[
177+
I18n.t("notes.author", { author: payload.author }),
178+
I18n.t("notes.repo", { repo: payload.repo }),
179+
I18n.t("notes.period", { from, to }),
180+
].join(" · ")
181+
);
178182

179183
const noDataText = I18n.t("messages.no_data");
180184
const typeRows =
181185
payload.typeMix.length === 0
182-
? `<tr><td colspan="2" class="val-right" style="color:#777;">${noDataText}</td></tr>`
183-
: payload.typeMix.map((t) => `<tr><td>${t.label}</td><td class="val-right">${t.value}</td></tr>`).join("");
186+
? `<tr><td colspan="2" class="val-right" style="color:#777;">${htmlEscape(noDataText)}</td></tr>`
187+
: payload.typeMix
188+
.map((t) => `<tr><td>${htmlEscape(t.label)}</td><td class="val-right">${t.value}</td></tr>`)
189+
.join("");
184190

185191
const barLabelsJson = JSON.stringify(["Commits", "Churn"]);
186192
const barValuesJson = JSON.stringify([payload.metrics.commits, payload.metrics.churn]);
@@ -201,19 +207,20 @@ export class AuthorWorkPatternAnalyzer {
201207

202208
let html = await fs.readFile(tplPath, "utf8");
203209
html = html
204-
.replaceAll("{{TITLE}}", `Author Work Pattern · ${payload.repo} · ${payload.author}`)
205-
.replaceAll("{{NOTES}}", notes)
210+
.replaceAll("{{TITLE}}", titleEsc)
211+
.replaceAll("{{NOTES}}", notesEsc)
206212
.replaceAll("{{COMMITS}}", String(payload.metrics.commits))
207213
.replaceAll("{{INSERTIONS}}", String(payload.metrics.insertions))
208214
.replaceAll("{{DELETIONS}}", String(payload.metrics.deletions))
209215
.replaceAll("{{CHURN}}", String(payload.metrics.churn))
210-
.replaceAll("{{BRANCH}}", payload.branch)
216+
.replaceAll("{{BRANCH}}", htmlEscape(payload.branch))
211217
.replaceAll("{{TYPE_TABLE_ROWS}}", typeRows)
212218
.replaceAll("{{BAR_LABELS_JSON}}", barLabelsJson)
213219
.replaceAll("{{BAR_VALUES_JSON}}", barValuesJson)
214220
.replaceAll("{{DONUT_LABELS_JSON}}", donutLabelsJson)
215221
.replaceAll("{{DONUT_VALUES_JSON}}", donutValuesJson)
216-
.replaceAll("{{DONUT_COLORS_JSON}}", donutColorsJson);
222+
.replaceAll("{{DONUT_COLORS_JSON}}", donutColorsJson)
223+
.replaceAll("</script>", "<\\/script>");
217224

218225
return { content: [{ type: "text" as const, text: html }] };
219226
}

packages/mcp/src/core/contributorRecommender.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { Octokit } from "@octokit/rest";
22
import type { RestEndpointMethodTypes } from "@octokit/rest";
33
import * as fs from "fs";
4-
import * as path from "path";
5-
import { getDirname } from "../common/utils.js";
64
import { GitHubUtils, CommonUtils } from "../common/utils.js";
75
import { I18n } from "../common/i18n.js";
86
import type { ContributorRecommenderInputs, ContributorCandidate, ContributorRecommendation } from "../common/types.js";
9-
import { Config } from "../common/config.js";
7+
import { htmlAssets } from "../common/htmlAssets.js";
8+
import { getDirname } from "../common/assetResolver.js";
109

1110
const __dirname = getDirname();
1211

@@ -244,17 +243,15 @@ export class ContributorRecommender {
244243

245244
try {
246245
if (candidates.length === 0) {
247-
const templatePath = path.join(__dirname, "../html/no-contributors.html");
248-
let template = fs.readFileSync(templatePath, "utf8");
246+
let template = fs.readFileSync(htmlAssets.path("no-contributors.html"), "utf8");
249247

250248
const notesHtml = notes.map((note) => `<p style="color: #666; font-size: 14px;">📝 ${note}</p>`).join("");
251249
template = template.replace("{{NOTES}}", notesHtml);
252250

253251
return template;
254252
}
255253

256-
const templatePath = path.join(__dirname, "../html/contributors-chart.html");
257-
let template = fs.readFileSync(templatePath, "utf8");
254+
let template = fs.readFileSync(htmlAssets.path("contributors-chart.html"), "utf8");
258255

259256
const names = candidates.map((c) => c.name);
260257
const scores = candidates.map((c) => c.score);
@@ -287,14 +284,13 @@ export class ContributorRecommender {
287284
const errorMessage = error instanceof Error ? error.message : String(error);
288285
console.error("Chart generation error:", error);
289286

290-
const errorTemplatePath = path.join(__dirname, "../html/error-chart.html");
291-
let errorTemplate = fs.readFileSync(errorTemplatePath, "utf8");
287+
let errorTemplate = fs.readFileSync(htmlAssets.path("error-chart.html"), "utf8");
292288

293-
const templatePath = path.join(__dirname, "../html/contributors-chart.html");
294-
const debugInfo = `Template directory exists: ${fs.existsSync(path.join(__dirname, "../html"))}
295-
Contributors template exists: ${fs.existsSync(path.join(__dirname, "../html/contributors-chart.html"))}
296-
No-contributors template exists: ${fs.existsSync(path.join(__dirname, "../html/no-contributors.html"))}
297-
Error template exists: ${fs.existsSync(errorTemplatePath)}`;
289+
const templatePath = htmlAssets.path("contributors-chart.html");
290+
const debugInfo = `Template dir: ${htmlAssets.baseDir} ...
291+
Contributors template exists: ${fs.existsSync(templatePath)}
292+
No-contributors template exists: ${fs.existsSync(htmlAssets.path("no-contributors.html"))}
293+
Error template exists: ${fs.existsSync(htmlAssets.path("error-chart.html"))}`;
298294

299295
errorTemplate = errorTemplate.replace("{{ERROR_MESSAGE}}", errorMessage);
300296
errorTemplate = errorTemplate.replace("{{TEMPLATE_PATH}}", templatePath);

0 commit comments

Comments
 (0)