@dialekt/adapter-laravel 0.1.0
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/TESTING.md +29 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +173 -0
- package/package.json +27 -0
- package/src/adapter.test.ts +179 -0
- package/src/adapter.ts +140 -0
- package/src/index.ts +2 -0
- package/src/php-array-writer.test.ts +58 -0
- package/src/php-array-writer.ts +38 -0
- package/src/resources.test.ts +59 -0
- package/src/resources.ts +78 -0
- package/src/unused-keys.test.ts +78 -0
- package/src/unused-keys.ts +70 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.ts +7 -0
- package/vitest.config.ts +8 -0
package/TESTING.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @dialekt/adapter-laravel Testing Guide
|
|
2
|
+
|
|
3
|
+
## Tested Areas Map
|
|
4
|
+
|
|
5
|
+
| Export | Test File | Status |
|
|
6
|
+
|--------|-----------|--------|
|
|
7
|
+
| `laravel()` adapter | `src/adapter.test.ts` | ✅ |
|
|
8
|
+
| `renderPhpArray` / `renderPhpFile` | `src/php-array-writer.test.ts` | ✅ |
|
|
9
|
+
| `listLaravelLocales` / `listLaravelResources` | `src/resources.test.ts` | ✅ |
|
|
10
|
+
| `findUnusedLaravelKeys` | `src/unused-keys.test.ts` | ✅ |
|
|
11
|
+
|
|
12
|
+
## Known Coverage Gaps
|
|
13
|
+
|
|
14
|
+
- Corrupted PHP file handling (PHP syntax error) — `readResource` returns `PhpExecutionError` but no explicit test for malformed PHP
|
|
15
|
+
- JSON locale file with invalid JSON — no explicit test for corrupted `en.json`
|
|
16
|
+
- `phpBinary` option in `LaravelAdapterOptions` — not tested with custom PHP binary path
|
|
17
|
+
|
|
18
|
+
## Specialties & Watch-Outs
|
|
19
|
+
|
|
20
|
+
- **PHP-dependent tests** use `it.skipIf(!hasPhpBinary())` to skip when `php` is not installed.
|
|
21
|
+
- Tests create real temp directories via `node:fs` and clean up with `rmSync` in `afterEach` pattern.
|
|
22
|
+
- The adapter's `findUnusedKeys!` requires `capabilities.unusedKeyDetection: true`.
|
|
23
|
+
- `writeResource` for PHP domains produces actual `.php` files with `<?php return [...];` syntax.
|
|
24
|
+
|
|
25
|
+
## Running Tests
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm --filter @dialekt/adapter-laravel test
|
|
29
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { TranslationAdapter } from "dialekt";
|
|
2
|
+
|
|
3
|
+
//#region src/adapter.d.ts
|
|
4
|
+
interface LaravelAdapterOptions {
|
|
5
|
+
readonly langDir: string;
|
|
6
|
+
readonly phpBinary?: string;
|
|
7
|
+
readonly scanPaths?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
declare function laravel(options: LaravelAdapterOptions): TranslationAdapter;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { type LaravelAdapterOptions, laravel };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Path } from "@effect/platform/Path";
|
|
3
|
+
import { AdapterReadError, AdapterWriteError, NodePlatformLayer, flattenObject, readFileIfExists, readPhpArrayAsJson, unflattenObject, writeFileEnsuringDir } from "dialekt";
|
|
4
|
+
import { FileSystem, Path as Path$1 } from "@effect/platform";
|
|
5
|
+
//#region src/php-array-writer.ts
|
|
6
|
+
function phpVarExport(value) {
|
|
7
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
8
|
+
}
|
|
9
|
+
function renderPhpArray(value, indent = 0) {
|
|
10
|
+
const entries = Object.entries(value);
|
|
11
|
+
if (entries.length === 0) return "[]";
|
|
12
|
+
const pad = " ".repeat(indent);
|
|
13
|
+
const innerPad = " ".repeat(indent + 1);
|
|
14
|
+
const lines = ["["];
|
|
15
|
+
for (const [key, val] of entries) {
|
|
16
|
+
const renderedKey = /^\d+$/.test(key) ? key : phpVarExport(key);
|
|
17
|
+
const renderedValue = typeof val === "object" && val !== null && !Array.isArray(val) ? renderPhpArray(val, indent + 1) : typeof val === "string" ? phpVarExport(val) : String(val);
|
|
18
|
+
lines.push(`${innerPad}${renderedKey} => ${renderedValue},`);
|
|
19
|
+
}
|
|
20
|
+
lines.push(`${pad}]`);
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
function renderPhpFile(value) {
|
|
24
|
+
return `<?php\n\nreturn ${renderPhpArray(value, 0)};\n`;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/resources.ts
|
|
28
|
+
function listLaravelLocales(langDir) {
|
|
29
|
+
return Effect.gen(function* () {
|
|
30
|
+
const fs = yield* FileSystem.FileSystem;
|
|
31
|
+
const path = yield* Path$1.Path;
|
|
32
|
+
const entries = yield* fs.readDirectory(langDir).pipe(Effect.orElseSucceed(() => []));
|
|
33
|
+
const locales = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const fullPath = path.join(langDir, entry);
|
|
36
|
+
const stat = yield* fs.stat(fullPath).pipe(Effect.option);
|
|
37
|
+
if (stat._tag === "Some" && stat.value.type === "Directory" && entry !== "vendor" && entry !== "lang") locales.push(entry);
|
|
38
|
+
}
|
|
39
|
+
return locales;
|
|
40
|
+
}).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
41
|
+
adapter: "laravel",
|
|
42
|
+
locale: "",
|
|
43
|
+
resource: "",
|
|
44
|
+
cause
|
|
45
|
+
})));
|
|
46
|
+
}
|
|
47
|
+
function listLaravelResources(langDir, locale) {
|
|
48
|
+
return Effect.gen(function* () {
|
|
49
|
+
const fs = yield* FileSystem.FileSystem;
|
|
50
|
+
const path = yield* Path$1.Path;
|
|
51
|
+
const localeDir = path.join(langDir, locale);
|
|
52
|
+
const refs = [];
|
|
53
|
+
const entries = yield* fs.readDirectory(localeDir).pipe(Effect.orElseSucceed(() => []));
|
|
54
|
+
for (const entry of entries) if (entry.endsWith(".php")) {
|
|
55
|
+
const domain = entry.replace(/\.php$/, "");
|
|
56
|
+
refs.push({
|
|
57
|
+
key: domain,
|
|
58
|
+
label: domain
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const jsonPath = path.join(langDir, `${locale}.json`);
|
|
62
|
+
if (yield* fs.exists(jsonPath).pipe(Effect.orElseSucceed(() => false))) refs.push({
|
|
63
|
+
key: "json",
|
|
64
|
+
label: `${locale}.json`
|
|
65
|
+
});
|
|
66
|
+
return refs;
|
|
67
|
+
}).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
68
|
+
adapter: "laravel",
|
|
69
|
+
locale,
|
|
70
|
+
resource: "",
|
|
71
|
+
cause
|
|
72
|
+
})));
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/unused-keys.ts
|
|
76
|
+
function findUnusedLaravelKeys(scanPaths, domain, keys) {
|
|
77
|
+
return Effect.gen(function* () {
|
|
78
|
+
const fs = yield* FileSystem.FileSystem;
|
|
79
|
+
const path = yield* Path$1.Path;
|
|
80
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
81
|
+
for (const scanPath of scanPaths) {
|
|
82
|
+
if (!(yield* fs.exists(scanPath).pipe(Effect.orElseSucceed(() => false)))) continue;
|
|
83
|
+
const entries = yield* fs.readDirectory(scanPath, { recursive: true }).pipe(Effect.orElseSucceed(() => []));
|
|
84
|
+
for (const relativePath of entries) {
|
|
85
|
+
if (!relativePath.endsWith(".php") && !relativePath.endsWith(".blade.php")) continue;
|
|
86
|
+
const filePath = path.join(scanPath, relativePath);
|
|
87
|
+
const content = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
|
|
88
|
+
const quotedStrings = [];
|
|
89
|
+
for (const pattern of [/'((?:[^'\\]|\\.)*)'/g, /"((?:[^"\\]|\\.)*)"/g]) {
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = pattern.exec(content)) !== null) if (m[1] !== void 0) quotedStrings.push(m[1]);
|
|
92
|
+
}
|
|
93
|
+
for (const str of quotedStrings) for (const key of keys) if (str === `${domain}.${key}`) referenced.add(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return keys.filter((key) => !referenced.has(key));
|
|
97
|
+
}).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
98
|
+
adapter: "laravel",
|
|
99
|
+
locale: "",
|
|
100
|
+
resource: domain,
|
|
101
|
+
cause
|
|
102
|
+
})));
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/adapter.ts
|
|
106
|
+
function laravel(options) {
|
|
107
|
+
const { langDir, scanPaths = [] } = options;
|
|
108
|
+
return {
|
|
109
|
+
name: "laravel",
|
|
110
|
+
capabilities: {
|
|
111
|
+
canCreateResource: true,
|
|
112
|
+
unusedKeyDetection: true
|
|
113
|
+
},
|
|
114
|
+
listLocales: () => listLaravelLocales(langDir).pipe(Effect.provide(NodePlatformLayer)),
|
|
115
|
+
listResources: (locale) => listLaravelResources(langDir, locale).pipe(Effect.provide(NodePlatformLayer)),
|
|
116
|
+
readResource: (locale, resource) => Effect.gen(function* () {
|
|
117
|
+
const path = yield* Path;
|
|
118
|
+
if (resource.key === "json") {
|
|
119
|
+
const content = yield* readFileIfExists(path.join(langDir, `${locale}.json`)).pipe(Effect.mapError((cause) => new AdapterReadError({
|
|
120
|
+
adapter: "laravel",
|
|
121
|
+
locale,
|
|
122
|
+
resource: resource.key,
|
|
123
|
+
cause
|
|
124
|
+
})));
|
|
125
|
+
if (content === null) return {};
|
|
126
|
+
return yield* Effect.try({
|
|
127
|
+
try: () => JSON.parse(content),
|
|
128
|
+
catch: (cause) => new AdapterReadError({
|
|
129
|
+
adapter: "laravel",
|
|
130
|
+
locale,
|
|
131
|
+
resource: resource.key,
|
|
132
|
+
cause
|
|
133
|
+
})
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return flattenObject(yield* readPhpArrayAsJson(path.join(langDir, locale, `${resource.key}.php`)).pipe(Effect.catchTag("PhpExecutionError", () => Effect.succeed({})), Effect.mapError((cause) => new AdapterReadError({
|
|
137
|
+
adapter: "laravel",
|
|
138
|
+
locale,
|
|
139
|
+
resource: resource.key,
|
|
140
|
+
cause
|
|
141
|
+
}))));
|
|
142
|
+
}).pipe(Effect.provide([NodePlatformLayer])),
|
|
143
|
+
writeResource: (locale, resource, entries) => Effect.gen(function* () {
|
|
144
|
+
const path = yield* Path;
|
|
145
|
+
if (resource.key === "json") {
|
|
146
|
+
yield* writeFileEnsuringDir(path.join(langDir, `${locale}.json`), JSON.stringify(entries, null, 2)).pipe(Effect.mapError((cause) => new AdapterWriteError({
|
|
147
|
+
adapter: "laravel",
|
|
148
|
+
locale,
|
|
149
|
+
resource: resource.key,
|
|
150
|
+
cause
|
|
151
|
+
})));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
yield* writeFileEnsuringDir(path.join(langDir, locale, `${resource.key}.php`), renderPhpFile(unflattenObject(entries))).pipe(Effect.mapError((cause) => new AdapterWriteError({
|
|
155
|
+
adapter: "laravel",
|
|
156
|
+
locale,
|
|
157
|
+
resource: resource.key,
|
|
158
|
+
cause
|
|
159
|
+
})));
|
|
160
|
+
}).pipe(Effect.provide([NodePlatformLayer])),
|
|
161
|
+
findUnusedKeys: (locale, resource) => Effect.gen(function* () {
|
|
162
|
+
const path = yield* Path;
|
|
163
|
+
const adapterScanPaths = scanPaths.length > 0 ? scanPaths : [path.resolve(langDir, "..")];
|
|
164
|
+
const keys = yield* Effect.gen(function* () {
|
|
165
|
+
const map = yield* laravel(options).readResource(locale, resource);
|
|
166
|
+
return Object.keys(map);
|
|
167
|
+
});
|
|
168
|
+
return yield* findUnusedLaravelKeys(adapterScanPaths, resource.key, keys);
|
|
169
|
+
}).pipe(Effect.provide([NodePlatformLayer]))
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
export { laravel };
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dialekt/adapter-laravel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"import": "./dist/index.mjs"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@effect/platform": "^0.96.0",
|
|
13
|
+
"effect": "^3.21.0",
|
|
14
|
+
"dialekt": "0.1.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^26.0.1",
|
|
18
|
+
"tsdown": "^0.22.3",
|
|
19
|
+
"typescript": "^5.8.0",
|
|
20
|
+
"vitest": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsdown",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { NodePlatformLayer } from 'dialekt';
|
|
4
|
+
import { laravel } from './adapter.js';
|
|
5
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
function hasPhpBinary(): boolean {
|
|
11
|
+
try {
|
|
12
|
+
execSync('php -v', { stdio: 'ignore' });
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('laravel adapter', () => {
|
|
20
|
+
const testDir = join(tmpdir(), `laravel-adapter-test-${Date.now()}`);
|
|
21
|
+
|
|
22
|
+
it.skipIf(!hasPhpBinary())('reads a PHP domain resource', async () => {
|
|
23
|
+
mkdirSync(join(testDir, 'en'), { recursive: true });
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(testDir, 'en', 'validation.php'),
|
|
26
|
+
"<?php return ['email' => 'Email address'];",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
30
|
+
const program = adapter.readResource('en', { key: 'validation', label: 'validation' }).pipe(
|
|
31
|
+
Effect.provide(NodePlatformLayer),
|
|
32
|
+
);
|
|
33
|
+
const result = await Effect.runPromise(program);
|
|
34
|
+
expect(result).toEqual({ email: 'Email address' });
|
|
35
|
+
|
|
36
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it.skipIf(!hasPhpBinary())('returns {} for a missing resource', async () => {
|
|
40
|
+
mkdirSync(join(testDir, 'en'), { recursive: true });
|
|
41
|
+
|
|
42
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
43
|
+
const program = adapter.readResource('en', { key: 'missing', label: 'missing' }).pipe(
|
|
44
|
+
Effect.provide(NodePlatformLayer),
|
|
45
|
+
);
|
|
46
|
+
const result = await Effect.runPromise(program);
|
|
47
|
+
expect(result).toEqual({});
|
|
48
|
+
|
|
49
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it.skipIf(!hasPhpBinary())('round-trips write and read', async () => {
|
|
53
|
+
mkdirSync(join(testDir, 'de'), { recursive: true });
|
|
54
|
+
|
|
55
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
56
|
+
const writeProgram = adapter
|
|
57
|
+
.writeResource('de', { key: 'auth', label: 'auth' }, { login: 'Anmelden' })
|
|
58
|
+
.pipe(Effect.provide(NodePlatformLayer));
|
|
59
|
+
await Effect.runPromise(writeProgram);
|
|
60
|
+
|
|
61
|
+
const readProgram = adapter.readResource('de', { key: 'auth', label: 'auth' }).pipe(
|
|
62
|
+
Effect.provide(NodePlatformLayer),
|
|
63
|
+
);
|
|
64
|
+
const result = await Effect.runPromise(readProgram);
|
|
65
|
+
expect(result).toEqual({ login: 'Anmelden' });
|
|
66
|
+
|
|
67
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reads a JSON locale resource', async () => {
|
|
71
|
+
mkdirSync(testDir, { recursive: true });
|
|
72
|
+
writeFileSync(join(testDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
|
|
73
|
+
|
|
74
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
75
|
+
const program = adapter.readResource('en', { key: 'json', label: 'en.json' }).pipe(
|
|
76
|
+
Effect.provide(NodePlatformLayer),
|
|
77
|
+
);
|
|
78
|
+
const result = await Effect.runPromise(program);
|
|
79
|
+
expect(result).toEqual({ greeting: 'Hello' });
|
|
80
|
+
|
|
81
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('lists locales and resources', async () => {
|
|
85
|
+
mkdirSync(join(testDir, 'en'), { recursive: true });
|
|
86
|
+
mkdirSync(join(testDir, 'de'), { recursive: true });
|
|
87
|
+
writeFileSync(join(testDir, 'en', 'validation.php'), '<?php return [];');
|
|
88
|
+
|
|
89
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
90
|
+
const locales = await Effect.runPromise(adapter.listLocales().pipe(Effect.provide(NodePlatformLayer)));
|
|
91
|
+
expect(locales).toContain('en');
|
|
92
|
+
expect(locales).toContain('de');
|
|
93
|
+
|
|
94
|
+
const resources = await Effect.runPromise(
|
|
95
|
+
adapter.listResources('en').pipe(Effect.provide(NodePlatformLayer)),
|
|
96
|
+
);
|
|
97
|
+
expect(resources.map((r) => r.key)).toContain('validation');
|
|
98
|
+
|
|
99
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('finds unused keys', async () => {
|
|
103
|
+
mkdirSync(join(testDir, 'en'), { recursive: true });
|
|
104
|
+
writeFileSync(join(testDir, 'en', 'validation.php'), "<?php return ['email' => 'Email'];");
|
|
105
|
+
mkdirSync(join(testDir, 'views'), { recursive: true });
|
|
106
|
+
writeFileSync(join(testDir, 'views', 'page.blade.php'), "__('validation.email')");
|
|
107
|
+
|
|
108
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [join(testDir, 'views')] });
|
|
109
|
+
const program = adapter
|
|
110
|
+
.findUnusedKeys!('en', { key: 'validation', label: 'validation' })
|
|
111
|
+
.pipe(Effect.provide(NodePlatformLayer));
|
|
112
|
+
const result = await Effect.runPromise(program);
|
|
113
|
+
expect(result).toEqual([]);
|
|
114
|
+
|
|
115
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.skipIf(!hasPhpBinary())('reads nested PHP arrays', async () => {
|
|
119
|
+
mkdirSync(join(testDir, 'en'), { recursive: true });
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(testDir, 'en', 'nested.php'),
|
|
122
|
+
"<?php return ['validation' => ['email' => 'Email', 'required' => 'Required']];",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
126
|
+
const program = adapter.readResource('en', { key: 'nested', label: 'nested' }).pipe(
|
|
127
|
+
Effect.provide(NodePlatformLayer),
|
|
128
|
+
);
|
|
129
|
+
const result = await Effect.runPromise(program);
|
|
130
|
+
expect(result).toEqual({
|
|
131
|
+
'validation.email': 'Email',
|
|
132
|
+
'validation.required': 'Required',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns empty for missing JSON locale file', async () => {
|
|
139
|
+
mkdirSync(testDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
142
|
+
const program = adapter.readResource('en', { key: 'json', label: 'en.json' }).pipe(
|
|
143
|
+
Effect.provide(NodePlatformLayer),
|
|
144
|
+
);
|
|
145
|
+
const result = await Effect.runPromise(program);
|
|
146
|
+
expect(result).toEqual({});
|
|
147
|
+
|
|
148
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles capabilities flags', () => {
|
|
152
|
+
const adapter = laravel({ langDir: testDir });
|
|
153
|
+
expect(adapter.capabilities.canCreateResource).toBe(true);
|
|
154
|
+
expect(adapter.capabilities.unusedKeyDetection).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('reports adapter name', () => {
|
|
158
|
+
const adapter = laravel({ langDir: testDir });
|
|
159
|
+
expect(adapter.name).toBe('laravel');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.skipIf(!hasPhpBinary())('writes JSON locale files', async () => {
|
|
163
|
+
mkdirSync(testDir, { recursive: true });
|
|
164
|
+
|
|
165
|
+
const adapter = laravel({ langDir: testDir, scanPaths: [testDir] });
|
|
166
|
+
const writeProgram = adapter
|
|
167
|
+
.writeResource('en', { key: 'json', label: 'en.json' }, { greeting: 'Hello' })
|
|
168
|
+
.pipe(Effect.provide(NodePlatformLayer));
|
|
169
|
+
await Effect.runPromise(writeProgram);
|
|
170
|
+
|
|
171
|
+
const readProgram = adapter.readResource('en', { key: 'json', label: 'en.json' }).pipe(
|
|
172
|
+
Effect.provide(NodePlatformLayer),
|
|
173
|
+
);
|
|
174
|
+
const result = await Effect.runPromise(readProgram);
|
|
175
|
+
expect(result).toEqual({ greeting: 'Hello' });
|
|
176
|
+
|
|
177
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { Path } from '@effect/platform/Path';
|
|
3
|
+
import type {
|
|
4
|
+
ResourceRef,
|
|
5
|
+
TranslationAdapter,
|
|
6
|
+
AdapterReadError,
|
|
7
|
+
AdapterWriteError,
|
|
8
|
+
} from 'dialekt';
|
|
9
|
+
import {
|
|
10
|
+
AdapterReadError as AdapterReadErrorClass,
|
|
11
|
+
AdapterWriteError as AdapterWriteErrorClass,
|
|
12
|
+
NodePlatformLayer,
|
|
13
|
+
flattenObject,
|
|
14
|
+
unflattenObject,
|
|
15
|
+
readPhpArrayAsJson,
|
|
16
|
+
readFileIfExists,
|
|
17
|
+
writeFileEnsuringDir,
|
|
18
|
+
} from 'dialekt';
|
|
19
|
+
import { renderPhpFile } from './php-array-writer.js';
|
|
20
|
+
import { listLaravelLocales, listLaravelResources } from './resources.js';
|
|
21
|
+
import { findUnusedLaravelKeys } from './unused-keys.js';
|
|
22
|
+
|
|
23
|
+
export interface LaravelAdapterOptions {
|
|
24
|
+
readonly langDir: string;
|
|
25
|
+
readonly phpBinary?: string;
|
|
26
|
+
readonly scanPaths?: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function laravel(options: LaravelAdapterOptions): TranslationAdapter {
|
|
30
|
+
const { langDir, scanPaths = [] } = options;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
name: 'laravel',
|
|
34
|
+
capabilities: {
|
|
35
|
+
canCreateResource: true,
|
|
36
|
+
unusedKeyDetection: true,
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
listLocales: () => listLaravelLocales(langDir).pipe(Effect.provide(NodePlatformLayer)),
|
|
40
|
+
|
|
41
|
+
listResources: (locale) => listLaravelResources(langDir, locale).pipe(Effect.provide(NodePlatformLayer)),
|
|
42
|
+
|
|
43
|
+
readResource: (locale, resource) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const path = yield* Path;
|
|
46
|
+
|
|
47
|
+
if (resource.key === 'json') {
|
|
48
|
+
// JSON locale file
|
|
49
|
+
const filePath = path.join(langDir, `${locale}.json`);
|
|
50
|
+
const content = yield* readFileIfExists(filePath).pipe(
|
|
51
|
+
Effect.mapError(
|
|
52
|
+
(cause) =>
|
|
53
|
+
new AdapterReadErrorClass({
|
|
54
|
+
adapter: 'laravel',
|
|
55
|
+
locale,
|
|
56
|
+
resource: resource.key,
|
|
57
|
+
cause,
|
|
58
|
+
}) as AdapterReadError,
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
if (content === null) return {};
|
|
62
|
+
return yield* Effect.try({
|
|
63
|
+
try: () => JSON.parse(content) as Record<string, string>,
|
|
64
|
+
catch: (cause) =>
|
|
65
|
+
new AdapterReadErrorClass({
|
|
66
|
+
adapter: 'laravel',
|
|
67
|
+
locale,
|
|
68
|
+
resource: resource.key,
|
|
69
|
+
cause,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// PHP domain file
|
|
75
|
+
const filePath = path.join(langDir, locale, `${resource.key}.php`);
|
|
76
|
+
const result = yield* readPhpArrayAsJson(filePath).pipe(
|
|
77
|
+
Effect.catchTag('PhpExecutionError', () => Effect.succeed({})),
|
|
78
|
+
Effect.mapError(
|
|
79
|
+
(cause) =>
|
|
80
|
+
new AdapterReadErrorClass({
|
|
81
|
+
adapter: 'laravel',
|
|
82
|
+
locale,
|
|
83
|
+
resource: resource.key,
|
|
84
|
+
cause,
|
|
85
|
+
}) as AdapterReadError,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
return flattenObject(result);
|
|
89
|
+
}).pipe(Effect.provide([NodePlatformLayer])) as Effect.Effect<Record<string, string>, AdapterReadError, never>,
|
|
90
|
+
|
|
91
|
+
writeResource: (locale, resource, entries) =>
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const path = yield* Path;
|
|
94
|
+
|
|
95
|
+
if (resource.key === 'json') {
|
|
96
|
+
// JSON locale file
|
|
97
|
+
const filePath = path.join(langDir, `${locale}.json`);
|
|
98
|
+
yield* writeFileEnsuringDir(filePath, JSON.stringify(entries, null, 2)).pipe(
|
|
99
|
+
Effect.mapError(
|
|
100
|
+
(cause) =>
|
|
101
|
+
new AdapterWriteErrorClass({
|
|
102
|
+
adapter: 'laravel',
|
|
103
|
+
locale,
|
|
104
|
+
resource: resource.key,
|
|
105
|
+
cause,
|
|
106
|
+
}) as AdapterWriteError,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// PHP domain file
|
|
113
|
+
const filePath = path.join(langDir, locale, `${resource.key}.php`);
|
|
114
|
+
const nested = unflattenObject(entries);
|
|
115
|
+
yield* writeFileEnsuringDir(filePath, renderPhpFile(nested)).pipe(
|
|
116
|
+
Effect.mapError(
|
|
117
|
+
(cause) =>
|
|
118
|
+
new AdapterWriteErrorClass({
|
|
119
|
+
adapter: 'laravel',
|
|
120
|
+
locale,
|
|
121
|
+
resource: resource.key,
|
|
122
|
+
cause,
|
|
123
|
+
}) as AdapterWriteError,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
}).pipe(Effect.provide([NodePlatformLayer])) as Effect.Effect<void, AdapterWriteError, never>,
|
|
127
|
+
|
|
128
|
+
findUnusedKeys: (locale, resource) =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const path = yield* Path;
|
|
131
|
+
const adapterScanPaths =
|
|
132
|
+
scanPaths.length > 0 ? scanPaths : [path.resolve(langDir, '..')];
|
|
133
|
+
const keys = yield* Effect.gen(function* () {
|
|
134
|
+
const map = yield* laravel(options).readResource(locale, resource);
|
|
135
|
+
return Object.keys(map);
|
|
136
|
+
});
|
|
137
|
+
return yield* findUnusedLaravelKeys(adapterScanPaths, resource.key, keys);
|
|
138
|
+
}).pipe(Effect.provide([NodePlatformLayer])) as Effect.Effect<readonly string[], AdapterReadError, never>,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderPhpArray, renderPhpFile } from './php-array-writer.js';
|
|
3
|
+
|
|
4
|
+
describe('renderPhpArray', () => {
|
|
5
|
+
it('renders an empty array', () => {
|
|
6
|
+
expect(renderPhpArray({})).toBe('[]');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('renders flat key-value pairs', () => {
|
|
10
|
+
const result = renderPhpArray({ hello: 'Hello' });
|
|
11
|
+
expect(result).toContain("'hello' => 'Hello'");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renders nested objects', () => {
|
|
15
|
+
const result = renderPhpArray({ validation: { email: 'Email address' } });
|
|
16
|
+
expect(result).toContain("'validation' => [");
|
|
17
|
+
expect(result).toContain(" 'email' => 'Email address',");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('escapes single quotes', () => {
|
|
21
|
+
const result = renderPhpArray({ key: "it's" });
|
|
22
|
+
expect(result).toContain("'it\\'s'");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('escapes backslashes', () => {
|
|
26
|
+
const result = renderPhpArray({ key: 'a\\b' });
|
|
27
|
+
expect(result).toContain("'a\\\\b'");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('preserves unicode literally', () => {
|
|
31
|
+
const result = renderPhpArray({ key: 'Héllo 🌍 — 日本語' });
|
|
32
|
+
expect(result).toContain('Héllo 🌍 — 日本語');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('preserves empty string', () => {
|
|
36
|
+
const result = renderPhpArray({ key: '' });
|
|
37
|
+
expect(result).toContain("'key' => '',");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('preserves double quotes without escaping', () => {
|
|
41
|
+
const result = renderPhpArray({ key: 'He said "hi"' });
|
|
42
|
+
expect(result).toContain('"hi"');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders numeric keys without quotes', () => {
|
|
46
|
+
const result = renderPhpArray({ 0: 'first', 1: 'second' });
|
|
47
|
+
expect(result).toContain("0 => 'first'");
|
|
48
|
+
expect(result).toContain("1 => 'second'");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('renderPhpFile', () => {
|
|
53
|
+
it('wraps in PHP tags', () => {
|
|
54
|
+
const result = renderPhpFile({ hello: 'World' });
|
|
55
|
+
expect(result.startsWith('<?php\n\nreturn ')).toBe(true);
|
|
56
|
+
expect(result.endsWith(';\n')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function phpVarExport(value: string): string {
|
|
2
|
+
// Wrap in single quotes; escape backslashes and single quotes only.
|
|
3
|
+
// This matches PHP var_export() semantics for plain strings.
|
|
4
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
5
|
+
return `'${escaped}'`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function renderPhpArray(
|
|
9
|
+
value: Record<string, unknown>,
|
|
10
|
+
indent = 0,
|
|
11
|
+
): string {
|
|
12
|
+
const entries = Object.entries(value);
|
|
13
|
+
if (entries.length === 0) {
|
|
14
|
+
return '[]';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pad = ' '.repeat(indent);
|
|
18
|
+
const innerPad = ' '.repeat(indent + 1);
|
|
19
|
+
const lines: string[] = ['['];
|
|
20
|
+
|
|
21
|
+
for (const [key, val] of entries) {
|
|
22
|
+
const renderedKey = /^\d+$/.test(key) ? key : phpVarExport(key);
|
|
23
|
+
const renderedValue =
|
|
24
|
+
typeof val === 'object' && val !== null && !Array.isArray(val)
|
|
25
|
+
? renderPhpArray(val as Record<string, unknown>, indent + 1)
|
|
26
|
+
: typeof val === 'string'
|
|
27
|
+
? phpVarExport(val)
|
|
28
|
+
: String(val);
|
|
29
|
+
lines.push(`${innerPad}${renderedKey} => ${renderedValue},`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
lines.push(`${pad}]`);
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderPhpFile(value: Record<string, unknown>): string {
|
|
37
|
+
return `<?php\n\nreturn ${renderPhpArray(value, 0)};\n`;
|
|
38
|
+
}
|