@i18n-micro/devtools-ui 1.2.0 → 1.2.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.
@@ -0,0 +1,7 @@
1
+ import { PluginOption } from 'vite';
2
+ export interface DevToolsPluginOptions {
3
+ base?: string;
4
+ translationDir?: string;
5
+ injectButton?: boolean;
6
+ }
7
+ export declare function i18nDevToolsPlugin(options?: DevToolsPluginOptions): PluginOption;
@@ -1,4 +1,4 @@
1
- import type { PluginOption } from 'vite';
1
+ import { PluginOption } from 'vite';
2
2
  export interface DevToolsPluginOptions {
3
3
  base?: string;
4
4
  translationDir?: string;
package/package.json CHANGED
@@ -1,53 +1,80 @@
1
1
  {
2
2
  "name": "@i18n-micro/devtools-ui",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
+ "description": "DevTools UI and Vite plugin for Nuxt I18n Micro.",
5
+ "license": "MIT",
4
6
  "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/s00d/nuxt-i18n-micro.git",
11
+ "directory": "packages/devtools-ui"
12
+ },
13
+ "homepage": "https://github.com/s00d/nuxt-i18n-micro/tree/main/packages/devtools-ui#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/s00d/nuxt-i18n-micro/issues"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
5
20
  "main": "./dist/index.umd.js",
6
- "module": "./dist/index.es.js",
7
21
  "types": "./dist/index.d.ts",
8
22
  "exports": {
9
23
  ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.es.js",
12
- "require": "./dist/index.umd.js"
24
+ "import": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.es.js"
27
+ },
28
+ "require": {
29
+ "types": "./dist/index.d.cts",
30
+ "default": "./dist/index.umd.js"
31
+ },
32
+ "default": "./dist/index.es.js"
13
33
  },
14
34
  "./bridge": {
15
35
  "types": "./dist/bridge/interface.d.ts",
16
- "import": "./dist/bridge/interface.js"
36
+ "import": "./dist/bridge/interface.js",
37
+ "default": "./dist/bridge/interface.js"
17
38
  },
18
39
  "./bridge/create": {
19
40
  "types": "./dist/bridge/create-bridge.d.ts",
20
- "import": "./dist/bridge/create-bridge.js"
41
+ "import": "./dist/bridge/create-bridge.js",
42
+ "default": "./dist/bridge/create-bridge.js"
21
43
  },
22
44
  "./vite": {
23
45
  "types": "./dist/vite/plugin.d.ts",
24
- "import": "./dist/vite/plugin.mjs"
25
- }
46
+ "import": "./dist/vite/plugin.mjs",
47
+ "default": "./dist/vite/plugin.mjs"
48
+ },
49
+ "./package.json": "./package.json"
26
50
  },
27
51
  "files": [
28
52
  "dist",
29
- "vite"
53
+ "LICENSE",
54
+ "README.md"
30
55
  ],
31
56
  "publishConfig": {
32
57
  "access": "public"
33
58
  },
34
59
  "dependencies": {
35
60
  "vue": "^3.5.25",
36
- "@i18n-micro/types": "1.1.5"
61
+ "@i18n-micro/types": "1.2.2"
37
62
  },
38
63
  "devDependencies": {
39
64
  "@tailwindcss/vite": "^4.1.17",
40
65
  "@vitejs/plugin-vue": "^6.0.3",
41
66
  "autoprefixer": "^10.4.22",
42
67
  "postcss": "^8.5.6",
68
+ "publint": "^0.3.17",
43
69
  "tailwindcss": "^4.1.17",
44
70
  "vite": "^7.3.1",
45
- "vite-plugin-dts": "^4.5.4"
71
+ "vite-plugin-dts": "^4.5.4",
72
+ "vitest": "^3.2.4"
46
73
  },
47
74
  "scripts": {
48
- "build": "vite build && vite build --config vite.config.plugin.mjs && npm run build:plugin-types",
49
- "build:plugin": "vite build --config vite.config.plugin.mjs",
50
- "build:plugin-types": "tsc vite/plugin.ts --declaration --emitDeclarationOnly --outDir dist/vite --module esnext --target es2020 --moduleResolution node --esModuleInterop --skipLibCheck",
75
+ "build": "vite build && vite build --mode umd && vite build --mode plugin",
76
+ "check:package": "publint",
77
+ "test:dist": "vitest run --config vitest.dist.config.ts",
51
78
  "typecheck": "tsc --noEmit"
52
79
  }
