@tbsten/mir-core 0.0.1-alpha02

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +25 -0
  2. package/src/__tests__/errors.test.ts +71 -0
  3. package/src/__tests__/hooks.test.ts +145 -0
  4. package/src/__tests__/i18n.test.ts +60 -0
  5. package/src/__tests__/remote-registry.test.ts +265 -0
  6. package/src/__tests__/safe-yaml-parser.test.ts +187 -0
  7. package/src/__tests__/snapshots/__snapshots__/error-messages.snapshot.test.ts.snap +27 -0
  8. package/src/__tests__/snapshots/__snapshots__/snippet-schema-output.snapshot.test.ts.snap +78 -0
  9. package/src/__tests__/snapshots/__snapshots__/template-output.snapshot.test.ts.snap +45 -0
  10. package/src/__tests__/snapshots/error-messages.snapshot.test.ts +76 -0
  11. package/src/__tests__/snapshots/snippet-schema-output.snapshot.test.ts +98 -0
  12. package/src/__tests__/snapshots/template-output.snapshot.test.ts +73 -0
  13. package/src/__tests__/snippet-schema.test.ts +180 -0
  14. package/src/__tests__/symlink-checker.test.ts +95 -0
  15. package/src/__tests__/template-engine.test.ts +137 -0
  16. package/src/__tests__/validate-name.test.ts +61 -0
  17. package/src/errors.ts +82 -0
  18. package/src/hooks.ts +63 -0
  19. package/src/i18n/index.ts +32 -0
  20. package/src/i18n/locales/en.ts +91 -0
  21. package/src/i18n/locales/ja.ts +91 -0
  22. package/src/i18n/types.ts +91 -0
  23. package/src/index.ts +84 -0
  24. package/src/lib/symlink-checker.ts +62 -0
  25. package/src/registry.ts +117 -0
  26. package/src/remote-registry.ts +260 -0
  27. package/src/safe-yaml-parser.ts +71 -0
  28. package/src/snippet-schema.ts +117 -0
  29. package/src/template-engine.ts +114 -0
  30. package/src/validate-name.ts +12 -0
  31. package/tsconfig.json +17 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * mir-core: snippet-schema の unit テスト
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import {
6
+ parseSnippetYaml,
7
+ serializeSnippetYaml,
8
+ validateSnippetDefinition,
9
+ ValidationError,
10
+ } from "../index.js";
11
+
12
+ // TODO: 現時点では理想の挙動をテストケースとして記述。後で有効化する。
13
+
14
+ describe("parseSnippetYaml", () => {
15
+ it("最小限の YAML をパースできる", () => {
16
+ const def = parseSnippetYaml("name: my-snippet\n");
17
+ expect(def.name).toBe("my-snippet");
18
+ });
19
+
20
+ it("変数付き YAML をパースできる", () => {
21
+ const yaml = `
22
+ name: react-hook
23
+ description: React カスタムフック
24
+ variables:
25
+ name:
26
+ description: フック名
27
+ schema:
28
+ type: string
29
+ `;
30
+ const def = parseSnippetYaml(yaml);
31
+ expect(def.name).toBe("react-hook");
32
+ expect(def.variables?.name?.description).toBe("フック名");
33
+ expect(def.variables?.name?.schema?.type).toBe("string");
34
+ });
35
+
36
+ it("hooks 付き YAML をパースできる", () => {
37
+ const yaml = `
38
+ name: test-snippet
39
+ hooks:
40
+ before-install:
41
+ - echo: "Installing..."
42
+ after-install:
43
+ - echo: "Done!"
44
+ `;
45
+ const def = parseSnippetYaml(yaml);
46
+ expect(def.hooks?.["before-install"]).toHaveLength(1);
47
+ expect(def.hooks?.["after-install"]).toHaveLength(1);
48
+ });
49
+
50
+ it("dependencies 付き YAML をパースできる", () => {
51
+ const yaml = `
52
+ name: react-component
53
+ description: React component
54
+ dependencies:
55
+ - react-hook
56
+ - typescript-setup
57
+ `;
58
+ const def = parseSnippetYaml(yaml);
59
+ expect(def.dependencies).toEqual(["react-hook", "typescript-setup"]);
60
+ });
61
+
62
+ it("不正な YAML でエラー", () => {
63
+ expect(() => parseSnippetYaml("not a yaml object: [")).toThrow();
64
+ });
65
+
66
+ it("name がない YAML でエラー", () => {
67
+ expect(() => parseSnippetYaml("description: no name\n")).toThrow(
68
+ ValidationError,
69
+ );
70
+ });
71
+ });
72
+
73
+ describe("serializeSnippetYaml", () => {
74
+ it("SnippetDefinition を YAML 文字列に変換する", () => {
75
+ const yaml = serializeSnippetYaml({ name: "test" });
76
+ expect(yaml).toContain("name: test");
77
+ });
78
+
79
+ it("パースと逆変換で元に戻る", () => {
80
+ const original = { name: "test", description: "desc" };
81
+ const yaml = serializeSnippetYaml(original);
82
+ const parsed = parseSnippetYaml(yaml);
83
+ expect(parsed.name).toBe(original.name);
84
+ expect(parsed.description).toBe(original.description);
85
+ });
86
+ });
87
+
88
+ describe("validateSnippetDefinition", () => {
89
+ it("最小限の定義はバリデーション通過", () => {
90
+ expect(() => validateSnippetDefinition({ name: "valid" })).not.toThrow();
91
+ });
92
+
93
+ it("name が空だとエラー", () => {
94
+ expect(() => validateSnippetDefinition({ name: "" })).toThrow(
95
+ ValidationError,
96
+ );
97
+ });
98
+
99
+ it("suggests が配列でないとエラー", () => {
100
+ expect(() =>
101
+ validateSnippetDefinition({
102
+ name: "test",
103
+ variables: {
104
+ key: { suggests: "not-array" as unknown as string[] },
105
+ },
106
+ }),
107
+ ).toThrow(ValidationError);
108
+ });
109
+
110
+ it("schema.type が不正だとエラー", () => {
111
+ expect(() =>
112
+ validateSnippetDefinition({
113
+ name: "test",
114
+ variables: {
115
+ key: {
116
+ schema: { type: "invalid" as "string" },
117
+ },
118
+ },
119
+ }),
120
+ ).toThrow(ValidationError);
121
+ });
122
+
123
+ it("正しい schema.type は通過 (string, number, boolean)", () => {
124
+ expect(() =>
125
+ validateSnippetDefinition({
126
+ name: "test",
127
+ variables: {
128
+ a: { schema: { type: "string" } },
129
+ b: { schema: { type: "number" } },
130
+ c: { schema: { type: "boolean" } },
131
+ },
132
+ }),
133
+ ).not.toThrow();
134
+ });
135
+
136
+ it("dependencies が配列の場合は通過", () => {
137
+ expect(() =>
138
+ validateSnippetDefinition({
139
+ name: "react-component",
140
+ dependencies: ["react-hook"],
141
+ }),
142
+ ).not.toThrow();
143
+ });
144
+
145
+ it("dependencies に複数要素を持つ場合は通過", () => {
146
+ expect(() =>
147
+ validateSnippetDefinition({
148
+ name: "test",
149
+ dependencies: ["dep1", "dep2", "dep3"],
150
+ }),
151
+ ).not.toThrow();
152
+ });
153
+
154
+ it("dependencies が配列でないとエラー", () => {
155
+ expect(() =>
156
+ validateSnippetDefinition({
157
+ name: "test",
158
+ dependencies: "not-array" as unknown as string[],
159
+ }),
160
+ ).toThrow(ValidationError);
161
+ });
162
+
163
+ it("dependencies の要素が文字列でないとエラー", () => {
164
+ expect(() =>
165
+ validateSnippetDefinition({
166
+ name: "test",
167
+ dependencies: ["valid-dep", 123 as unknown as string],
168
+ }),
169
+ ).toThrow(ValidationError);
170
+ });
171
+
172
+ it("dependencies の要素に不正な名前があるとエラー", () => {
173
+ expect(() =>
174
+ validateSnippetDefinition({
175
+ name: "test",
176
+ dependencies: ["-invalid-dep-name"],
177
+ }),
178
+ ).toThrow(ValidationError);
179
+ });
180
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { isSymbolicLink, findSymlinksInDirectory } from "../lib/symlink-checker.js";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mir-symlink-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("isSymbolicLink", () => {
18
+ it("通常のファイルに対して false を返す", () => {
19
+ const filePath = path.join(tmpDir, "regular.txt");
20
+ fs.writeFileSync(filePath, "content");
21
+ expect(isSymbolicLink(filePath)).toBe(false);
22
+ });
23
+
24
+ it("ディレクトリに対して false を返す", () => {
25
+ const dirPath = path.join(tmpDir, "subdir");
26
+ fs.mkdirSync(dirPath);
27
+ expect(isSymbolicLink(dirPath)).toBe(false);
28
+ });
29
+
30
+ it("シンボリックリンクに対して true を返す", () => {
31
+ const targetPath = path.join(tmpDir, "target.txt");
32
+ const linkPath = path.join(tmpDir, "link.txt");
33
+ fs.writeFileSync(targetPath, "content");
34
+ fs.symlinkSync(targetPath, linkPath);
35
+ expect(isSymbolicLink(linkPath)).toBe(true);
36
+ });
37
+
38
+ it("存在しないパスに対して false を返す", () => {
39
+ expect(isSymbolicLink(path.join(tmpDir, "nonexistent"))).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("findSymlinksInDirectory", () => {
44
+ it("シンボリックリンクがない場合 hasSymlinks=false を返す", () => {
45
+ fs.writeFileSync(path.join(tmpDir, "index.ts"), "content");
46
+ const result = findSymlinksInDirectory(tmpDir);
47
+ expect(result.hasSymlinks).toBe(false);
48
+ expect(result.symlinkPaths).toHaveLength(0);
49
+ });
50
+
51
+ it("シンボリックリンクを検出する", () => {
52
+ const targetPath = path.join(tmpDir, "target.txt");
53
+ const linkPath = path.join(tmpDir, "link.txt");
54
+ fs.writeFileSync(targetPath, "content");
55
+ fs.symlinkSync(targetPath, linkPath);
56
+
57
+ const result = findSymlinksInDirectory(tmpDir);
58
+ expect(result.hasSymlinks).toBe(true);
59
+ expect(result.symlinkPaths).toContain("link.txt");
60
+ });
61
+
62
+ it("サブディレクトリ内のシンボリックリンクを検出する", () => {
63
+ const subDir = path.join(tmpDir, "src");
64
+ fs.mkdirSync(subDir);
65
+ const targetPath = path.join(tmpDir, "target.txt");
66
+ const linkPath = path.join(subDir, "link.txt");
67
+ fs.writeFileSync(targetPath, "content");
68
+ fs.symlinkSync(targetPath, linkPath);
69
+
70
+ const result = findSymlinksInDirectory(tmpDir);
71
+ expect(result.hasSymlinks).toBe(true);
72
+ expect(result.symlinkPaths.some((p) => p.includes("link.txt"))).toBe(true);
73
+ });
74
+
75
+ it("複数のシンボリックリンクをすべて検出する", () => {
76
+ const target1 = path.join(tmpDir, "target1.txt");
77
+ const target2 = path.join(tmpDir, "target2.txt");
78
+ const link1 = path.join(tmpDir, "link1.txt");
79
+ const link2 = path.join(tmpDir, "link2.txt");
80
+ fs.writeFileSync(target1, "content1");
81
+ fs.writeFileSync(target2, "content2");
82
+ fs.symlinkSync(target1, link1);
83
+ fs.symlinkSync(target2, link2);
84
+
85
+ const result = findSymlinksInDirectory(tmpDir);
86
+ expect(result.hasSymlinks).toBe(true);
87
+ expect(result.symlinkPaths).toHaveLength(2);
88
+ });
89
+
90
+ it("存在しないディレクトリに対して空の結果を返す", () => {
91
+ const result = findSymlinksInDirectory(path.join(tmpDir, "nonexistent"));
92
+ expect(result.hasSymlinks).toBe(false);
93
+ expect(result.symlinkPaths).toHaveLength(0);
94
+ });
95
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * mir-core: テンプレートエンジンの unit テスト
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import {
9
+ expandTemplate,
10
+ expandPath,
11
+ extractVariables,
12
+ extractVariablesFromDirectory,
13
+ } from "../index.js";
14
+
15
+ // TODO: 現時点では理想の挙動をテストケースとして記述。後で有効化する。
16
+
17
+ describe("expandTemplate", () => {
18
+ it("変数を展開する", () => {
19
+ expect(expandTemplate("Hello, {{ name }}!", { name: "World" })).toBe(
20
+ "Hello, World!",
21
+ );
22
+ });
23
+
24
+ it("複数変数を展開する", () => {
25
+ const result = expandTemplate(
26
+ "{{ greeting }}, {{ name }}!",
27
+ { greeting: "Hi", name: "User" },
28
+ );
29
+ expect(result).toBe("Hi, User!");
30
+ });
31
+
32
+ it("変数が未定義の場合は空文字に展開", () => {
33
+ expect(expandTemplate("Hello, {{ name }}!", {})).toBe("Hello, !");
34
+ });
35
+
36
+ it("変数を含まないテンプレートはそのまま返す", () => {
37
+ expect(expandTemplate("plain text", {})).toBe("plain text");
38
+ });
39
+
40
+ it("空テンプレートは空文字を返す", () => {
41
+ expect(expandTemplate("", {})).toBe("");
42
+ });
43
+
44
+ it("Handlebars の #if ブロックを展開する", () => {
45
+ const template = "{{#if enabled}}ON{{else}}OFF{{/if}}";
46
+ expect(expandTemplate(template, { enabled: true })).toBe("ON");
47
+ expect(expandTemplate(template, { enabled: false })).toBe("OFF");
48
+ });
49
+
50
+ it("noEscape モードで HTML タグがエスケープされない", () => {
51
+ expect(expandTemplate("{{ html }}", { html: "<div>test</div>" })).toBe(
52
+ "<div>test</div>",
53
+ );
54
+ });
55
+ });
56
+
57
+ describe("expandPath", () => {
58
+ it("パス内の変数を展開する", () => {
59
+ expect(expandPath("{{ name }}.ts", { name: "useAuth" })).toBe("useAuth.ts");
60
+ });
61
+
62
+ it("ネストしたパスの変数を展開する", () => {
63
+ const result = expandPath("{{ dir }}/{{ name }}.ts", {
64
+ dir: "hooks",
65
+ name: "useAuth",
66
+ });
67
+ expect(result).toMatch(/hooks[/\\]useAuth\.ts/);
68
+ });
69
+
70
+ it("パス区切りを正規化する", () => {
71
+ const result = expandPath("a//b/c", {});
72
+ expect(result).not.toContain("//");
73
+ });
74
+ });
75
+
76
+ describe("extractVariables", () => {
77
+ it("単一変数を抽出する", () => {
78
+ expect(extractVariables("{{ name }}")).toContain("name");
79
+ });
80
+
81
+ it("複数変数を抽出する", () => {
82
+ const vars = extractVariables("{{ name }} {{ description }}");
83
+ expect(vars).toContain("name");
84
+ expect(vars).toContain("description");
85
+ });
86
+
87
+ it("重複変数は1つにまとめる", () => {
88
+ const vars = extractVariables("{{ name }} {{ name }}");
89
+ expect(vars.filter((v) => v === "name")).toHaveLength(1);
90
+ });
91
+
92
+ it("変数がない場合は空配列", () => {
93
+ expect(extractVariables("no variables")).toEqual([]);
94
+ });
95
+
96
+ it("#if ブロック内の変数を抽出する", () => {
97
+ const vars = extractVariables("{{#if enabled}}{{ name }}{{/if}}");
98
+ expect(vars).toContain("enabled");
99
+ expect(vars).toContain("name");
100
+ });
101
+ });
102
+
103
+ describe("extractVariablesFromDirectory", () => {
104
+ let tmpDir: string;
105
+
106
+ beforeEach(() => {
107
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mir-core-test-"));
108
+ });
109
+
110
+ afterEach(() => {
111
+ fs.rmSync(tmpDir, { recursive: true, force: true });
112
+ });
113
+
114
+ it("ファイル内容から変数を抽出する", () => {
115
+ fs.writeFileSync(path.join(tmpDir, "index.ts"), "export {{ name }}", "utf-8");
116
+ const vars = extractVariablesFromDirectory(tmpDir);
117
+ expect(vars).toContain("name");
118
+ });
119
+
120
+ it("ファイル名から変数を抽出する", () => {
121
+ fs.writeFileSync(path.join(tmpDir, "{{ name }}.ts"), "content", "utf-8");
122
+ const vars = extractVariablesFromDirectory(tmpDir);
123
+ expect(vars).toContain("name");
124
+ });
125
+
126
+ it("サブディレクトリ名から変数を抽出する", () => {
127
+ const subDir = path.join(tmpDir, "{{ module }}");
128
+ fs.mkdirSync(subDir, { recursive: true });
129
+ fs.writeFileSync(path.join(subDir, "index.ts"), "export {}", "utf-8");
130
+ const vars = extractVariablesFromDirectory(tmpDir);
131
+ expect(vars).toContain("module");
132
+ });
133
+
134
+ it("存在しないディレクトリは空配列", () => {
135
+ expect(extractVariablesFromDirectory("/nonexistent")).toEqual([]);
136
+ });
137
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * mir-core: validateSnippetName の unit テスト
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import { validateSnippetName, ValidationError } from "../index.js";
6
+
7
+ // TODO: 現時点では理想の挙動をテストケースとして記述。後で有効化する。
8
+
9
+ describe("validateSnippetName", () => {
10
+ describe("正常系", () => {
11
+ it("英小文字のみ", () => {
12
+ expect(() => validateSnippetName("react")).not.toThrow();
13
+ });
14
+
15
+ it("英数字 + ハイフン", () => {
16
+ expect(() => validateSnippetName("react-hook")).not.toThrow();
17
+ });
18
+
19
+ it("数字始まり", () => {
20
+ expect(() => validateSnippetName("1component")).not.toThrow();
21
+ });
22
+
23
+ it("大文字含む", () => {
24
+ expect(() => validateSnippetName("MyComponent")).not.toThrow();
25
+ });
26
+
27
+ it("1文字", () => {
28
+ expect(() => validateSnippetName("a")).not.toThrow();
29
+ });
30
+ });
31
+
32
+ describe("異常系", () => {
33
+ it("空文字でエラー", () => {
34
+ expect(() => validateSnippetName("")).toThrow(ValidationError);
35
+ });
36
+
37
+ it("アンダースコアでエラー", () => {
38
+ expect(() => validateSnippetName("my_comp")).toThrow(ValidationError);
39
+ });
40
+
41
+ it("先頭ハイフンでエラー", () => {
42
+ expect(() => validateSnippetName("-invalid")).toThrow(ValidationError);
43
+ });
44
+
45
+ it("スペース含むとエラー", () => {
46
+ expect(() => validateSnippetName("my comp")).toThrow(ValidationError);
47
+ });
48
+
49
+ it("日本語でエラー", () => {
50
+ expect(() => validateSnippetName("テスト")).toThrow(ValidationError);
51
+ });
52
+
53
+ it("特殊文字でエラー", () => {
54
+ expect(() => validateSnippetName("react@hook")).toThrow(ValidationError);
55
+ });
56
+
57
+ it("ドット含むとエラー", () => {
58
+ expect(() => validateSnippetName("my.comp")).toThrow(ValidationError);
59
+ });
60
+ });
61
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { t } from "./i18n/index.js";
2
+
3
+ export class MirError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "MirError";
7
+ }
8
+ }
9
+
10
+ export class ValidationError extends MirError {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "ValidationError";
14
+ }
15
+ }
16
+
17
+ export class SnippetNotFoundError extends MirError {
18
+ readonly details: string;
19
+
20
+ constructor(name: string) {
21
+ super(t("error.snippet-not-found", { name }));
22
+ this.name = "SnippetNotFoundError";
23
+ this.details = t("error.snippet-not-found-details");
24
+ }
25
+ }
26
+
27
+ export class SnippetAlreadyExistsError extends MirError {
28
+ constructor(name: string) {
29
+ super(t("error.snippet-already-exists", { name }));
30
+ this.name = "SnippetAlreadyExistsError";
31
+ }
32
+ }
33
+
34
+ export class RegistryNotFoundError extends MirError {
35
+ constructor(name: string) {
36
+ super(t("error.registry-not-found", { name }));
37
+ this.name = "RegistryNotFoundError";
38
+ }
39
+ }
40
+
41
+ export class RegistryRemoteError extends MirError {
42
+ constructor(name?: string) {
43
+ super(
44
+ name
45
+ ? t("error.registry-remote-named", { name })
46
+ : t("error.registry-remote"),
47
+ );
48
+ this.name = "RegistryRemoteError";
49
+ }
50
+ }
51
+
52
+ export class PathTraversalError extends MirError {
53
+ constructor(filePath: string) {
54
+ super(t("error.path-traversal", { path: filePath }));
55
+ this.name = "PathTraversalError";
56
+ }
57
+ }
58
+
59
+ export class FileConflictError extends MirError {
60
+ constructor(filePath: string) {
61
+ super(t("error.file-conflict", { path: filePath }));
62
+ this.name = "FileConflictError";
63
+ }
64
+ }
65
+
66
+ export class RemoteRegistryFetchError extends MirError {
67
+ constructor(url: string, status?: number) {
68
+ super(
69
+ status
70
+ ? t("error.remote-fetch-status", { url, status })
71
+ : t("error.remote-fetch", { url }),
72
+ );
73
+ this.name = "RemoteRegistryFetchError";
74
+ }
75
+ }
76
+
77
+ export class InvalidManifestError extends MirError {
78
+ constructor(url: string) {
79
+ super(t("error.invalid-manifest", { url }));
80
+ this.name = "InvalidManifestError";
81
+ }
82
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { MirError } from "./errors.js";
2
+ import { expandTemplate } from "./template-engine.js";
3
+ import { t } from "./i18n/index.js";
4
+ import type { Action } from "./snippet-schema.js";
5
+
6
+ export class ExitHookError extends MirError {
7
+ constructor() {
8
+ super(t("error.exit-hook"));
9
+ this.name = "ExitHookError";
10
+ }
11
+ }
12
+
13
+ export interface HookExecutionOptions {
14
+ onEcho?: (message: string) => void;
15
+ }
16
+
17
+ export function executeHooks(
18
+ actions: Action[],
19
+ variables: Record<string, unknown>,
20
+ options?: HookExecutionOptions,
21
+ ): Record<string, unknown> {
22
+ const vars = { ...variables };
23
+
24
+ for (const action of actions) {
25
+ if (action.echo !== undefined) {
26
+ const message = expandTemplate(action.echo, vars);
27
+ options?.onEcho?.(message);
28
+ }
29
+
30
+ if (action.exit !== undefined) {
31
+ if (action.if !== undefined) {
32
+ const condition = expandTemplate(action.if, vars);
33
+ if (isTruthy(condition)) {
34
+ throw new ExitHookError();
35
+ }
36
+ } else if (action.exit) {
37
+ throw new ExitHookError();
38
+ }
39
+ }
40
+
41
+ if (action.input !== undefined) {
42
+ for (const [, inputDef] of Object.entries(action.input)) {
43
+ const answerTo = inputDef["answer-to"];
44
+ if (!answerTo) continue;
45
+
46
+ if (inputDef.schema?.default !== undefined) {
47
+ vars[answerTo] = inputDef.schema.default;
48
+ } else {
49
+ throw new MirError(
50
+ t("error.hook-input-required", { key: answerTo }),
51
+ );
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ return vars;
58
+ }
59
+
60
+ function isTruthy(value: string): boolean {
61
+ const trimmed = value.trim();
62
+ return trimmed !== "" && trimmed !== "false" && trimmed !== "0";
63
+ }
@@ -0,0 +1,32 @@
1
+ import type { MessageKey, MessageCatalog } from "./types.js";
2
+ import { ja } from "./locales/ja.js";
3
+ import { en } from "./locales/en.js";
4
+
5
+ export type Locale = "ja" | "en";
6
+
7
+ const catalogs: Record<Locale, MessageCatalog> = { ja, en };
8
+
9
+ let currentLocale: Locale = "ja";
10
+
11
+ export function setLocale(locale: Locale): void {
12
+ currentLocale = locale;
13
+ }
14
+
15
+ export function getLocale(): Locale {
16
+ return currentLocale;
17
+ }
18
+
19
+ export function t(
20
+ key: MessageKey,
21
+ params?: Record<string, string | number>,
22
+ ): string {
23
+ let message = catalogs[currentLocale][key];
24
+ if (params) {
25
+ for (const [k, v] of Object.entries(params)) {
26
+ message = message.replaceAll(`{${k}}`, String(v));
27
+ }
28
+ }
29
+ return message;
30
+ }
31
+
32
+ export type { MessageKey, MessageCatalog };