addonova 1.0.3 → 1.0.4

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/README.md CHANGED
@@ -1,6 +1,8 @@
1
- # addonova
1
+ # Addonova
2
2
 
3
- WebExtension framework for scaffolding and building cross-browser browser extensions.
3
+ [![Addonova Social Banner](https://raw.githubusercontent.com/code-hemu/addonova/refs/heads/main/docs/assets/capture.jpg)](https://github.com/code-hemu/addonova)
4
+
5
+ Addonova is a framework that allows developers to build extensions for multiple browsers. Developers can easily build, test, and manage extensions.
4
6
 
5
7
  ## Quick Start
6
8
 
@@ -26,6 +28,7 @@ npm run release
26
28
  | `addonova init <name>` | Scaffold a new extension project |
27
29
  | `addonova build [options]` | Build the current extension project |
28
30
  | `addonova zip` | Create release zip bundles |
31
+ | `addonova tool` | Open the i18n tools UI in a browser |
29
32
  | `addonova --help` | Show help |
30
33
 
31
34
  ## Build Scripts
@@ -50,7 +53,6 @@ npm run debug:thunderbird
50
53
  npm run debug:naver
51
54
 
52
55
  npm run dev
53
- npm run watch
54
56
  npm run zip
55
57
  npm test
56
58
  ```
@@ -62,6 +64,7 @@ npx addonova build --all --release
62
64
  npx addonova build --chrome --debug
63
65
  npx addonova build --all --debug --watch
64
66
  npx addonova zip
67
+ npx addonova tool
65
68
  ```
66
69
 
67
70
  ## Build Options
@@ -128,9 +131,19 @@ This is my extension description.
128
131
  Run the interactive message manager from a generated project:
129
132
 
130
133
  ```bash
131
- node node_modules/addonova/src/tools/translate.js
134
+ npm run tool
135
+ ```
136
+
137
+ Or use the i18n tools UI:
138
+
139
+ ```bash
140
+ npx addonova tool
132
141
  ```
133
142
 
143
+ This opens a full UI at `http://localhost:9876` with:
144
+ - **Translate tab** — view all locale messages, add new messages with auto-translation, delete messages
145
+ - **JSON → i18n tab** — drag-and-drop a `messages.json` file to convert to `.i18n` format
146
+
134
147
  ## Development
135
148
 
136
149
  Run the test suite:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "addonova",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Web Extension Framework",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -19,10 +19,11 @@
19
19
  "templates/"
20
20
  ],
21
21
  "dependencies": {
22
- "@craftamap/esbuild-plugin-html": "^0.9.0",
23
22
  "chokidar": "^5.0.0",
24
23
  "esbuild": "^0.27.3",
25
24
  "globby": "^16.1.0",
25
+ "html-minifier-terser": "^7.2.0",
26
+ "htmlhint": "^1.9.2",
26
27
  "web-ext": "^10.3.0",
27
28
  "yazl": "^3.3.1"
28
29
  },
@@ -1,91 +1,120 @@
1
1
  import path from 'node:path';
2
- import { build } from 'esbuild';
3
- import { htmlPlugin } from '@craftamap/esbuild-plugin-html';
4
- import {getDestDir} from './paths.js';
5
- import {readFile, writeFile, getConfig, getAllFiles, log, fileExistsInConfig} from './utils.js';
6
- import {createTask} from './task.js';
2
+ import { minify } from 'html-minifier-terser';
3
+ import htmlhint from 'htmlhint';
4
+
5
+ import { getDestDir } from './paths.js';
6
+ import { readFile, writeFile, getConfig, getAllFiles, log, fileExistsInConfig } from './utils.js';
7
+ import { createTask } from './task.js';
7
8
 
8
9
  const srcHTMLDir = 'src/html';
10
+ const { HTMLHint } = htmlhint;
11
+
12
+ const htmlHintRules = {
13
+ 'attr-lowercase': true,
14
+ 'attr-no-duplication': true,
15
+ 'doctype-first': false,
16
+ 'id-unique': true,
17
+ 'tag-pair': true,
18
+ 'tagname-lowercase': true,
19
+ };
9
20
 
10
- async function removeTopSourceComment(filePath) {
11
- const code = await readFile(filePath, 'utf8');
12
- const newCode = code.replace(/^\s*<!--[\s\S]*?-->\s*/, '');
13
- await writeFile(filePath, newCode);
21
+ const htmlMinifyOptions = {
22
+ collapseBooleanAttributes: true,
23
+ collapseWhitespace: true,
24
+ conservativeCollapse: true,
25
+ keepClosingSlash: true,
26
+ minifyCSS: true,
27
+ minifyJS: true,
28
+ removeComments: true,
29
+ removeEmptyAttributes: false,
30
+ };
31
+
32
+ function getDestinationFile(destDir, dest, src, filename) {
33
+ const sourceName = path.basename(src, path.extname(src));
34
+ const outputName = `${filename.replace('[name]', sourceName)}.html`;
35
+ return path.join(destDir, dest, outputName);
14
36
  }
15
37
 
16
- async function esbuildHTML(config, isDebug, platform){
17
- let buildResult, outputs;
18
- const dir = getDestDir({isDebug, platform});
19
- for (const [dest, src] of Object.entries(config.entry)) {
20
- buildResult = await build({
21
- entryPoints: src,
22
- outdir: path.join(dir, dest),
23
- plugins: [htmlPlugin()],
24
- loader: {
25
- '.html': 'file' // 🔥 Important
26
- },
27
- entryNames: config.filename,
28
- assetNames: config.filename,
29
- chunkNames: config.filename,
30
- metafile: true,
31
- write: true
32
- });
33
-
34
- if (!isDebug) {
35
- outputs = Object.keys(buildResult.metafile.outputs);
36
- for (const file of outputs) {
37
- if (file.endsWith('.html')) {
38
- removeTopSourceComment(file);
39
- }
40
- }
41
- }
42
- }
38
+ function formatHtmlIssue(file, issue) {
39
+ return `${file}:${issue.line}:${issue.col} ${issue.type.toUpperCase()} ${issue.message}`;
40
+ }
41
+
42
+ function validateHTML(file, html) {
43
+ const issues = HTMLHint.verify(html, htmlHintRules);
44
+ const errors = issues.filter((issue) => issue.type === 'error');
45
+
46
+ if (errors.length > 0) {
47
+ throw new Error(errors.map((issue) => formatHtmlIssue(file, issue)).join('\n'));
48
+ }
49
+
50
+ for (const issue of issues) {
51
+ log.warn(`[HTML] ${formatHtmlIssue(file, issue)}`);
52
+ }
53
+ }
54
+
55
+ async function bundleHTML(config, isDebug, platform) {
56
+ const dir = getDestDir({ isDebug, platform });
57
+
58
+ for (const [dest, sources] of Object.entries(config.entry)) {
59
+ for (const src of sources) {
60
+ const html = await readFile(src, 'utf8');
61
+ validateHTML(src, html);
62
+
63
+ const output = isDebug
64
+ ? html
65
+ : await minify(html, htmlMinifyOptions);
43
66
 
67
+ await writeFile(
68
+ getDestinationFile(dir, dest, src, config.filename),
69
+ output,
70
+ 'utf8'
71
+ );
72
+ }
73
+ }
44
74
  }
45
75
 
46
- export function createBundleHTMLTask(srcHTMLDir){
47
- let currentWatchFiles;
48
- const bundleHTML = async ({platforms, isDebug, logInfo, logWarn}) => {
49
- for(const platform of platforms){
50
- const config = (await getConfig(platform));
51
- if(config.html){
52
- await esbuildHTML(config.html, isDebug, platform);
53
- if (logInfo) log.ok(`Bundling HTML for ${platform}...`);
54
- } else{
55
- if(logWarn) log.warn(`No HTML config found for ${platform}, skipping HTML bundling.`);
56
- }
57
- }
76
+ export function createBundleHTMLTask(srcHTMLDir) {
77
+ const runBundleHTML = async ({ platforms, isDebug, logInfo, logWarn }) => {
78
+ for (const platform of platforms) {
79
+ const config = await getConfig(platform);
80
+ if (config.html) {
81
+ await bundleHTML(config.html, isDebug, platform);
82
+ if (logInfo) log.ok(`Bundling HTML for ${platform}...`);
83
+ } else if (logWarn) {
84
+ log.warn(`No HTML config found for ${platform}, skipping HTML bundling.`);
85
+ }
58
86
  }
87
+ };
88
+
89
+ const onChange = async (changedFiles, watcher, platforms, isDebug) => {
90
+ for (const platform of platforms) {
91
+ const config = await getConfig(platform);
59
92
 
60
- const onChange = async (changedFiles, watcher, platforms, isDebug) => {
61
- for(const platform of platforms){
62
- const config = (await getConfig(platform));
63
-
64
- if (!config.html) continue;
65
- const exists = await fileExistsInConfig(
66
- config.html.entry,
67
- changedFiles[0]
68
- );
69
-
70
- if (exists){
71
- const newConfig = {
72
- html: {
73
- entry: exists,
74
- filename: config.html.filename
75
- }
76
- };
77
- await esbuildHTML(newConfig.html, isDebug, platform);
78
- }
79
- }
93
+ if (!config.html) continue;
94
+ const exists = await fileExistsInConfig(
95
+ config.html.entry,
96
+ changedFiles[0]
97
+ );
98
+
99
+ if (exists) {
100
+ const newConfig = {
101
+ html: {
102
+ entry: exists,
103
+ filename: config.html.filename,
104
+ },
105
+ };
106
+ await bundleHTML(newConfig.html, isDebug, platform);
107
+ }
80
108
  }
81
- return createTask(
82
- 'bundle HTML',
83
- bundleHTML,
84
- ).addWatcher(
85
- async () => {
86
- currentWatchFiles = await getAllFiles(srcHTMLDir);
87
- return currentWatchFiles;
88
- }, onChange);
109
+ };
110
+
111
+ return createTask(
112
+ 'bundle HTML',
113
+ runBundleHTML
114
+ ).addWatcher(
115
+ () => getAllFiles(srcHTMLDir),
116
+ onChange
117
+ );
89
118
  }
90
119
 
91
120
  export default createBundleHTMLTask(srcHTMLDir);
package/src/cli/help.js CHANGED
@@ -6,6 +6,7 @@ export function printHelp() {
6
6
  npx addonova init <my-extension> Scaffold a new extension project
7
7
  npx addonova build [options] Build the extension
8
8
  npx addonova zip Create release zip bundles
9
+ npx addonova tool Open the helping tools UI in a browser
9
10
  npx addonova --help Show this help
10
11
 
11
12
  Build options:
package/src/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { existsSync } from 'node:fs';
6
6
 
7
7
  import { init } from '../commands/init.js';
8
+ import { runTool } from '../commands/tool.js';
8
9
  import { printHelp } from './help.js';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,9 @@ export async function runCli(argv) {
41
42
  case 'init':
42
43
  await init(args);
43
44
  break;
45
+ case 'tool':
46
+ await runTool();
47
+ break;
44
48
  case 'build':
45
49
  case 'zip':
46
50
  await runBuildCommand(command, args);
@@ -0,0 +1,32 @@
1
+ import { fork } from 'node:child_process';
2
+ import process from 'node:process';
3
+ import { resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { existsSync } from 'node:fs';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ export async function runTool() {
10
+ const serverPath = resolve(__dirname, '../tools/tools-server.js');
11
+
12
+ if (!existsSync(serverPath)) {
13
+ console.error('[*] Addonova tools server not found. Reinstall the package.');
14
+ process.exit(1);
15
+ }
16
+
17
+ const child = fork(serverPath, [], {
18
+ stdio: 'inherit',
19
+ });
20
+
21
+ process.on('SIGINT', () => {
22
+ child.kill('SIGKILL');
23
+ process.exit(130);
24
+ });
25
+
26
+ await new Promise((resolve, reject) =>
27
+ child.on('error', reject).on('close', (code) => {
28
+ if (code !== 0) process.exit(code);
29
+ resolve();
30
+ })
31
+ );
32
+ }
@@ -0,0 +1,287 @@
1
+ import http from 'node:http';
2
+ import { exec } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ import {readFile, writeFile, httpsRequest, timeout} from '../build/utils.js';
9
+
10
+ const openBrowser = (url) => {
11
+ const cmd = process.platform === 'win32' ? 'start' :
12
+ process.platform === 'darwin' ? 'open' : 'xdg-open';
13
+ exec(`${cmd} ${url}`);
14
+ };
15
+
16
+ const LOCALES_ROOT = path.resolve(process.cwd(), 'src/_locales');
17
+ const PORT = process.env.PORT || 9876;
18
+ const MIME = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.js': 'text/javascript; charset=utf-8',
21
+ '.css': 'text/css; charset=utf-8',
22
+ '.json': 'application/json',
23
+ '.png': 'image/png',
24
+ };
25
+
26
+ function toMessageId(message) {
27
+ if (typeof message !== 'string') return '';
28
+ return message.trim().split(/\s+/).slice(0, 3)
29
+ .map(w => w.replace(/[^\w]/g, '').toLowerCase())
30
+ .filter(Boolean).join('_');
31
+ }
32
+
33
+ function parseLocale(content) {
34
+ const messages = new Map();
35
+ const lines = content.split('\n');
36
+ let id = '';
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i];
39
+ if (line.startsWith('@')) {
40
+ id = line.substring(1);
41
+ } else if (line.startsWith('#')) {
42
+ continue;
43
+ } else if (messages.has(id)) {
44
+ messages.set(id, `${messages.get(id)}\n${line}`);
45
+ } else {
46
+ messages.set(id, line);
47
+ }
48
+ }
49
+ messages.forEach((value, id) => messages.set(id, value.trim()));
50
+ return messages;
51
+ }
52
+
53
+ function stringifyLocale(messages) {
54
+ const lines = [];
55
+ messages.forEach((message, id) => {
56
+ lines.push(`@${id}`);
57
+ const hasDoubleNewLines = /\n\n/.test(message);
58
+ message.split('\n').filter(l => l.trim()).forEach((line, index, filtered) => {
59
+ lines.push(line);
60
+ if (hasDoubleNewLines && index < filtered.length - 1) lines.push('');
61
+ });
62
+ lines.push('');
63
+ });
64
+ return lines.join('\n');
65
+ }
66
+
67
+ function json2i18n(input) {
68
+ let output = '';
69
+ for (const key in input) {
70
+ output += `@${key}\n${input[key].message}\n\n`;
71
+ }
72
+ return output;
73
+ }
74
+
75
+ async function getSupportedLocales() {
76
+ const entries = await fs.readdir(LOCALES_ROOT).catch(() => []);
77
+ return entries.filter(f => f.endsWith('.i18n')).map(f => f.replace('.i18n', ''));
78
+ }
79
+
80
+ async function translate(text, lang) {
81
+ const url = new URL('https://translate.googleapis.com/translate_a/single');
82
+ url.search = new URLSearchParams({
83
+ client: 'gtx', sl: 'en-US', tl: lang, dt: 't', dj: '1', q: text,
84
+ }).toString();
85
+ const response = await httpsRequest(url.toString());
86
+ const data = JSON.parse(response.text());
87
+ return data.sentences.map(s => s.trans).join('\n').replaceAll(/\n+/g, '\n');
88
+ }
89
+
90
+ function jsonResponse(res, data, status = 200) {
91
+ res.writeHead(status, { 'Content-Type': 'application/json' });
92
+ res.end(JSON.stringify(data));
93
+ }
94
+
95
+ function serveStatic(req, res) {
96
+ let filePath = req.url === '/' ? '/tools.html' : req.url;
97
+ filePath = path.join(__dirname, filePath);
98
+
99
+ const ext = path.extname(filePath);
100
+ if (!MIME[ext]) {
101
+ res.writeHead(404);
102
+ res.end('Not found');
103
+ return;
104
+ }
105
+
106
+ fs.readFile(filePath).then(content => {
107
+ res.writeHead(200, { 'Content-Type': MIME[ext] });
108
+ res.end(content);
109
+ }).catch(() => {
110
+ res.writeHead(404);
111
+ res.end('Not found');
112
+ });
113
+ }
114
+
115
+ function parseBody(req) {
116
+ return new Promise((resolve, reject) => {
117
+ let body = '';
118
+ req.on('data', chunk => body += chunk);
119
+ req.on('end', () => {
120
+ try { resolve(JSON.parse(body)); }
121
+ catch { reject(new Error('Invalid JSON')); }
122
+ });
123
+ req.on('error', reject);
124
+ });
125
+ }
126
+
127
+ async function handleAPI(req, res) {
128
+ const url = new URL(req.url, `http://localhost:${PORT}`);
129
+ const parts = url.pathname.split('/').filter(Boolean);
130
+
131
+ if (parts[0] !== 'api') return false;
132
+
133
+ try {
134
+ switch (parts[1]) {
135
+ case 'locales': {
136
+ if (parts[2]) {
137
+ const filePath = path.join(LOCALES_ROOT, `${parts[2]}.i18n`);
138
+ const content = await readFile(filePath).catch(() => null);
139
+ if (!content) {
140
+ jsonResponse(res, { error: 'Locale not found' }, 404);
141
+ return true;
142
+ }
143
+ const messages = parseLocale(content);
144
+ const obj = {};
145
+ messages.forEach((v, k) => obj[k] = v);
146
+ jsonResponse(res, { locale: parts[2], messages: obj });
147
+ } else {
148
+ const list = await getSupportedLocales();
149
+ const data = {};
150
+ for (const loc of list) {
151
+ const content = await readFile(path.join(LOCALES_ROOT, `${loc}.i18n`));
152
+ const messages = parseLocale(content);
153
+ const obj = {};
154
+ messages.forEach((v, k) => obj[k] = v);
155
+ data[loc] = obj;
156
+ }
157
+ jsonResponse(res, { locales: list, data });
158
+ }
159
+ return true;
160
+ }
161
+
162
+ case 'translate': {
163
+ const { text, lang } = await parseBody(req);
164
+ if (!text || !lang) {
165
+ jsonResponse(res, { error: 'text and lang required' }, 400);
166
+ return true;
167
+ }
168
+ const result = await translate(text, lang);
169
+ jsonResponse(res, { translated: result });
170
+ return true;
171
+ }
172
+
173
+ case 'messages': {
174
+ if (req.method !== 'POST') {
175
+ jsonResponse(res, { error: 'Method not allowed' }, 405);
176
+ return true;
177
+ }
178
+
179
+ const body = await parseBody(req);
180
+
181
+ if (parts[2] === 'add') {
182
+ const { message, customId } = body;
183
+ if (!message) {
184
+ jsonResponse(res, { error: 'message required' }, 400);
185
+ return true;
186
+ }
187
+
188
+ const locales = await getSupportedLocales();
189
+ const messageId = (customId && customId.trim()) ? customId.trim() : toMessageId(message);
190
+ const results = { messageId, locales: {} };
191
+
192
+ for (const locale of locales) {
193
+ const filePath = path.join(LOCALES_ROOT, `${locale}.i18n`);
194
+ const content = await readFile(filePath);
195
+ const messages = parseLocale(content);
196
+
197
+ if (locale === 'en') {
198
+ if (!messages.has(messageId)) {
199
+ messages.set(messageId, message);
200
+ await writeFile(filePath, stringifyLocale(messages));
201
+ results.locales[locale] = message;
202
+ } else {
203
+ results.locales[locale] = { exists: true };
204
+ }
205
+ } else {
206
+ if (messages.has(messageId)) {
207
+ results.locales[locale] = { exists: true };
208
+ } else {
209
+ await timeout(1000);
210
+ const translated = await translate(message, locale);
211
+ messages.set(messageId, translated);
212
+ await writeFile(filePath, stringifyLocale(messages));
213
+ results.locales[locale] = translated;
214
+ }
215
+ }
216
+ }
217
+
218
+ jsonResponse(res, results);
219
+ } else if (parts[2] === 'delete') {
220
+ const { messageId, targetLocale } = body;
221
+ if (!messageId) {
222
+ jsonResponse(res, { error: 'messageId required' }, 400);
223
+ return true;
224
+ }
225
+
226
+ const locales = targetLocale ? [targetLocale] : await getSupportedLocales();
227
+ const deleted = [];
228
+
229
+ for (const locale of locales) {
230
+ const filePath = path.join(LOCALES_ROOT, `${locale}.i18n`);
231
+ try {
232
+ const content = await readFile(filePath);
233
+ const messages = parseLocale(content);
234
+ if (messages.has(messageId)) {
235
+ messages.delete(messageId);
236
+ await writeFile(filePath, stringifyLocale(messages));
237
+ deleted.push(locale);
238
+ }
239
+ } catch { /* skip missing */ }
240
+ }
241
+
242
+ jsonResponse(res, { deleted });
243
+ } else {
244
+ jsonResponse(res, { error: 'Unknown action' }, 400);
245
+ }
246
+ return true;
247
+ }
248
+
249
+ case 'json2i18n': {
250
+ if (req.method !== 'POST') {
251
+ jsonResponse(res, { error: 'Method not allowed' }, 405);
252
+ return true;
253
+ }
254
+ const body = await parseBody(req);
255
+ if (!body.json) {
256
+ jsonResponse(res, { error: 'json field required' }, 400);
257
+ return true;
258
+ }
259
+ const result = json2i18n(body.json);
260
+ jsonResponse(res, { i18n: result });
261
+ return true;
262
+ }
263
+
264
+ default:
265
+ jsonResponse(res, { error: 'Unknown endpoint' }, 404);
266
+ return true;
267
+ }
268
+ } catch (err) {
269
+ jsonResponse(res, { error: err.message }, 500);
270
+ return true;
271
+ }
272
+ }
273
+
274
+ const server = http.createServer(async (req, res) => {
275
+ if (req.url.startsWith('/api/')) {
276
+ await handleAPI(req, res);
277
+ } else {
278
+ serveStatic(req, res);
279
+ }
280
+ });
281
+
282
+ server.listen(PORT, () => {
283
+ const url = `http://localhost:${PORT}`;
284
+ console.log(`Addonova Tools UI → ${url}`);
285
+ console.log(`Working directory locales: ${LOCALES_ROOT}`);
286
+ openBrowser(url);
287
+ });