@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 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
+ ```
@@ -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,2 @@
1
+ export { laravel } from './adapter.js';
2
+ export type { LaravelAdapterOptions } from './adapter.js';
@@ -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
+ }