vite-plugin-l10n-c 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blosharper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # vite-plugin-l10n-c
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Development-only translation editor for Vite 5+ (Node 22.12+; bundles target Node 22).
4
+
5
+ ```ts
6
+ import i18n from 'vite-plugin-l10n-c'
7
+ export default { plugins: [i18n({ baseLanguage: 'en' })] }
8
+ ```
9
+
10
+ Open `/i18n/` on the dev server. Inline options override
11
+ `l10n-c.config.json` in the Vite root. The package ships its editor assets.
12
+ It adds no production build or preview middleware. GET `/i18n/api/locales`
13
+ returns flattened language dictionaries; POST `/i18n/api/publish` accepts
14
+ `{ changes: { en: { 'ui.start': 'Start' } } }`. Publish writes local files.
15
+ Only exact API routes match. Language/key validation rejects traversal and
16
+ prototype keys before writes; cross-origin write requests are rejected.
17
+
18
+ Configuration uses the same options as `l10n-c`: `localesDir` (default
19
+ `public/locales`), `baseLanguage` (default `ko`), `sourcePaths`, `sourceIgnore`,
20
+ `mirrors`, `allowedRoots`, `referencePrefixes` and `languageOrder`. Paths resolve
21
+ from the Vite project root. For example:
22
+
23
+ ```json
24
+ {
25
+ "localesDir": "messages",
26
+ "baseLanguage": "en",
27
+ "languageOrder": ["en", "fr"]
28
+ }
29
+ ```
30
+
31
+ Each `messages/en/ui.json` owns its `ui` root. Use the editor to search keys or
32
+ values, filter domains/languages, click a cell to edit and **Publish** to save.
33
+ CSV exports the filtered rows with drafts. The fixed `/i18n/` routes also apply
34
+ when Vite has a base path. File writes are sequential; a filesystem failure may
35
+ leave partial writes. Keep the dev server on a trusted development machine.
36
+
37
+ The tarball includes ESM/CommonJS builds, types, editor HTML/JS, README and MIT
38
+ license. See the [full documentation](https://github.com/Blosharper/l10n-c#readme)
39
+ for compatibility options and CLI checks.
40
+
41
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ default: () => i18nPlugin
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+ var import_promises = __toESM(require("fs/promises"), 1);
37
+ var import_node_path = __toESM(require("path"), 1);
38
+ var import_node_url = require("url");
39
+ var import_l10n_c = require("l10n-c");
40
+ var import_meta = {};
41
+ var assetsDir = typeof __dirname !== "undefined" ? import_node_path.default.resolve(__dirname, "../ui") : (0, import_node_url.fileURLToPath)(new URL("../ui", import_meta.url));
42
+ async function readChanges(req) {
43
+ const origin = req.headers.origin;
44
+ if (origin && new URL(origin).host !== req.headers.host)
45
+ throw new Error("Cross-origin writes are not allowed");
46
+ if (!req.headers["content-type"]?.startsWith("application/json"))
47
+ throw new Error("Expected application/json");
48
+ let body = "";
49
+ for await (const chunk of req) {
50
+ body += chunk.toString();
51
+ if (Buffer.byteLength(body) > 2 * 1024 * 1024)
52
+ throw new Error("Payload exceeds 2 MiB");
53
+ }
54
+ const payload = JSON.parse(body);
55
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) || !("changes" in payload))
56
+ throw new Error("Expected { changes: { language: { key: value } } }");
57
+ const changes = payload.changes;
58
+ if (!changes || typeof changes !== "object" || Array.isArray(changes))
59
+ throw new Error("Changes must be an object");
60
+ for (const [language, data] of Object.entries(changes)) {
61
+ (0, import_l10n_c.assertSegment)(language, "language");
62
+ (0, import_l10n_c.validateChanges)(data);
63
+ }
64
+ return changes;
65
+ }
66
+ function i18nPlugin(options = {}) {
67
+ let store;
68
+ return {
69
+ name: "vite-plugin-l10n-c",
70
+ apply: "serve",
71
+ async configResolved(config) {
72
+ store = await (0, import_l10n_c.createI18n)(options, config.root);
73
+ },
74
+ configureServer(server) {
75
+ const localesDir = store.config.localesDir;
76
+ server.watcher.add(localesDir);
77
+ const onChange = (_event, file) => {
78
+ const relative = import_node_path.default.relative(localesDir, import_node_path.default.resolve(file));
79
+ const segments = relative.split(import_node_path.default.sep);
80
+ if (segments.length === 2 && !relative.startsWith("..") && segments[1].endsWith(".json")) {
81
+ server.ws.send("i18n-update", { locale: segments[0] });
82
+ }
83
+ };
84
+ server.watcher.on("all", onChange);
85
+ server.httpServer?.once(
86
+ "close",
87
+ () => server.watcher.off("all", onChange)
88
+ );
89
+ server.middlewares.use(async (req, res, next) => {
90
+ const pathname = (req.url ?? "").split("?")[0];
91
+ const json = (status, data) => {
92
+ res.statusCode = status;
93
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
94
+ res.end(JSON.stringify(data));
95
+ };
96
+ try {
97
+ if (req.method === "GET" && pathname === "/i18n/api/locales") {
98
+ json(200, await store.getLocales());
99
+ return;
100
+ }
101
+ if (req.method === "GET" && pathname === "/i18n/api/config") {
102
+ json(200, {
103
+ baseLanguage: store.config.baseLanguage,
104
+ languageOrder: store.config.languageOrder
105
+ });
106
+ return;
107
+ }
108
+ if (req.method === "POST" && pathname === "/i18n/api/publish") {
109
+ let changes;
110
+ try {
111
+ changes = await readChanges(req);
112
+ } catch (error) {
113
+ json(400, { error: error.message });
114
+ return;
115
+ }
116
+ for (const [language, data] of Object.entries(changes)) {
117
+ if (Object.keys(data).length)
118
+ await store.saveLocale(language, data);
119
+ }
120
+ json(200, { success: true });
121
+ return;
122
+ }
123
+ if (req.method === "GET" && ["/i18n", "/i18n/", "/i18n/index.html"].includes(pathname)) {
124
+ const html = await import_promises.default.readFile(
125
+ import_node_path.default.join(assetsDir, "index.html"),
126
+ "utf8"
127
+ );
128
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
129
+ res.end(await server.transformIndexHtml("/i18n/", html));
130
+ return;
131
+ }
132
+ if (req.method === "GET" && pathname === "/i18n/app.js") {
133
+ res.setHeader("Content-Type", "text/javascript; charset=utf-8");
134
+ res.end(await import_promises.default.readFile(import_node_path.default.join(assetsDir, "app.js"), "utf8"));
135
+ return;
136
+ }
137
+ next();
138
+ } catch (error) {
139
+ json(500, { error: error.message });
140
+ }
141
+ });
142
+ }
143
+ };
144
+ }
145
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { IncomingMessage } from 'node:http'\nimport type { Plugin } from 'vite'\nimport {\n createI18n,\n assertSegment,\n validateChanges,\n type I18nConfig,\n type LocaleStore,\n} from 'l10n-c'\n\nexport type { I18nConfig } from 'l10n-c'\nconst assetsDir =\n typeof __dirname !== 'undefined'\n ? path.resolve(__dirname, '../ui')\n : fileURLToPath(new URL('../ui', import.meta.url))\n\nasync function readChanges(\n req: IncomingMessage,\n): Promise<Record<string, Record<string, string>>> {\n const origin = req.headers.origin\n if (origin && new URL(origin).host !== req.headers.host)\n throw new Error('Cross-origin writes are not allowed')\n if (!req.headers['content-type']?.startsWith('application/json'))\n throw new Error('Expected application/json')\n let body = ''\n for await (const chunk of req) {\n body += chunk.toString()\n if (Buffer.byteLength(body) > 2 * 1024 * 1024)\n throw new Error('Payload exceeds 2 MiB')\n }\n const payload: unknown = JSON.parse(body)\n if (\n !payload ||\n typeof payload !== 'object' ||\n Array.isArray(payload) ||\n !('changes' in payload)\n )\n throw new Error('Expected { changes: { language: { key: value } } }')\n const changes = payload.changes\n if (!changes || typeof changes !== 'object' || Array.isArray(changes))\n throw new Error('Changes must be an object')\n // Validate every entry before the first filesystem write.\n for (const [language, data] of Object.entries(changes)) {\n assertSegment(language, 'language')\n validateChanges(data)\n }\n return changes as Record<string, Record<string, string>>\n}\n\n/** Development-only locale editor. Open /i18n/ on the Vite dev server. */\nexport default function i18nPlugin(options: I18nConfig = {}): Plugin {\n let store: LocaleStore\n return {\n name: 'vite-plugin-l10n-c',\n apply: 'serve',\n async configResolved(config) {\n store = await createI18n(options, config.root)\n },\n configureServer(server) {\n const localesDir = store.config.localesDir\n server.watcher.add(localesDir)\n const onChange = (_event: string, file: string) => {\n const relative = path.relative(localesDir, path.resolve(file))\n const segments = relative.split(path.sep)\n if (\n segments.length === 2 &&\n !relative.startsWith('..') &&\n segments[1].endsWith('.json')\n ) {\n server.ws.send('i18n-update', { locale: segments[0] })\n }\n }\n server.watcher.on('all', onChange)\n server.httpServer?.once('close', () =>\n server.watcher.off('all', onChange),\n )\n server.middlewares.use(async (req, res, next) => {\n const pathname = (req.url ?? '').split('?')[0]\n const json = (status: number, data: unknown) => {\n res.statusCode = status\n res.setHeader('Content-Type', 'application/json; charset=utf-8')\n res.end(JSON.stringify(data))\n }\n try {\n if (req.method === 'GET' && pathname === '/i18n/api/locales') {\n json(200, await store.getLocales())\n return\n }\n if (req.method === 'GET' && pathname === '/i18n/api/config') {\n json(200, {\n baseLanguage: store.config.baseLanguage,\n languageOrder: store.config.languageOrder,\n })\n return\n }\n if (req.method === 'POST' && pathname === '/i18n/api/publish') {\n let changes: Record<string, Record<string, string>>\n try {\n changes = await readChanges(req)\n } catch (error) {\n json(400, { error: (error as Error).message })\n return\n }\n for (const [language, data] of Object.entries(changes)) {\n if (Object.keys(data).length)\n await store.saveLocale(language, data)\n }\n json(200, { success: true })\n return\n }\n if (\n req.method === 'GET' &&\n ['/i18n', '/i18n/', '/i18n/index.html'].includes(pathname)\n ) {\n const html = await fs.readFile(\n path.join(assetsDir, 'index.html'),\n 'utf8',\n )\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end(await server.transformIndexHtml('/i18n/', html))\n return\n }\n if (req.method === 'GET' && pathname === '/i18n/app.js') {\n res.setHeader('Content-Type', 'text/javascript; charset=utf-8')\n res.end(await fs.readFile(path.join(assetsDir, 'app.js'), 'utf8'))\n return\n }\n next()\n } catch (error) {\n json(500, { error: (error as Error).message })\n }\n })\n },\n }\n}\n"],"mappings":"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","names":["path","fs"]}
@@ -0,0 +1,8 @@
1
+ import { Plugin } from 'vite';
2
+ import { I18nConfig } from 'l10n-c';
3
+ export { I18nConfig } from 'l10n-c';
4
+
5
+ /** Development-only locale editor. Open /i18n/ on the Vite dev server. */
6
+ declare function i18nPlugin(options?: I18nConfig): Plugin;
7
+
8
+ export { i18nPlugin as default };
@@ -0,0 +1,8 @@
1
+ import { Plugin } from 'vite';
2
+ import { I18nConfig } from 'l10n-c';
3
+ export { I18nConfig } from 'l10n-c';
4
+
5
+ /** Development-only locale editor. Open /i18n/ on the Vite dev server. */
6
+ declare function i18nPlugin(options?: I18nConfig): Plugin;
7
+
8
+ export { i18nPlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,117 @@
1
+ // src/index.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import {
6
+ createI18n,
7
+ assertSegment,
8
+ validateChanges
9
+ } from "l10n-c";
10
+ var assetsDir = typeof __dirname !== "undefined" ? path.resolve(__dirname, "../ui") : fileURLToPath(new URL("../ui", import.meta.url));
11
+ async function readChanges(req) {
12
+ const origin = req.headers.origin;
13
+ if (origin && new URL(origin).host !== req.headers.host)
14
+ throw new Error("Cross-origin writes are not allowed");
15
+ if (!req.headers["content-type"]?.startsWith("application/json"))
16
+ throw new Error("Expected application/json");
17
+ let body = "";
18
+ for await (const chunk of req) {
19
+ body += chunk.toString();
20
+ if (Buffer.byteLength(body) > 2 * 1024 * 1024)
21
+ throw new Error("Payload exceeds 2 MiB");
22
+ }
23
+ const payload = JSON.parse(body);
24
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) || !("changes" in payload))
25
+ throw new Error("Expected { changes: { language: { key: value } } }");
26
+ const changes = payload.changes;
27
+ if (!changes || typeof changes !== "object" || Array.isArray(changes))
28
+ throw new Error("Changes must be an object");
29
+ for (const [language, data] of Object.entries(changes)) {
30
+ assertSegment(language, "language");
31
+ validateChanges(data);
32
+ }
33
+ return changes;
34
+ }
35
+ function i18nPlugin(options = {}) {
36
+ let store;
37
+ return {
38
+ name: "vite-plugin-l10n-c",
39
+ apply: "serve",
40
+ async configResolved(config) {
41
+ store = await createI18n(options, config.root);
42
+ },
43
+ configureServer(server) {
44
+ const localesDir = store.config.localesDir;
45
+ server.watcher.add(localesDir);
46
+ const onChange = (_event, file) => {
47
+ const relative = path.relative(localesDir, path.resolve(file));
48
+ const segments = relative.split(path.sep);
49
+ if (segments.length === 2 && !relative.startsWith("..") && segments[1].endsWith(".json")) {
50
+ server.ws.send("i18n-update", { locale: segments[0] });
51
+ }
52
+ };
53
+ server.watcher.on("all", onChange);
54
+ server.httpServer?.once(
55
+ "close",
56
+ () => server.watcher.off("all", onChange)
57
+ );
58
+ server.middlewares.use(async (req, res, next) => {
59
+ const pathname = (req.url ?? "").split("?")[0];
60
+ const json = (status, data) => {
61
+ res.statusCode = status;
62
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
63
+ res.end(JSON.stringify(data));
64
+ };
65
+ try {
66
+ if (req.method === "GET" && pathname === "/i18n/api/locales") {
67
+ json(200, await store.getLocales());
68
+ return;
69
+ }
70
+ if (req.method === "GET" && pathname === "/i18n/api/config") {
71
+ json(200, {
72
+ baseLanguage: store.config.baseLanguage,
73
+ languageOrder: store.config.languageOrder
74
+ });
75
+ return;
76
+ }
77
+ if (req.method === "POST" && pathname === "/i18n/api/publish") {
78
+ let changes;
79
+ try {
80
+ changes = await readChanges(req);
81
+ } catch (error) {
82
+ json(400, { error: error.message });
83
+ return;
84
+ }
85
+ for (const [language, data] of Object.entries(changes)) {
86
+ if (Object.keys(data).length)
87
+ await store.saveLocale(language, data);
88
+ }
89
+ json(200, { success: true });
90
+ return;
91
+ }
92
+ if (req.method === "GET" && ["/i18n", "/i18n/", "/i18n/index.html"].includes(pathname)) {
93
+ const html = await fs.readFile(
94
+ path.join(assetsDir, "index.html"),
95
+ "utf8"
96
+ );
97
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
98
+ res.end(await server.transformIndexHtml("/i18n/", html));
99
+ return;
100
+ }
101
+ if (req.method === "GET" && pathname === "/i18n/app.js") {
102
+ res.setHeader("Content-Type", "text/javascript; charset=utf-8");
103
+ res.end(await fs.readFile(path.join(assetsDir, "app.js"), "utf8"));
104
+ return;
105
+ }
106
+ next();
107
+ } catch (error) {
108
+ json(500, { error: error.message });
109
+ }
110
+ });
111
+ }
112
+ };
113
+ }
114
+ export {
115
+ i18nPlugin as default
116
+ };
117
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { IncomingMessage } from 'node:http'\nimport type { Plugin } from 'vite'\nimport {\n createI18n,\n assertSegment,\n validateChanges,\n type I18nConfig,\n type LocaleStore,\n} from 'l10n-c'\n\nexport type { I18nConfig } from 'l10n-c'\nconst assetsDir =\n typeof __dirname !== 'undefined'\n ? path.resolve(__dirname, '../ui')\n : fileURLToPath(new URL('../ui', import.meta.url))\n\nasync function readChanges(\n req: IncomingMessage,\n): Promise<Record<string, Record<string, string>>> {\n const origin = req.headers.origin\n if (origin && new URL(origin).host !== req.headers.host)\n throw new Error('Cross-origin writes are not allowed')\n if (!req.headers['content-type']?.startsWith('application/json'))\n throw new Error('Expected application/json')\n let body = ''\n for await (const chunk of req) {\n body += chunk.toString()\n if (Buffer.byteLength(body) > 2 * 1024 * 1024)\n throw new Error('Payload exceeds 2 MiB')\n }\n const payload: unknown = JSON.parse(body)\n if (\n !payload ||\n typeof payload !== 'object' ||\n Array.isArray(payload) ||\n !('changes' in payload)\n )\n throw new Error('Expected { changes: { language: { key: value } } }')\n const changes = payload.changes\n if (!changes || typeof changes !== 'object' || Array.isArray(changes))\n throw new Error('Changes must be an object')\n // Validate every entry before the first filesystem write.\n for (const [language, data] of Object.entries(changes)) {\n assertSegment(language, 'language')\n validateChanges(data)\n }\n return changes as Record<string, Record<string, string>>\n}\n\n/** Development-only locale editor. Open /i18n/ on the Vite dev server. */\nexport default function i18nPlugin(options: I18nConfig = {}): Plugin {\n let store: LocaleStore\n return {\n name: 'vite-plugin-l10n-c',\n apply: 'serve',\n async configResolved(config) {\n store = await createI18n(options, config.root)\n },\n configureServer(server) {\n const localesDir = store.config.localesDir\n server.watcher.add(localesDir)\n const onChange = (_event: string, file: string) => {\n const relative = path.relative(localesDir, path.resolve(file))\n const segments = relative.split(path.sep)\n if (\n segments.length === 2 &&\n !relative.startsWith('..') &&\n segments[1].endsWith('.json')\n ) {\n server.ws.send('i18n-update', { locale: segments[0] })\n }\n }\n server.watcher.on('all', onChange)\n server.httpServer?.once('close', () =>\n server.watcher.off('all', onChange),\n )\n server.middlewares.use(async (req, res, next) => {\n const pathname = (req.url ?? '').split('?')[0]\n const json = (status: number, data: unknown) => {\n res.statusCode = status\n res.setHeader('Content-Type', 'application/json; charset=utf-8')\n res.end(JSON.stringify(data))\n }\n try {\n if (req.method === 'GET' && pathname === '/i18n/api/locales') {\n json(200, await store.getLocales())\n return\n }\n if (req.method === 'GET' && pathname === '/i18n/api/config') {\n json(200, {\n baseLanguage: store.config.baseLanguage,\n languageOrder: store.config.languageOrder,\n })\n return\n }\n if (req.method === 'POST' && pathname === '/i18n/api/publish') {\n let changes: Record<string, Record<string, string>>\n try {\n changes = await readChanges(req)\n } catch (error) {\n json(400, { error: (error as Error).message })\n return\n }\n for (const [language, data] of Object.entries(changes)) {\n if (Object.keys(data).length)\n await store.saveLocale(language, data)\n }\n json(200, { success: true })\n return\n }\n if (\n req.method === 'GET' &&\n ['/i18n', '/i18n/', '/i18n/index.html'].includes(pathname)\n ) {\n const html = await fs.readFile(\n path.join(assetsDir, 'index.html'),\n 'utf8',\n )\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end(await server.transformIndexHtml('/i18n/', html))\n return\n }\n if (req.method === 'GET' && pathname === '/i18n/app.js') {\n res.setHeader('Content-Type', 'text/javascript; charset=utf-8')\n res.end(await fs.readFile(path.join(assetsDir, 'app.js'), 'utf8'))\n return\n }\n next()\n } catch (error) {\n json(500, { error: (error as Error).message })\n }\n })\n },\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "vite-plugin-l10n-c",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Development-only Vite translation manager for l10n-c",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "ui/index.html",
19
+ "ui/app.js"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup && esbuild ui/app.ts --bundle --format=esm --target=es2022 --outfile=ui/app.js",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "author": "firejune",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/Blosharper/l10n-c.git",
30
+ "directory": "packages/vite-plugin-l10n-c"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/Blosharper/l10n-c/issues"
34
+ },
35
+ "homepage": "https://github.com/Blosharper/l10n-c#readme",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.0.0",
43
+ "vite": "^7.3.0",
44
+ "esbuild": "^0.25.0"
45
+ },
46
+ "dependencies": {
47
+ "l10n-c": "^0.2.0"
48
+ },
49
+ "peerDependencies": {
50
+ "vite": ">=5.0.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.12.0"
54
+ }
55
+ }
package/ui/app.js ADDED
@@ -0,0 +1,402 @@
1
+ // ui/app.ts
2
+ var localesData = {};
3
+ var languages = [];
4
+ var allKeys = [];
5
+ var filteredKeys = [];
6
+ var categories = [];
7
+ var visibleLanguages = [];
8
+ var selectedCategories = [];
9
+ var selectedLanguages = [];
10
+ var changes = {};
11
+ var tableHead = document.getElementById("table-head");
12
+ var tableBody = document.getElementById("table-body");
13
+ var btnPublish = document.getElementById("btn-publish");
14
+ var btnDownloadCsv = document.getElementById(
15
+ "btn-download-csv"
16
+ );
17
+ var searchInput = document.getElementById("search");
18
+ var categoryAnchor = document.getElementById("category-anchor");
19
+ var categoryItems = document.getElementById("category-items");
20
+ var categoryDropdown = document.getElementById("category-dropdown");
21
+ var languageAnchor = document.getElementById("language-anchor");
22
+ var languageItems = document.getElementById("language-items");
23
+ var languageDropdown = document.getElementById("language-dropdown");
24
+ var scrollSentinel = document.getElementById("scroll-sentinel");
25
+ var tableContainer = document.getElementById("table-container");
26
+ var progressBar = document.getElementById("progress-bar");
27
+ var visibilityObserver;
28
+ async function init() {
29
+ try {
30
+ const res = await fetch("/i18n/api/locales");
31
+ if (!res.ok) throw new Error("Failed to fetch locales");
32
+ localesData = await res.json();
33
+ const configResponse = await fetch("/i18n/api/config");
34
+ if (!configResponse.ok)
35
+ throw new Error("Failed to fetch editor configuration");
36
+ const { languageOrder: predefinedOrder } = await configResponse.json();
37
+ const availableLangs = Object.keys(localesData);
38
+ languages = predefinedOrder.filter((l) => availableLangs.includes(l));
39
+ languages.push(
40
+ ...availableLangs.filter((l) => !predefinedOrder.includes(l))
41
+ );
42
+ const keySet = /* @__PURE__ */ new Set();
43
+ const catSet = /* @__PURE__ */ new Set();
44
+ for (const lang of languages) {
45
+ for (const key of Object.keys(localesData[lang])) {
46
+ if (!key.startsWith("_meta")) {
47
+ keySet.add(key);
48
+ if (key.includes(".")) {
49
+ const cat = key.split(".")[0];
50
+ if (cat !== "_meta") {
51
+ catSet.add(cat);
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ allKeys = Array.from(keySet).sort();
58
+ categories = Array.from(catSet).sort();
59
+ const urlParams = new URLSearchParams(window.location.search);
60
+ if (urlParams.has("search")) searchInput.value = urlParams.get("search");
61
+ selectedCategories = urlParams.has("category") ? urlParams.get("category").split(",") : [];
62
+ selectedCategories.sort(
63
+ (a, b) => categories.indexOf(a) - categories.indexOf(b)
64
+ );
65
+ selectedLanguages = urlParams.has("language") ? urlParams.get("language").split(",").filter((l) => languages.includes(l)) : [];
66
+ selectedLanguages.sort(
67
+ (a, b) => languages.indexOf(a) - languages.indexOf(b)
68
+ );
69
+ categories.forEach((cat) => {
70
+ const li = document.createElement("li");
71
+ const cb = document.createElement("input");
72
+ cb.type = "checkbox";
73
+ cb.value = cat;
74
+ cb.checked = selectedCategories.includes(cat);
75
+ cb.addEventListener("change", () => {
76
+ if (cb.checked) {
77
+ selectedCategories.push(cat);
78
+ selectedCategories.sort(
79
+ (a, b) => categories.indexOf(a) - categories.indexOf(b)
80
+ );
81
+ } else selectedCategories = selectedCategories.filter((c) => c !== cat);
82
+ updateDropdownAnchor(categoryAnchor, selectedCategories, "Categories");
83
+ applyFilters();
84
+ });
85
+ li.appendChild(cb);
86
+ const count = allKeys.filter(
87
+ (key) => key === cat || key.startsWith(cat + ".")
88
+ ).length;
89
+ li.appendChild(document.createTextNode(` ${cat} (${count})`));
90
+ categoryItems.appendChild(li);
91
+ });
92
+ updateDropdownAnchor(categoryAnchor, selectedCategories, "Categories");
93
+ languages.forEach((l) => {
94
+ const li = document.createElement("li");
95
+ const cb = document.createElement("input");
96
+ cb.type = "checkbox";
97
+ cb.value = l;
98
+ cb.checked = selectedLanguages.includes(l);
99
+ cb.addEventListener("change", () => {
100
+ if (cb.checked) {
101
+ selectedLanguages.push(l);
102
+ selectedLanguages.sort(
103
+ (a, b) => languages.indexOf(a) - languages.indexOf(b)
104
+ );
105
+ } else selectedLanguages = selectedLanguages.filter((x) => x !== l);
106
+ updateDropdownAnchor(languageAnchor, selectedLanguages, "Languages");
107
+ applyFilters();
108
+ });
109
+ li.appendChild(cb);
110
+ li.appendChild(document.createTextNode(` ${l.toUpperCase()}`));
111
+ languageItems.appendChild(li);
112
+ });
113
+ updateDropdownAnchor(languageAnchor, selectedLanguages, "Languages");
114
+ setupDropdown(categoryDropdown, categoryAnchor);
115
+ setupDropdown(languageDropdown, languageAnchor);
116
+ languages.forEach((l) => changes[l] = {});
117
+ setupObservers();
118
+ applyFilters();
119
+ setTimeout(() => {
120
+ const savedScroll = localStorage.getItem("i18nScrollTop");
121
+ if (savedScroll) {
122
+ tableContainer.scrollTop = parseInt(savedScroll, 10);
123
+ }
124
+ }, 100);
125
+ } catch (err) {
126
+ console.error(err);
127
+ alert(`Failed to load locales: ${err.message}`);
128
+ }
129
+ }
130
+ function setupObservers() {
131
+ visibilityObserver = new IntersectionObserver(
132
+ (entries) => {
133
+ entries.forEach((entry) => {
134
+ const tr = entry.target;
135
+ if (entry.isIntersecting) {
136
+ fillRow(tr);
137
+ } else {
138
+ if (!tr.querySelector(".editing")) {
139
+ clearRow(tr);
140
+ }
141
+ }
142
+ });
143
+ },
144
+ {
145
+ root: tableContainer,
146
+ rootMargin: "200px"
147
+ }
148
+ );
149
+ }
150
+ function applyFilters() {
151
+ const filterText = searchInput.value.toLowerCase();
152
+ visibleLanguages = selectedLanguages.length > 0 ? selectedLanguages : languages;
153
+ const url = new URL(window.location.href);
154
+ if (filterText) url.searchParams.set("search", filterText);
155
+ else url.searchParams.delete("search");
156
+ if (selectedCategories.length > 0)
157
+ url.searchParams.set("category", selectedCategories.join(","));
158
+ else url.searchParams.delete("category");
159
+ if (selectedLanguages.length > 0)
160
+ url.searchParams.set("language", selectedLanguages.join(","));
161
+ else url.searchParams.delete("language");
162
+ window.history.replaceState({}, "", url);
163
+ const prevScrollTop = tableContainer.scrollTop;
164
+ filteredKeys = allKeys.filter((key) => {
165
+ if (selectedCategories.length > 0) {
166
+ const matchCat = selectedCategories.some(
167
+ (cat) => key === cat || key.startsWith(cat + ".")
168
+ );
169
+ if (!matchCat) return false;
170
+ }
171
+ let match = key.toLowerCase().includes(filterText);
172
+ if (!match) {
173
+ for (const lang of visibleLanguages) {
174
+ const rawVal = localesData[lang][key];
175
+ const val = rawVal === null || rawVal === void 0 ? "" : String(rawVal);
176
+ if (val.toLowerCase().includes(filterText)) {
177
+ match = true;
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ return match;
183
+ });
184
+ tableHead.innerHTML = `<th class="key-col">Key (${filteredKeys.length})</th>`;
185
+ const counts = visibleLanguages.map((lang) => {
186
+ return filteredKeys.filter((key) => {
187
+ return localesData[lang][key] !== void 0 || changes[lang][key] !== void 0;
188
+ }).length;
189
+ });
190
+ const maxCount = Math.max(...counts);
191
+ visibleLanguages.forEach((lang, idx) => {
192
+ const count = counts[idx];
193
+ const cls = count !== maxCount ? ' class="warning"' : "";
194
+ tableHead.innerHTML += `<th${cls}>${lang.toUpperCase()} (${count})</th>`;
195
+ });
196
+ renderVirtualTable();
197
+ setTimeout(() => {
198
+ tableContainer.scrollTop = prevScrollTop;
199
+ }, 0);
200
+ }
201
+ function renderVirtualTable() {
202
+ visibilityObserver.disconnect();
203
+ tableBody.innerHTML = "";
204
+ const fragment = document.createDocumentFragment();
205
+ for (let i = 0; i < filteredKeys.length; i++) {
206
+ const tr = document.createElement("tr");
207
+ tr.className = "virtual-row";
208
+ tr.dataset.index = String(i);
209
+ visibilityObserver.observe(tr);
210
+ fragment.appendChild(tr);
211
+ }
212
+ tableBody.appendChild(fragment);
213
+ updateProgressBar();
214
+ }
215
+ function clearRow(tr) {
216
+ tr.innerHTML = "";
217
+ }
218
+ function fillRow(tr) {
219
+ if (tr.childElementCount > 0) return;
220
+ const index = parseInt(tr.dataset.index || "0", 10);
221
+ const key = filteredKeys[index];
222
+ if (!key) return;
223
+ const fragment = document.createDocumentFragment();
224
+ const tdKey = document.createElement("td");
225
+ tdKey.className = "key-label";
226
+ tdKey.textContent = key;
227
+ tdKey.title = key;
228
+ fragment.appendChild(tdKey);
229
+ visibleLanguages.forEach((lang) => {
230
+ const td = document.createElement("td");
231
+ td.className = "editable-cell";
232
+ const rawVal = localesData[lang][key];
233
+ const val = rawVal === null || rawVal === void 0 ? "" : String(rawVal);
234
+ const isMissing = !val.trim();
235
+ if (isMissing) td.classList.add("missing");
236
+ let changedVal = changes[lang][key];
237
+ let displayVal = changedVal !== void 0 ? changedVal : val;
238
+ if (changedVal !== void 0) {
239
+ td.classList.add("changed");
240
+ td.classList.remove("missing");
241
+ }
242
+ const contentDiv = document.createElement("div");
243
+ contentDiv.className = "cell-content";
244
+ contentDiv.textContent = displayVal || (isMissing ? "(Empty)" : "");
245
+ td.appendChild(contentDiv);
246
+ td.addEventListener("click", () => {
247
+ if (td.classList.contains("editing")) return;
248
+ td.classList.add("editing");
249
+ changedVal = changes[lang][key];
250
+ const currentValue = localesData[lang][key];
251
+ displayVal = changedVal !== void 0 ? changedVal : String(currentValue ?? "");
252
+ const textarea = document.createElement("textarea");
253
+ textarea.value = displayVal;
254
+ textarea.placeholder = isMissing ? "(Empty)" : "";
255
+ contentDiv.style.visibility = "hidden";
256
+ td.appendChild(textarea);
257
+ textarea.focus();
258
+ textarea.style.height = "auto";
259
+ textarea.style.height = textarea.scrollHeight + "px";
260
+ textarea.addEventListener("input", function() {
261
+ this.style.height = "auto";
262
+ this.style.height = this.scrollHeight + "px";
263
+ });
264
+ textarea.addEventListener("keydown", (e) => {
265
+ if (e.key === "Escape") {
266
+ textarea.blur();
267
+ }
268
+ });
269
+ textarea.addEventListener("blur", () => {
270
+ const newVal = textarea.value;
271
+ handleInput(lang, key, newVal, td);
272
+ contentDiv.textContent = newVal || (!newVal.trim() ? "(Empty)" : "");
273
+ contentDiv.style.visibility = "visible";
274
+ textarea.remove();
275
+ td.classList.remove("editing");
276
+ });
277
+ });
278
+ fragment.appendChild(td);
279
+ });
280
+ tr.appendChild(fragment);
281
+ }
282
+ function updateProgressBar() {
283
+ const scrollHeight = tableContainer.scrollHeight;
284
+ const clientHeight = tableContainer.clientHeight;
285
+ const scrollTop = tableContainer.scrollTop;
286
+ const maxScroll = scrollHeight - clientHeight;
287
+ if (maxScroll <= 0) {
288
+ progressBar.style.width = "0%";
289
+ progressBar.style.left = "0%";
290
+ return;
291
+ }
292
+ const widthPercent = clientHeight / scrollHeight * 100;
293
+ const leftPercent = scrollTop / scrollHeight * 100;
294
+ progressBar.style.width = `${Math.min(widthPercent, 100)}%`;
295
+ progressBar.style.left = `${Math.min(leftPercent, 100)}%`;
296
+ }
297
+ tableContainer.addEventListener("scroll", () => {
298
+ updateProgressBar();
299
+ localStorage.setItem("i18nScrollTop", tableContainer.scrollTop.toString());
300
+ });
301
+ window.addEventListener("resize", updateProgressBar);
302
+ function handleInput(lang, key, value, tdElement) {
303
+ const rawOriginal = localesData[lang][key];
304
+ const original = rawOriginal === null || rawOriginal === void 0 ? "" : String(rawOriginal);
305
+ if (value !== original) {
306
+ changes[lang][key] = value;
307
+ tdElement.classList.add("changed");
308
+ if (value.trim()) tdElement.classList.remove("missing");
309
+ } else {
310
+ delete changes[lang][key];
311
+ tdElement.classList.remove("changed");
312
+ if (!value.trim()) tdElement.classList.add("missing");
313
+ }
314
+ updatePublishButton();
315
+ }
316
+ function updatePublishButton() {
317
+ let count = 0;
318
+ languages.forEach((lang) => {
319
+ count += Object.keys(changes[lang]).length;
320
+ });
321
+ btnPublish.textContent = `\u{1F680} Publish (${count})`;
322
+ btnPublish.disabled = count === 0;
323
+ }
324
+ btnPublish.addEventListener("click", async () => {
325
+ try {
326
+ btnPublish.disabled = true;
327
+ btnPublish.textContent = "\u{1F680} Publishing...";
328
+ const res = await fetch("/i18n/api/publish", {
329
+ method: "POST",
330
+ headers: { "Content-Type": "application/json" },
331
+ body: JSON.stringify({ changes })
332
+ });
333
+ if (!res.ok) throw new Error("Publish failed");
334
+ btnPublish.textContent = "\u{1F680} Saved successfully!";
335
+ languages.forEach((lang) => {
336
+ Object.assign(localesData[lang], changes[lang]);
337
+ changes[lang] = {};
338
+ });
339
+ document.querySelectorAll(".changed").forEach((el) => el.classList.remove("changed"));
340
+ setTimeout(() => {
341
+ updatePublishButton();
342
+ }, 2e3);
343
+ } catch (err) {
344
+ console.error(err);
345
+ alert(`Error: ${err.message}`);
346
+ updatePublishButton();
347
+ }
348
+ });
349
+ function downloadCSV() {
350
+ let csvContent = "\uFEFF";
351
+ const headers = ["Key", ...visibleLanguages.map((l) => l.toUpperCase())];
352
+ csvContent += headers.map(escapeCSV).join(",") + "\n";
353
+ for (const key of filteredKeys) {
354
+ const row = [key];
355
+ for (const lang of visibleLanguages) {
356
+ let val = changes[lang][key] !== void 0 ? changes[lang][key] : localesData[lang][key];
357
+ val = val === null || val === void 0 ? "" : String(val);
358
+ row.push(escapeCSV(val));
359
+ }
360
+ csvContent += row.join(",") + "\n";
361
+ }
362
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
363
+ const url = URL.createObjectURL(blob);
364
+ const a = document.createElement("a");
365
+ const dateStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
366
+ a.href = url;
367
+ a.download = `l10n-c_${dateStr}.csv`;
368
+ document.body.appendChild(a);
369
+ a.click();
370
+ document.body.removeChild(a);
371
+ URL.revokeObjectURL(url);
372
+ }
373
+ function escapeCSV(str) {
374
+ if (str.includes(",") || str.includes("\n") || str.includes('"')) {
375
+ return `"${str.replace(/"/g, '""')}"`;
376
+ }
377
+ return str;
378
+ }
379
+ btnDownloadCsv.addEventListener("click", downloadCSV);
380
+ searchInput.addEventListener("input", () => {
381
+ applyFilters();
382
+ });
383
+ function setupDropdown(dropdown, anchor) {
384
+ anchor.addEventListener("click", (e) => {
385
+ dropdown.classList.toggle("visible");
386
+ e.stopPropagation();
387
+ });
388
+ }
389
+ document.addEventListener("click", (e) => {
390
+ if (!categoryDropdown.contains(e.target))
391
+ categoryDropdown.classList.remove("visible");
392
+ if (!languageDropdown.contains(e.target))
393
+ languageDropdown.classList.remove("visible");
394
+ });
395
+ function updateDropdownAnchor(anchor, selected, typeName) {
396
+ if (selected.length === 0) {
397
+ anchor.textContent = `All ${typeName}`;
398
+ } else {
399
+ anchor.textContent = selected.join(", ");
400
+ }
401
+ }
402
+ init();
package/ui/index.html ADDED
@@ -0,0 +1,365 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>l10n-c Translation Manager</title>
7
+ <style>
8
+ :root {
9
+ --bg-color: #121212;
10
+ --panel-bg: #1e1e1e;
11
+ --text-main: #e0e0e0;
12
+ --text-muted: #888;
13
+ --border-color: #333;
14
+ --primary: #bb86fc;
15
+ --danger: #cf6679;
16
+ --highlight: rgba(187, 134, 252, 0.1);
17
+ }
18
+ body {
19
+ font-family:
20
+ 'Inter',
21
+ -apple-system,
22
+ BlinkMacSystemFont,
23
+ 'Segoe UI',
24
+ Roboto,
25
+ Helvetica,
26
+ Arial,
27
+ sans-serif;
28
+ margin: 0;
29
+ padding: 0;
30
+ background-color: var(--bg-color);
31
+ color: var(--text-main);
32
+ display: flex;
33
+ flex-direction: column;
34
+ height: 100vh;
35
+ overflow: hidden;
36
+ }
37
+ header {
38
+ background-color: var(--panel-bg);
39
+ padding: 1rem 2rem;
40
+ display: flex;
41
+ justify-content: space-between;
42
+ align-items: center;
43
+ border-bottom: 1px solid var(--border-color);
44
+ flex-shrink: 0;
45
+ z-index: 100;
46
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
47
+ }
48
+ h1 {
49
+ margin: 0;
50
+ font-size: 1.5rem;
51
+ font-weight: 600;
52
+ color: var(--primary);
53
+ }
54
+ .header-actions {
55
+ display: flex;
56
+ gap: 1rem;
57
+ align-items: center;
58
+ }
59
+ button {
60
+ background-color: var(--primary);
61
+ color: #000;
62
+ border: none;
63
+ padding: 0.5rem 1.2rem;
64
+ font-size: 0.85rem;
65
+ font-weight: 600;
66
+ border-radius: 6px;
67
+ cursor: pointer;
68
+ transition: all 0.2s;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ gap: 0.5rem;
73
+ }
74
+ button:hover:not(:disabled) {
75
+ opacity: 0.9;
76
+ transform: translateY(-1px);
77
+ box-shadow: 0 4px 8px rgba(187, 134, 252, 0.2);
78
+ }
79
+ button:disabled {
80
+ background-color: var(--border-color);
81
+ color: var(--text-muted);
82
+ cursor: not-allowed;
83
+ }
84
+
85
+ .table-container {
86
+ flex-grow: 1;
87
+ overflow-y: auto;
88
+ overflow-x: auto;
89
+ position: relative;
90
+ }
91
+ table {
92
+ width: 100%;
93
+ min-width: max-content;
94
+ border-collapse: collapse;
95
+ table-layout: fixed;
96
+ }
97
+ th,
98
+ td {
99
+ border: 1px solid var(--border-color);
100
+ padding: 0;
101
+ vertical-align: top;
102
+ }
103
+ th {
104
+ background-color: #1a1a1a;
105
+ color: var(--text-muted);
106
+ text-align: left;
107
+ padding: 0.75rem 1rem;
108
+ font-size: 0.75rem;
109
+ text-transform: uppercase;
110
+ letter-spacing: 0.05em;
111
+ position: sticky;
112
+ top: -1px;
113
+ z-index: 10;
114
+ min-width: 240px;
115
+ }
116
+ th::after {
117
+ content: '';
118
+ position: absolute;
119
+ left: 0;
120
+ right: 0;
121
+ bottom: -1px;
122
+ height: 1px;
123
+ background-color: var(--border-color);
124
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
125
+ pointer-events: none;
126
+ z-index: 11;
127
+ }
128
+ th.key-col {
129
+ width: 128px;
130
+ min-width: 128px;
131
+ max-width: 128px;
132
+ background-color: #161616;
133
+ border-right: 2px solid var(--border-color);
134
+ }
135
+ th.warning {
136
+ color: var(--danger);
137
+ }
138
+ .key-label {
139
+ padding: 0.8rem;
140
+ font-size: 0.75rem;
141
+ font-family: monospace;
142
+ color: var(--primary);
143
+ background-color: #161616;
144
+ border-right: 2px solid var(--border-color);
145
+ white-space: nowrap;
146
+ overflow: hidden;
147
+ text-overflow: ellipsis;
148
+ }
149
+ .editable-cell {
150
+ position: relative;
151
+ }
152
+ .cell-content {
153
+ min-height: 20px;
154
+ white-space: pre-wrap;
155
+ word-break: break-word;
156
+ padding: 0.6rem 0.8rem;
157
+ font-size: 0.75rem;
158
+ cursor: text;
159
+ line-height: 1.4;
160
+ }
161
+ .editable-cell:hover .cell-content {
162
+ background-color: rgba(255, 255, 255, 0.05);
163
+ }
164
+ .missing .cell-content {
165
+ color: var(--danger);
166
+ opacity: 0.7;
167
+ }
168
+ textarea {
169
+ width: 100%;
170
+ min-height: 48px;
171
+ background: var(--highlight);
172
+ border: 2px solid var(--primary);
173
+ color: var(--text-main);
174
+ padding: 0.6rem 0.8rem;
175
+ box-sizing: border-box;
176
+ resize: vertical;
177
+ font-family: inherit;
178
+ font-size: 0.75rem;
179
+ line-height: 1.4;
180
+ position: absolute;
181
+ top: 0;
182
+ left: 0;
183
+ z-index: 20;
184
+ }
185
+ textarea:focus {
186
+ outline: none;
187
+ }
188
+ .changed {
189
+ border-left: 3px solid var(--primary);
190
+ }
191
+
192
+ #search {
193
+ padding: 0.5rem;
194
+ border-radius: 4px;
195
+ border: 1px solid var(--border-color);
196
+ background: var(--bg-color);
197
+ color: var(--text-main);
198
+ width: 128px;
199
+ }
200
+ #category {
201
+ padding: 0.5rem;
202
+ border-radius: 4px;
203
+ border: 1px solid var(--border-color);
204
+ background: var(--bg-color);
205
+ color: var(--text-main);
206
+ }
207
+
208
+ .pagination {
209
+ display: none;
210
+ }
211
+
212
+ #scroll-sentinel {
213
+ height: 20px;
214
+ margin-top: 10px;
215
+ }
216
+
217
+ /* Custom Dropdown Check List */
218
+ .dropdown-check-list {
219
+ display: inline-block;
220
+ position: relative;
221
+ background: var(--bg-color);
222
+ border: 1px solid var(--border-color);
223
+ border-radius: 4px;
224
+ min-width: 128px;
225
+ color: var(--text-main);
226
+ cursor: pointer;
227
+ user-select: none;
228
+ max-width: 128px;
229
+ }
230
+ .dropdown-check-list .anchor {
231
+ display: block;
232
+ padding: 0.5rem 2rem 0.5rem 0.5rem;
233
+ position: relative;
234
+ font-size: 0.85rem;
235
+ white-space: nowrap;
236
+ overflow: hidden;
237
+ text-overflow: ellipsis;
238
+ }
239
+ .dropdown-check-list .anchor::after {
240
+ content: '▼';
241
+ position: absolute;
242
+ right: 10px;
243
+ top: 50%;
244
+ transform: translateY(-50%);
245
+ font-size: 0.7rem;
246
+ }
247
+ .dropdown-check-list .items {
248
+ display: none;
249
+ position: absolute;
250
+ top: 100%;
251
+ left: -1px;
252
+ right: -1px;
253
+ background: var(--panel-bg);
254
+ border: 1px solid var(--border-color);
255
+ border-top: none;
256
+ z-index: 1000;
257
+ max-height: 400px;
258
+ overflow-y: auto;
259
+ list-style: none;
260
+ padding: 0;
261
+ margin: 0;
262
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
263
+ }
264
+ .dropdown-check-list.visible .items {
265
+ display: block;
266
+ }
267
+ .dropdown-check-list .items li {
268
+ padding: 0.5rem;
269
+ font-size: 0.8rem;
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 0.5rem;
273
+ }
274
+ .dropdown-check-list .items li:hover {
275
+ background: rgba(255, 255, 255, 0.05);
276
+ }
277
+ .dropdown-check-list .items input[type='checkbox'] {
278
+ cursor: pointer;
279
+ }
280
+
281
+
282
+ .virtual-row {
283
+ height: 14px;
284
+ }
285
+ .virtual-row td {
286
+ height: 14px;
287
+ overflow: hidden;
288
+ white-space: nowrap;
289
+ text-overflow: ellipsis;
290
+ }
291
+
292
+ ::-webkit-scrollbar {
293
+ width: 10px;
294
+ height: 10px;
295
+ }
296
+ ::-webkit-scrollbar-track {
297
+ background: var(--bg-color);
298
+ }
299
+ ::-webkit-scrollbar-thumb {
300
+ background: var(--border-color);
301
+ border-radius: 5px;
302
+ }
303
+ ::-webkit-scrollbar-thumb:hover {
304
+ background: var(--text-muted);
305
+ }
306
+
307
+ .progress-container {
308
+ height: 3px;
309
+ width: 100%;
310
+ background-color: var(--border-color);
311
+ flex-shrink: 0;
312
+ position: relative;
313
+ }
314
+ .progress-bar {
315
+ height: 100%;
316
+ width: 0%;
317
+ position: absolute;
318
+ left: 0;
319
+ top: 0;
320
+ background: linear-gradient(90deg, #bb86fc, #ff79c6);
321
+ box-shadow: 0 0 8px var(--primary);
322
+ border-radius: 3px;
323
+ }
324
+ </style>
325
+ </head>
326
+ <body>
327
+ <header>
328
+ <h1>🌸 I18n Manager</h1>
329
+ <div class="header-actions">
330
+ <div id="language-dropdown" class="dropdown-check-list">
331
+ <span class="anchor" id="language-anchor">All Languages</span>
332
+ <ul class="items" id="language-items"></ul>
333
+ </div>
334
+ <div id="category-dropdown" class="dropdown-check-list">
335
+ <span class="anchor" id="category-anchor">All Categories</span>
336
+ <ul class="items" id="category-items"></ul>
337
+ </div>
338
+ <input type="text" id="search" placeholder="Search keys or translations..." />
339
+
340
+ <button id="btn-download-csv">📥 CSV</button>
341
+ <button id="btn-publish" disabled>🚀 Publish (0)</button>
342
+ </div>
343
+ </header>
344
+
345
+ <div class="progress-container">
346
+ <div class="progress-bar" id="progress-bar"></div>
347
+ </div>
348
+
349
+ <div class="table-container" id="table-container">
350
+ <table id="i18n-table">
351
+ <thead>
352
+ <tr id="table-head">
353
+ <!-- Dynamically generated -->
354
+ </tr>
355
+ </thead>
356
+ <tbody id="table-body">
357
+ <!-- Dynamically generated -->
358
+ </tbody>
359
+ </table>
360
+ <div id="scroll-sentinel"></div>
361
+ </div>
362
+
363
+ <script type="module" src="/i18n/app.js"></script>
364
+ </body>
365
+ </html>