@tbsten/mir-core 0.0.1-alpha04 → 0.0.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbsten/mir-core",
3
- "version": "0.0.1-alpha04",
3
+ "version": "0.0.1",
4
4
  "description": "スニペット配布・取得ツール mir のコアロジック(テンプレート、スキーマ、i18n)",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ lowercase,
4
+ uppercase,
5
+ capitalize,
6
+ uncapitalize,
7
+ replace,
8
+ camelCase,
9
+ pascalCase,
10
+ snakeCase,
11
+ kebabCase,
12
+ trim,
13
+ } from "../../helpers/string-helpers.js";
14
+
15
+ describe("lowercase", () => {
16
+ it("大文字を小文字に変換", () => expect(lowercase("HELLO")).toBe("hello"));
17
+ it("空文字", () => expect(lowercase("")).toBe(""));
18
+ it("undefined は 'undefined' に変換", () =>
19
+ expect(lowercase(undefined)).toBe("undefined"));
20
+ it("数値は文字列化", () => expect(lowercase(123)).toBe("123"));
21
+ });
22
+
23
+ describe("uppercase", () => {
24
+ it("小文字を大文字に変換", () => expect(uppercase("hello")).toBe("HELLO"));
25
+ it("空文字", () => expect(uppercase("")).toBe(""));
26
+ });
27
+
28
+ describe("capitalize", () => {
29
+ it("先頭を大文字に", () => expect(capitalize("hello")).toBe("Hello"));
30
+ it("空文字", () => expect(capitalize("")).toBe(""));
31
+ it("1文字", () => expect(capitalize("a")).toBe("A"));
32
+ });
33
+
34
+ describe("uncapitalize", () => {
35
+ it("先頭を小文字に", () => expect(uncapitalize("Hello")).toBe("hello"));
36
+ it("空文字", () => expect(uncapitalize("")).toBe(""));
37
+ it("1文字", () => expect(uncapitalize("A")).toBe("a"));
38
+ });
39
+
40
+ describe("replace", () => {
41
+ it("リテラル文字列を置換", () =>
42
+ expect(replace("com/example/app", "/", ".")).toBe("com.example.app"));
43
+ it("マッチしない場合はそのまま", () =>
44
+ expect(replace("hello", "x", "y")).toBe("hello"));
45
+ it("空の search は何もしない", () =>
46
+ expect(replace("hello", "", "x")).toBe("hello"));
47
+ it("正規表現メタ文字がリテラルとして扱われる", () =>
48
+ expect(replace("a.b.c", ".", "-")).toBe("a-b-c"));
49
+ it("$記号を含む replacement が安全に処理される", () =>
50
+ expect(replace("hello", "l", "$1")).toBe("he$1$1o"));
51
+ });
52
+
53
+ describe("camelCase", () => {
54
+ it("kebab-case → camelCase", () =>
55
+ expect(camelCase("my-component")).toBe("myComponent"));
56
+ it("snake_case → camelCase", () =>
57
+ expect(camelCase("my_component")).toBe("myComponent"));
58
+ it("PascalCase → camelCase", () =>
59
+ expect(camelCase("MyComponent")).toBe("myComponent"));
60
+ it("スペース区切り", () =>
61
+ expect(camelCase("hello world")).toBe("helloWorld"));
62
+ it("ドット区切り", () =>
63
+ expect(camelCase("com.example.app")).toBe("comExampleApp"));
64
+ it("空文字", () => expect(camelCase("")).toBe(""));
65
+ it("単一単語", () => expect(camelCase("hello")).toBe("hello"));
66
+ });
67
+
68
+ describe("pascalCase", () => {
69
+ it("kebab-case → PascalCase", () =>
70
+ expect(pascalCase("my-component")).toBe("MyComponent"));
71
+ it("camelCase → PascalCase", () =>
72
+ expect(pascalCase("myComponent")).toBe("MyComponent"));
73
+ it("空文字", () => expect(pascalCase("")).toBe(""));
74
+ });
75
+
76
+ describe("snakeCase", () => {
77
+ it("camelCase → snake_case", () =>
78
+ expect(snakeCase("myComponent")).toBe("my_component"));
79
+ it("kebab-case → snake_case", () =>
80
+ expect(snakeCase("my-component")).toBe("my_component"));
81
+ it("PascalCase → snake_case", () =>
82
+ expect(snakeCase("MyComponent")).toBe("my_component"));
83
+ it("空文字", () => expect(snakeCase("")).toBe(""));
84
+ });
85
+
86
+ describe("kebabCase", () => {
87
+ it("camelCase → kebab-case", () =>
88
+ expect(kebabCase("myComponent")).toBe("my-component"));
89
+ it("PascalCase → kebab-case", () =>
90
+ expect(kebabCase("MyComponent")).toBe("my-component"));
91
+ it("snake_case → kebab-case", () =>
92
+ expect(kebabCase("my_component")).toBe("my-component"));
93
+ it("空文字", () => expect(kebabCase("")).toBe(""));
94
+ });
95
+
96
+ describe("trim", () => {
97
+ it("前後空白を除去", () => expect(trim(" hello ")).toBe("hello"));
98
+ it("空文字", () => expect(trim("")).toBe(""));
99
+ it("改行を除去", () => expect(trim("\nhello\n")).toBe("hello"));
100
+ });
@@ -100,6 +100,109 @@ describe("extractVariables", () => {
100
100
  });