53
80
  }
package/dist/env.d.ts DELETED
@@ -1 +0,0 @@
1
- /// <reference types="vite/client" />
package/vite/plugin.ts DELETED
@@ -1,583 +0,0 @@
1
- import * as fs from 'node:fs'
2
- import { readdir } from 'node:fs/promises'
3
- import type { IncomingMessage, ServerResponse } from 'node:http'
4
- import * as path from 'node:path'
5
- import type { IndexHtmlTransformResult, PluginOption, ViteDevServer } from 'vite'
6
-
7
- export interface DevToolsPluginOptions {
8
- base?: string
9
- translationDir?: string
10
- injectButton?: boolean
11
- }
12
-
13
- // Helper function for safe path resolution
14
- function safeResolvePath(projectRoot: string, filePath: string): string {
15
- const normalizedFile = filePath.replace(/^\/+/, '').replace(/\/+/g, '/')
16
- const resolvedPath = path.resolve(projectRoot, normalizedFile)
17
- const normalizedRoot = path.resolve(projectRoot)
18
- const normalizedFilePath = path.resolve(resolvedPath)
19
-
20
- if (!normalizedFilePath.startsWith(normalizedRoot)) {
21
- throw new Error(`Access denied: Path ${resolvedPath} is outside project root`)
22
- }
23
-
24
- return resolvedPath
25
- }
26
-
27
- // Recursive directory scanning to find JSON files
28
- async function scanTranslationFiles(dir: string, baseDir: string): Promise<string[]> {
29
- const files: string[] = []
30
- try {
31
- const entries = await readdir(dir, { withFileTypes: true })
32
- for (const entry of entries) {
33
- const fullPath = path.join(dir, entry.name)
34
- if (entry.isDirectory()) {
35
- const subFiles = await scanTranslationFiles(fullPath, baseDir)
36
- files.push(...subFiles)
37
- } else if (entry.isFile() && entry.name.endsWith('.json')) {
38
- const relativePath = path.relative(baseDir, fullPath)
39
- files.push(relativePath.replace(/\\/g, '/')) // Normalize for cross-platform compatibility
40
- }
41
- }
42
- } catch (error) {
43
- // Ignore directory access errors
44
- console.warn(`[i18n-devtools] Cannot scan directory ${dir}:`, error)
45
- }
46
- return files
47
- }
48
-
49
- // Script for button injection
50
- const BUTTON_INJECTION_SCRIPT = `
51
- (function() {
52
- if (typeof window === 'undefined' || document.getElementById('i18n-devtools-button-container')) {
53
- return;
54
- }
55
-
56
- const container = document.createElement('div');
57
- container.id = 'i18n-devtools-button-container';
58
- container.style.cssText = 'position: fixed; bottom: 20px; right: 20px; z-index: 99999; pointer-events: none;';
59
- document.body.appendChild(container);
60
-
61
- const button = document.createElement('button');
62
- button.id = 'i18n-devtools-button';
63
- button.type = 'button';
64
- button.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" style="display: inline-block; vertical-align: middle; margin-right: 8px;"><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>i18n';
65
- button.style.cssText = 'cursor: pointer; background: #1e1e1e; color: white; padding: 8px 16px; border-radius: 9999px; display: flex; align-items: center; gap: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #333; font-size: 14px; font-weight: 600; transition: all 0.3s ease; opacity: 0.3; pointer-events: auto; font-family: system-ui, sans-serif;';
66
-
67
- let hideTimeout = null;
68
- let isVisible = true;
69
- let lastMouseY = window.innerHeight;
70
-
71
- function showButton() {
72
- if (!isVisible) {
73
- button.style.transform = 'translateX(0)';
74
- button.style.opacity = '0.3';
75
- isVisible = true;
76
- }
77
- if (hideTimeout) {
78
- clearTimeout(hideTimeout);
79
- hideTimeout = null;
80
- }
81
- }
82
-
83
- function hideButton() {
84
- if (isVisible) {
85
- // Leave only a small part of the button visible (approximately 40px)
86
- button.style.transform = 'translateX(calc(100% - 40px))';
87
- button.style.opacity = '0.3';
88
- isVisible = false;
89
- }
90
- }
91
-
92
- function checkMouseDistance(e) {
93
- const buttonRect = button.getBoundingClientRect();
94
- const buttonCenterX = buttonRect.left + buttonRect.width / 2;
95
- const buttonCenterY = buttonRect.top + buttonRect.height / 2;
96
- const distanceX = Math.abs(e.clientX - buttonCenterX);
97
- const distanceY = Math.abs(e.clientY - buttonCenterY);
98
- const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
99
-
100
- if (distance > 200 && !button.matches(':hover')) {
101
- if (!hideTimeout) {
102
- hideTimeout = setTimeout(hideButton, 1000);
103
- }
104
- } else {
105
- if (hideTimeout) {
106
- clearTimeout(hideTimeout);
107
- hideTimeout = null;
108
- }
109
- if (!isVisible) {
110
- showButton();
111
- }
112
- }
113
- lastMouseY = e.clientY;
114
- }
115
-
116
- button.addEventListener('mouseenter', function() {
117
- this.style.opacity = '1';
118
- this.style.transform = 'translateX(0) translateY(-2px) scale(1.05)';
119
- if (hideTimeout) {
120
- clearTimeout(hideTimeout);
121
- hideTimeout = null;
122
- }
123
- // Show the button fully on hover
124
- if (!isVisible) {
125
- showButton();
126
- }
127
- });
128
-
129
- button.addEventListener('mouseleave', function() {
130
- if (isVisible) {
131
- this.style.opacity = '0.3';
132
- this.style.transform = 'translateX(0) scale(1)';
133
- hideTimeout = setTimeout(hideButton, 3000);
134
- }
135
- });
136
-
137
- button.addEventListener('click', function() {
138
- // Check if the editor is already open
139
- if (document.getElementById('i18n-devtools-modal')) {
140
- return;
141
- }
142
-
143
- // Open modal window with iframe
144
- const modal = document.createElement('div');
145
- modal.id = 'i18n-devtools-modal';
146
- modal.style.cssText = 'position: fixed; inset: 0; z-index: 999999; background: rgba(0, 0, 0, 0.2); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; padding: 2rem;';
147
- modal.addEventListener('click', function(e) {
148
- if (e.target === modal) {
149
- document.body.removeChild(modal);
150
- }
151
- });
152
-
153
- const content = document.createElement('div');
154
- content.style.cssText = 'background: white; width: 90vw; max-width: 1200px; height: 85vh; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #e5e7eb;';
155
- content.addEventListener('click', function(e) {
156
- e.stopPropagation();
157
- });
158
-
159
- const header = document.createElement('div');
160
- header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; background: #f8fafc; flex-shrink: 0;';
161
- header.innerHTML = '<div style="font-weight: 600; color: #334155; display: flex; align-items: center; gap: 8px;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg> i18n DevTools</div><button id="i18n-devtools-close" style="background: transparent; border: none; cursor: pointer; padding: 4px; border-radius: 4px; display: flex; color: #64748b; transition: background 0.2s;">✕</button>';
162
-
163
- const closeBtn = header.querySelector('#i18n-devtools-close');
164
- closeBtn.addEventListener('mouseenter', function() {
165
- this.style.background = '#e2e8f0';
166
- });
167
- closeBtn.addEventListener('mouseleave', function() {
168
- this.style.background = 'transparent';
169
- });
170
- closeBtn.addEventListener('click', function() {
171
- document.body.removeChild(modal);
172
- });
173
-
174
- const iframeContainer = document.createElement('div');
175
- iframeContainer.style.cssText = 'flex: 1; position: relative; overflow: hidden; background: white;';
176
-
177
- const iframe = document.createElement('iframe');
178
- iframe.id = 'i18n-devtools-iframe';
179
- iframe.style.cssText = 'width: 100%; height: 100%; border: none; background: white;';
180
- iframe.src = '/__i18n_devtools.html';
181
- iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
182
-
183
- iframeContainer.appendChild(iframe);
184
- content.appendChild(header);
185
- content.appendChild(iframeContainer);
186
- modal.appendChild(content);
187
- document.body.appendChild(modal);
188
- });
189
-
190
- document.addEventListener('mousemove', checkMouseDistance);
191
-
192
- // Auto-hide after 3 seconds of loading
193
- setTimeout(() => {
194
- if (!button.matches(':hover')) {
195
- hideTimeout = setTimeout(hideButton, 3000);
196
- }
197
- }, 3000);
198
-
199
- container.appendChild(button);
200
- })();
201
- `
202
-
203
- export function i18nDevToolsPlugin(options: DevToolsPluginOptions = {}): PluginOption {
204
- const apiBase = options.base || '/__i18n_api'
205
- const translationDir = options.translationDir || 'src/locales'
206
- const injectButton = options.injectButton !== false // Default is true
207
-
208
- return {
209
- name: 'i18n-micro-devtools-plugin',
210
- apply: 'serve', // Works only in dev server
211
-
212
- resolveId(id: string) {
213
- if (id === '/@vite-plugin-i18n-devtools/devtools-ui.js') {
214
- return id
215
- }
216
- return null
217
- },
218
-
219
- load(id: string) {
220
- if (id === '/@vite-plugin-i18n-devtools/devtools-ui.js') {
221
- // Return re-export from devtools-ui package
222
- return `export * from '@i18n-micro/devtools-ui'`
223
- }
224
- return null
225
- },
226
-
227
- transformIndexHtml(html: string): IndexHtmlTransformResult {
228
- if (!injectButton) {
229
- return html
230
- }
231
-
232
- // Inject script before the closing </body> tag
233
- const scriptTag = `<script>${BUTTON_INJECTION_SCRIPT}</script>`
234
- if (html.includes('</body>')) {
235
- return html.replace('</body>', `${scriptTag}</body>`)
236
- }
237
- // If no </body>, append to the end
238
- return html + scriptTag
239
- },
240
-
241
- configureServer(server: ViteDevServer) {
242
- // Use server.config.root as the source of truth for the project root
243
- const projectRoot = server.config.root
244
-
245
- // HTML page for iframe with devtools UI
246
- // Use virtual module for proper imports
247
- const devtoolsHtml = `<!DOCTYPE html>
248
- <html lang="en">
249
- <head>
250
- <meta charset="UTF-8">
251
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
252
- <title>i18n DevTools</title>
253
- <style>
254
- * {
255
- margin: 0;
256
- padding: 0;
257
- box-sizing: border-box;
258
- }
259
- body {
260
- font-family: system-ui, -apple-system, sans-serif;
261
- overflow: hidden;
262
- height: 100vh;
263
- }
264
- #app {
265
- width: 100%;
266
- height: 100%;
267
- }
268
- .loading {
269
- display: flex;
270
- align-items: center;
271
- justify-content: center;
272
- height: 100%;
273
- color: #64748b;
274
- }
275
- </style>
276
- </head>
277
- <body>
278
- <div id="app">
279
- <div class="loading">Loading DevTools...</div>
280
- </div>
281
- <script type="module">
282
- // Import devtools UI
283
- // Try to import through different paths for compatibility
284
- let register;
285
- try {
286
- const devtoolsModule = await import('/@vite-plugin-i18n-devtools/devtools-ui.js');
287
- register = devtoolsModule.register;
288
- } catch (e) {
289
- // Fallback: try to import directly
290
- try {
291
- const devtoolsModule = await import('@i18n-micro/devtools-ui');
292
- register = devtoolsModule.register;
293
- } catch (e2) {
294
- console.error('Failed to load devtools UI:', e2);
295
- document.getElementById('app').innerHTML = '<div class="loading" style="color: #ef4444;">Failed to load DevTools. Please ensure @i18n-micro/devtools-ui is installed.</div>';
296
- throw e2;
297
- }
298
- }
299
-
300
- // Register custom element
301
- register();
302
-
303
- // Create bridge via API
304
- const bridge = {
305
- async getLocalesAndTranslations() {
306
- try {
307
- const response = await fetch('/__i18n_api/files');
308
- const data = await response.json();
309
- const result = {};
310
- for (const file of data.files || []) {
311
- try {
312
- const fileResponse = await fetch('/__i18n_api/file?path=' + encodeURIComponent(file));
313
- const fileData = await fileResponse.json();
314
- if (fileData.success && fileData.content) {
315
- result[file] = fileData.content;
316
- }
317
- } catch (e) {
318
- console.warn('Failed to load file:', file, e);
319
- }
320
- }
321
- return result;
322
- } catch (e) {
323
- console.error('Failed to get locales:', e);
324
- return {};
325
- }
326
- },
327
-
328
- async getConfigs() {
329
- try {
330
- const response = await fetch('/__i18n_api/config');
331
- return await response.json();
332
- } catch (e) {
333
- return {
334
- defaultLocale: 'en',
335
- fallbackLocale: 'en',
336
- locales: [],
337
- translationDir: '${translationDir}',
338
- };
339
- }
340
- },
341
-
342
- async saveTranslation(filePath, content) {
343
- const response = await fetch('/__i18n_api/save', {
344
- method: 'POST',
345
- headers: { 'Content-Type': 'application/json' },
346
- body: JSON.stringify({ file: filePath, content }),
347
- });
348
- if (!response.ok) {
349
- const error = await response.json().catch(() => ({ error: 'Unknown error' }));
350
- throw new Error(error.error || 'Failed to save');
351
- }
352
- },
353
-
354
- onLocalesUpdate(callback) {
355
- let interval = null;
356
- interval = setInterval(async () => {
357
- try {
358
- const data = await bridge.getLocalesAndTranslations();
359
- callback(data);
360
- } catch (e) {
361
- console.error('Failed to update locales:', e);
362
- }
363
- }, 2000);
364
- return () => {
365
- if (interval) {
366
- clearInterval(interval);
367
- }
368
- };
369
- },
370
- };
371
-
372
- const app = document.getElementById('app');
373
- app.innerHTML = '';
374
- const element = document.createElement('i18n-devtools-ui');
375
- element.bridge = bridge;
376
- element.style.cssText = 'width: 100%; height: 100%; display: block;';
377
- app.appendChild(element);
378
- </script>
379
- </body>
380
- </html>`
381
-
382
- // Middleware for serving the devtools HTML page
383
- server.middlewares.use('/__i18n_devtools.html', (_req: IncomingMessage, res: ServerResponse) => {
384
- res.statusCode = 200
385
- res.setHeader('Content-Type', 'text/html; charset=utf-8')
386
- res.end(devtoolsHtml)
387
- })
388
-
389
- // Endpoint for getting configuration
390
- server.middlewares.use(`${apiBase}/config`, async (_req: IncomingMessage, res: ServerResponse, next) => {
391
- if (_req.method !== 'GET') {
392
- return next()
393
- }
394
-
395
- try {
396
- res.statusCode = 200
397
- res.setHeader('Content-Type', 'application/json')
398
- res.end(
399
- JSON.stringify({
400
- defaultLocale: 'en',
401
- fallbackLocale: 'en',
402
- locales: [],
403
- translationDir,
404
- }),
405
- )
406
- } catch (e) {
407
- console.error('[i18n-devtools] Config error:', e)
408
- res.statusCode = 500
409
- res.setHeader('Content-Type', 'application/json')
410
- res.end(
411
- JSON.stringify({
412
- success: false,
413
- error: e instanceof Error ? e.message : String(e),
414
- }),
415
- )
416
- }
417
- })
418
-
419
- // Endpoint for getting file list
420
- server.middlewares.use(`${apiBase}/files`, async (req: IncomingMessage, res: ServerResponse, next) => {
421
- if (req.method !== 'GET') {
422
- return next()
423
- }
424
-
425
- try {
426
- const localesPath = path.resolve(projectRoot, translationDir)
427
-
428
- // Verify the path is safe
429
- safeResolvePath(projectRoot, translationDir)
430
-
431
- if (!fs.existsSync(localesPath)) {
432
- res.statusCode = 200
433
- res.setHeader('Content-Type', 'application/json')
434
- res.end(JSON.stringify({ files: [], structure: {} }))
435
- return
436
- }
437
-
438
- const files = await scanTranslationFiles(localesPath, localesPath)
439
-
440
- // Build directory structure
441
- const structure: Record<string, unknown> = {}
442
- for (const file of files) {
443
- const parts = file.split('/')
444
- let current = structure
445
- for (let i = 0; i < parts.length - 1; i++) {
446
- const part = parts[i]!
447
- if (!current[part]) {
448
- current[part] = {}
449
- }
450
- current = current[part] as Record<string, unknown>
451
- }
452
- const last = parts[parts.length - 1]
453
- if (last !== undefined) {
454
- current[last] = file
455
- }
456
- }
457
-
458
- res.statusCode = 200
459
- res.setHeader('Content-Type', 'application/json')
460
- res.end(JSON.stringify({ files, structure }))
461
- } catch (e) {
462
- console.error('[i18n-devtools] Files list error:', e)
463
- res.statusCode = 500
464
- res.setHeader('Content-Type', 'application/json')
465
- res.end(
466
- JSON.stringify({
467
- success: false,
468
- error: e instanceof Error ? e.message : String(e),
469
- }),
470
- )
471
- }
472
- })
473
-
474
- // Endpoint for loading a specific file
475
- server.middlewares.use(`${apiBase}/file`, async (req: IncomingMessage, res: ServerResponse, next) => {
476
- if (req.method !== 'GET') {
477
- return next()
478
- }
479
-
480
- try {
481
- const url = new URL(req.url || '', `http://${req.headers.host}`)
482
- const filePath = url.searchParams.get('path')
483
-
484
- if (!filePath) {
485
- throw new Error('Path parameter is required')
486
- }
487
-
488
- // Resolve path relative to translationDir
489
- const fullPath = path.join(translationDir, filePath)
490
- const resolvedPath = safeResolvePath(projectRoot, fullPath)
491
-
492
- if (!fs.existsSync(resolvedPath)) {
493
- res.statusCode = 404
494
- res.setHeader('Content-Type', 'application/json')
495
- res.end(JSON.stringify({ success: false, error: 'File not found' }))
496
- return
497
- }
498
-
499
- if (!resolvedPath.endsWith('.json')) {
500
- throw new Error('Invalid file: only .json files are allowed')
501
- }
502
-
503
- const content = fs.readFileSync(resolvedPath, 'utf-8')
504
- const parsed = JSON.parse(content)
505
-
506
- res.statusCode = 200
507
- res.setHeader('Content-Type', 'application/json')
508
- res.end(JSON.stringify({ success: true, content: parsed, path: filePath }))
509
- } catch (e) {
510
- console.error('[i18n-devtools] File read error:', e)
511
- res.statusCode = 500
512
- res.setHeader('Content-Type', 'application/json')
513
- res.end(
514
- JSON.stringify({
515
- success: false,
516
- error: e instanceof Error ? e.message : String(e),
517
- }),
518
- )
519
- }
520
- })
521
-
522
- // Endpoint for saving a file
523
- server.middlewares.use(`${apiBase}/save`, async (req: IncomingMessage, res: ServerResponse, next) => {
524
- if (req.method !== 'POST') {
525
- return next()
526
- }
527
-
528
- try {
529
- // Read request body
530
- const buffers: Buffer[] = []
531
- for await (const chunk of req) {
532
- buffers.push(chunk)
533
- }
534
- const bodyData = Buffer.concat(buffers).toString()
535
-
536
- if (!bodyData) {
537
- throw new Error('Empty request body')
538
- }
539
-
540
- const body = JSON.parse(bodyData)
541
- const { file, content } = body
542
-
543
- if (!file || !content) {
544
- throw new Error('Invalid data: file and content are required')
545
- }
546
-
547
- // Resolve path relative to translationDir if path is not absolute
548
- const fullPath = file.startsWith(translationDir) ? file : path.join(translationDir, file)
549
- const filePath = safeResolvePath(projectRoot, fullPath)
550
-
551
- // Check extension
552
- if (!filePath.endsWith('.json')) {
553
- throw new Error('Invalid file: only .json files are allowed')
554
- }
555
-
556
- // Create directory if it doesn't exist
557
- const dir = path.dirname(filePath)
558
- if (!fs.existsSync(dir)) {
559
- fs.mkdirSync(dir, { recursive: true })
560
- }
561
-
562
- // Write file
563
- fs.writeFileSync(filePath, JSON.stringify(content, null, 2), 'utf-8')
564
-
565
- // Successful response
566
- res.statusCode = 200
567
- res.setHeader('Content-Type', 'application/json')
568
- res.end(JSON.stringify({ success: true }))
569
- } catch (e) {
570
- console.error('[i18n-devtools] Save error:', e)
571
- res.statusCode = 500
572
- res.setHeader('Content-Type', 'application/json')
573
- res.end(
574
- JSON.stringify({
575
- success: false,
576
- error: e instanceof Error ? e.message : String(e),
577
- }),
578
- )
579
- }
580
- })
581
- },
582
- } as PluginOption
583
- }