101
101
  });
102
102
 
103
+ describe("expandTemplate with helpers", () => {
104
+ it("lowercase ヘルパー", () => {
105
+ expect(expandTemplate("{{lowercase name}}", { name: "HELLO" })).toBe("hello");
106
+ });
107
+
108
+ it("uppercase ヘルパー", () => {
109
+ expect(expandTemplate("{{uppercase name}}", { name: "hello" })).toBe("HELLO");
110
+ });
111
+
112
+ it("replace ヘルパー", () => {
113
+ expect(
114
+ expandTemplate('{{replace package "/" "."}}', { package: "com/example/app" }),
115
+ ).toBe("com.example.app");
116
+ });
117
+
118
+ it("camelCase ヘルパー", () => {
119
+ expect(expandTemplate("{{camelCase name}}", { name: "my-component" })).toBe(
120
+ "myComponent",
121
+ );
122
+ });
123
+
124
+ it("pascalCase ヘルパー", () => {
125
+ expect(expandTemplate("{{pascalCase name}}", { name: "my-component" })).toBe(
126
+ "MyComponent",
127
+ );
128
+ });
129
+
130
+ it("snakeCase ヘルパー", () => {
131
+ expect(expandTemplate("{{snakeCase name}}", { name: "myComponent" })).toBe(
132
+ "my_component",
133
+ );
134
+ });
135
+
136
+ it("kebabCase ヘルパー", () => {
137
+ expect(expandTemplate("{{kebabCase name}}", { name: "MyComponent" })).toBe(
138
+ "my-component",
139
+ );
140
+ });
141
+
142
+ it("capitalize ヘルパー", () => {
143
+ expect(expandTemplate("{{capitalize name}}", { name: "hello" })).toBe("Hello");
144
+ });
145
+
146
+ it("uncapitalize ヘルパー", () => {
147
+ expect(expandTemplate("{{uncapitalize name}}", { name: "Hello" })).toBe("hello");
148
+ });
149
+
150
+ it("trim ヘルパー", () => {
151
+ expect(expandTemplate("{{trim name}}", { name: " hello " })).toBe("hello");
152
+ });
153
+
154
+ it("サブ式(ネスト): lowercase(replace(...))", () => {
155
+ expect(
156
+ expandTemplate('{{lowercase (replace name "/" ".")}}', {
157
+ name: "COM/EXAMPLE/APP",
158
+ }),
159
+ ).toBe("com.example.app");
160
+ });
161
+
162
+ it("サブ式(ネスト): pascalCase(replace(...))", () => {
163
+ expect(
164
+ expandTemplate('{{pascalCase (replace pkg "/" "-")}}', {
165
+ pkg: "my/cool/component",
166
+ }),
167
+ ).toBe("MyCoolComponent");
168
+ });
169
+ });
170
+
171
+ describe("extractVariables with helpers", () => {
172
+ it("ヘルパー名を変数に含めない", () => {
173
+ const vars = extractVariables("{{lowercase name}}");
174
+ expect(vars).toContain("name");
175
+ expect(vars).not.toContain("lowercase");
176
+ });
177
+
178
+ it("replace ヘルパーで変数のみ抽出", () => {
179
+ const vars = extractVariables('{{replace package "/" "."}}');
180
+ expect(vars).toContain("package");
181
+ expect(vars).not.toContain("replace");
182
+ });
183
+
184
+ it("サブ式でもヘルパー名を除外", () => {
185
+ const vars = extractVariables('{{lowercase (replace name "/" ".")}}');
186
+ expect(vars).toContain("name");
187
+ expect(vars).not.toContain("lowercase");
188
+ expect(vars).not.toContain("replace");
189
+ });
190
+
191
+ it("ヘルパーと通常変数が混在", () => {
192
+ const vars = extractVariables("{{name}} {{lowercase title}}");
193
+ expect(vars).toContain("name");
194
+ expect(vars).toContain("title");
195
+ expect(vars).not.toContain("lowercase");
196
+ });
197
+
198
+ it("組み込みヘルパー (if/each) も変数に含めない", () => {
199
+ const vars = extractVariables("{{#if enabled}}{{name}}{{/if}}");
200
+ expect(vars).toContain("enabled");
201
+ expect(vars).toContain("name");
202
+ expect(vars).not.toContain("if");
203
+ });
204
+ });
205
+
103
206
  describe("extractVariablesFromDirectory", () => {
104
207
  let tmpDir: string;
105
208
 
@@ -0,0 +1,34 @@
1
+ import type Handlebars from "handlebars";
2
+ import * as helpers from "./string-helpers.js";
3
+
4
+ /** ヘルパー名一覧(extractVariables でフィルタ用) */
5
+ export const HELPER_NAMES: ReadonlySet<string> = new Set([
6
+ "lowercase",
7
+ "uppercase",
8
+ "capitalize",
9
+ "uncapitalize",
10
+ "replace",
11
+ "camelCase",
12
+ "pascalCase",
13
+ "snakeCase",
14
+ "kebabCase",
15
+ "trim",
16
+ ]);
17
+
18
+ /** Handlebars インスタンスにヘルパーを一括登録 */
19
+ export function registerHelpers(hbs: typeof Handlebars): void {
20
+ hbs.registerHelper("lowercase", (v: unknown) => helpers.lowercase(v));
21
+ hbs.registerHelper("uppercase", (v: unknown) => helpers.uppercase(v));
22
+ hbs.registerHelper("capitalize", (v: unknown) => helpers.capitalize(v));
23
+ hbs.registerHelper("uncapitalize", (v: unknown) => helpers.uncapitalize(v));
24
+ hbs.registerHelper(
25
+ "replace",
26
+ (v: unknown, search: unknown, replacement: unknown) =>
27
+ helpers.replace(v, search, replacement),
28
+ );
29
+ hbs.registerHelper("camelCase", (v: unknown) => helpers.camelCase(v));
30
+ hbs.registerHelper("pascalCase", (v: unknown) => helpers.pascalCase(v));
31
+ hbs.registerHelper("snakeCase", (v: unknown) => helpers.snakeCase(v));
32
+ hbs.registerHelper("kebabCase", (v: unknown) => helpers.kebabCase(v));
33
+ hbs.registerHelper("trim", (v: unknown) => helpers.trim(v));
34
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Handlebars カスタムヘルパー用の純粋関数群
3
+ * 外部ライブラリ不使用
4
+ */
5
+
6
+ /** 単語分割: `-`, `_`, `.`, スペース, camelCase 境界で分割 */
7
+ function splitWords(value: string): string[] {
8
+ return value
9
+ .replace(/([a-z0-9])([A-Z])/g, "$1\0$2")
10
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2")
11
+ .split(/[-_.\s\0]+/)
12
+ .filter((w) => w.length > 0);
13
+ }
14
+
15
+ export function lowercase(value: unknown): string {
16
+ return String(value).toLowerCase();
17
+ }
18
+
19
+ export function uppercase(value: unknown): string {
20
+ return String(value).toUpperCase();
21
+ }
22
+
23
+ export function capitalize(value: unknown): string {
24
+ const s = String(value);
25
+ if (s.length === 0) return s;
26
+ return s[0].toUpperCase() + s.slice(1);
27
+ }
28
+
29
+ export function uncapitalize(value: unknown): string {
30
+ const s = String(value);
31
+ if (s.length === 0) return s;
32
+ return s[0].toLowerCase() + s.slice(1);
33
+ }
34
+
35
+ /** リテラル文字列置換(replaceAll)。正規表現は受け付けない(ReDoS 防止) */
36
+ export function replace(
37
+ value: unknown,
38
+ search: unknown,
39
+ replacement: unknown,
40
+ ): string {
41
+ const s = String(value);
42
+ const searchStr = String(search);
43
+ const replaceStr = String(replacement);
44
+ if (searchStr === "") return s;
45
+ return s.split(searchStr).join(replaceStr);
46
+ }
47
+
48
+ export function camelCase(value: unknown): string {
49
+ const words = splitWords(String(value));
50
+ return words
51
+ .map((w, i) =>
52
+ i === 0 ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1).toLowerCase(),
53
+ )
54
+ .join("");
55
+ }
56
+
57
+ export function pascalCase(value: unknown): string {
58
+ const words = splitWords(String(value));
59
+ return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join("");
60
+ }
61
+
62
+ export function snakeCase(value: unknown): string {
63
+ return splitWords(String(value))
64
+ .map((w) => w.toLowerCase())
65
+ .join("_");
66
+ }
67
+
68
+ export function kebabCase(value: unknown): string {
69
+ return splitWords(String(value))
70
+ .map((w) => w.toLowerCase())
71
+ .join("-");
72
+ }
73
+
74
+ export function trim(value: unknown): string {
75
+ return String(value).trim();
76
+ }
package/src/index.ts CHANGED
@@ -44,6 +44,9 @@ export {
44
44
  expandTemplateDirectory,
45
45
  } from "./template-engine.js";
46
46
 
47
+ // helpers
48
+ export { HELPER_NAMES } from "./helpers/index.js";
49
+
47
50
  // hooks
48
51
  export { ExitHookError, executeHooks } from "./hooks.js";
49
52
  export type { HookExecutionOptions } from "./hooks.js";
@@ -5,12 +5,17 @@ import {
5
5
  listTemplateFiles,
6
6
  readTemplateFile,
7
7
  } from "./registry.js";
8
+ import { HELPER_NAMES, registerHelpers } from "./helpers/index.js";
9
+
10
+ // 隔離インスタンスを作成しヘルパーを登録
11
+ const hbs = Handlebars.create();
12
+ registerHelpers(hbs);
8
13
 
9
14
  export function expandTemplate(
10
15
  template: string,
11
16
  variables: Record<string, unknown>,
12
17
  ): string {
13
- const compiled = Handlebars.compile(template, { noEscape: true });
18
+ const compiled = hbs.compile(template, { noEscape: true });
14
19
  return compiled(variables);
15
20
  }
16
21
 
@@ -23,30 +28,50 @@ export function expandPath(
23
28
  return path.normalize(expanded);
24
29
  }
25
30
 
31
+ /** Handlebars 組み込みヘルパー名 */
32
+ const BUILTIN_HELPERS = new Set([
33
+ "if",
34
+ "unless",
35
+ "each",
36
+ "with",
37
+ "lookup",
38
+ "log",
39
+ ]);
40
+
26
41
  export function extractVariables(template: string): string[] {
27
42
  const ast = Handlebars.parse(template);
28
43
  const vars = new Set<string>();
29
44
 
30
- function visitExpression(expr: hbs.AST.Expression): void {
45
+ function collectVarFromExpression(expr: hbs.AST.Expression): void {
31
46
  if (expr.type === "PathExpression") {
32
47
  const pathExpr = expr as hbs.AST.PathExpression;
33
48
  vars.add(pathExpr.parts[0]);
49
+ } else if (expr.type === "SubExpression") {
50
+ // サブ式: {{lowercase (replace name "/" ".")}} 等
51
+ const sub = expr as hbs.AST.SubExpression;
52
+ // sub.path はヘルパー名なのでスキップ、params のみ抽出
53
+ if (sub.params) {
54
+ for (const param of sub.params) collectVarFromExpression(param);
55
+ }
34
56
  }
35
57
  }
36
58
 
37
59
  function visit(node: hbs.AST.Node): void {
38
60
  if (node.type === "MustacheStatement") {
39
61
  const stmt = node as hbs.AST.MustacheStatement;
40
- if (stmt.path) visitExpression(stmt.path);
41
- if (stmt.params) {
42
- for (const param of stmt.params) visitExpression(param);
62
+ if (stmt.params && stmt.params.length > 0) {
63
+ // ヘルパー呼び出し: path はヘルパー名なのでスキップ、params のみ変数抽出
64
+ for (const param of stmt.params) collectVarFromExpression(param);
65
+ } else {
66
+ // 単純な変数参照: {{name}}
67
+ if (stmt.path) collectVarFromExpression(stmt.path);
43
68
  }
44
69
  }
45
70
  if (node.type === "BlockStatement") {
46
71
  const block = node as hbs.AST.BlockStatement;
47
72
  // #if, #unless, #each 等のパラメータから変数を抽出
48
73
  if (block.params) {
49
- for (const param of block.params) visitExpression(param);
74
+ for (const param of block.params) collectVarFromExpression(param);
50
75
  }
51
76
  if (block.program) visit(block.program);
52
77
  if (block.inverse) visit(block.inverse);
@@ -59,6 +84,11 @@ export function extractVariables(template: string): string[] {
59
84
  }
60
85
 
61
86
  visit(ast);
87
+
88
+ // ヘルパー名・組み込みヘルパー名を除外
89
+ for (const name of HELPER_NAMES) vars.delete(name);
90
+ for (const name of BUILTIN_HELPERS) vars.delete(name);
91
+
62
92
  return [...vars];
63
93
  }
64
94