@abdess76/i18nkit 1.0.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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/bin/cli.js +48 -0
- package/bin/commands/apply.js +48 -0
- package/bin/commands/check-sync.js +35 -0
- package/bin/commands/extract-utils.js +216 -0
- package/bin/commands/extract.js +198 -0
- package/bin/commands/find-orphans.js +36 -0
- package/bin/commands/help.js +34 -0
- package/bin/commands/index.js +79 -0
- package/bin/commands/translate.js +51 -0
- package/bin/commands/version.js +17 -0
- package/bin/commands/watch.js +34 -0
- package/bin/core/applier-utils.js +144 -0
- package/bin/core/applier.js +165 -0
- package/bin/core/args.js +147 -0
- package/bin/core/backup.js +74 -0
- package/bin/core/command-interface.js +69 -0
- package/bin/core/config.js +108 -0
- package/bin/core/context.js +86 -0
- package/bin/core/detector.js +152 -0
- package/bin/core/file-walker.js +159 -0
- package/bin/core/fs-adapter.js +56 -0
- package/bin/core/help-generator.js +208 -0
- package/bin/core/index.js +63 -0
- package/bin/core/json-utils.js +213 -0
- package/bin/core/key-generator.js +75 -0
- package/bin/core/log-utils.js +26 -0
- package/bin/core/orphan-finder.js +208 -0
- package/bin/core/parser-utils.js +187 -0
- package/bin/core/paths.js +60 -0
- package/bin/core/plugin-interface.js +83 -0
- package/bin/core/plugin-resolver-utils.js +166 -0
- package/bin/core/plugin-resolver.js +211 -0
- package/bin/core/sync-checker-utils.js +99 -0
- package/bin/core/sync-checker.js +199 -0
- package/bin/core/translator.js +197 -0
- package/bin/core/types.js +297 -0
- package/bin/core/watcher.js +119 -0
- package/bin/plugins/adapter-transloco.js +156 -0
- package/bin/plugins/parser-angular.js +56 -0
- package/bin/plugins/parser-primeng.js +79 -0
- package/bin/plugins/parser-typescript.js +66 -0
- package/bin/plugins/provider-deepl.js +65 -0
- package/bin/plugins/provider-mymemory.js +192 -0
- package/package.json +123 -0
- package/types/index.d.ts +85 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Shared text extraction patterns and utilities for all parsers.
|
|
5
|
+
* Handles HTML entities, ignore patterns, and Transloco expression cleanup.
|
|
6
|
+
* @module parser-utils
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} ExtractedText
|
|
11
|
+
* @property {string} text
|
|
12
|
+
* @property {string} rawText
|
|
13
|
+
* @property {string} context
|
|
14
|
+
* @property {string} [attr]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const IGNORE_PATTERNS = [
|
|
18
|
+
/^\s*$/,
|
|
19
|
+
/^\d+(\.\d+)?$/,
|
|
20
|
+
/^[a-z]+:\/\//i,
|
|
21
|
+
/^#[0-9a-f]{3,8}$/i,
|
|
22
|
+
/^(true|false|null|undefined)$/i,
|
|
23
|
+
/^\{\{[^}]*\}\}$/,
|
|
24
|
+
/^@(if|for|switch|else|case|defer|empty|placeholder|loading|error)\b/,
|
|
25
|
+
/^&\w+;$/,
|
|
26
|
+
/&[a-z]+;/i,
|
|
27
|
+
/^\d+(px|rem|em|vh|vw|%|°|deg|ms|s)$/,
|
|
28
|
+
/^[<>{}[\]()]+$/,
|
|
29
|
+
/\w+\(\)$/,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const HTML_ENTITIES = {
|
|
33
|
+
'&': '&',
|
|
34
|
+
'<': '<',
|
|
35
|
+
'>': '>',
|
|
36
|
+
'"': '"',
|
|
37
|
+
''': "'",
|
|
38
|
+
''': "'",
|
|
39
|
+
' ': ' ',
|
|
40
|
+
'–': '–',
|
|
41
|
+
'—': '—',
|
|
42
|
+
'«': '«',
|
|
43
|
+
'»': '»',
|
|
44
|
+
'€': '€',
|
|
45
|
+
'©': '©',
|
|
46
|
+
'®': '®',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ICON_CLASS_PATTERN =
|
|
50
|
+
/class="[^"]*\b(?:material-symbols|pi-icon|pi pi-|fa[rsbldt]?(?:\s|"|-)|icon\b|bi-|symbol)/i;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decodes HTML entities to their character equivalents.
|
|
54
|
+
* @param {string} str
|
|
55
|
+
* @returns {string}
|
|
56
|
+
* @example decodeHtmlEntities('&') // '&'
|
|
57
|
+
*/
|
|
58
|
+
function decodeHtmlEntities(str) {
|
|
59
|
+
if (!str) {
|
|
60
|
+
return str;
|
|
61
|
+
}
|
|
62
|
+
let result = str;
|
|
63
|
+
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
64
|
+
result = result.replaceAll(entity, char);
|
|
65
|
+
}
|
|
66
|
+
return result.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Determines if text should be ignored (too short, numeric, URL, etc.).
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function shouldIgnore(text) {
|
|
75
|
+
if (!text) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
const trimmed = text.trim();
|
|
79
|
+
return trimmed.length < 2 || trimmed.length > 500 || IGNORE_PATTERNS.some(p => p.test(trimmed));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isIconContainer(match) {
|
|
83
|
+
return ICON_CLASS_PATTERN.test(match);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Removes already-translated Transloco expressions from template.
|
|
88
|
+
* @param {string} template
|
|
89
|
+
* @param {boolean} [skipTranslated=true]
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function cleanTranslocoExpressions(template, skipTranslated = true) {
|
|
93
|
+
if (!skipTranslated) {
|
|
94
|
+
return template;
|
|
95
|
+
}
|
|
96
|
+
return template
|
|
97
|
+
.replace(/\{\{[^}]*\|\s*transloco[^}]*(\{[^}]*\})?[^}]*\}\}/g, '')
|
|
98
|
+
.replace(/\[[\w.-]+\]="[^"]*\|\s*transloco[^"]*"/g, '')
|
|
99
|
+
.replace(/\{\{\s*t\s*\([^)]*\)\s*\}\}/g, '')
|
|
100
|
+
.replace(/"'[^']+'\s*\|\s*transloco[^"]*"/g, '""')
|
|
101
|
+
.replace(/\*transloco\s*=\s*"[^"]*"/g, '')
|
|
102
|
+
.replace(/\*transloco\s*=\s*'[^']*'/g, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Removes Transloco function calls from TypeScript code.
|
|
107
|
+
* @param {string} code
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function cleanTranslocoCode(code) {
|
|
111
|
+
return code
|
|
112
|
+
.replace(/translate\s*\(\s*['"][^'"]+['"]\s*\)/g, '')
|
|
113
|
+
.replace(/transloco\s*\(\s*['"][^'"]+['"]\s*\)/g, '')
|
|
114
|
+
.replace(/\|\s*transloco/g, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractMatchText(match, group, decodeFn) {
|
|
118
|
+
const rawText = (match[group] || '').trim().replace(/\s+/g, ' ');
|
|
119
|
+
return { rawText, text: decodeFn(rawText) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shouldAddMatch(ctx) {
|
|
123
|
+
const { text, key, seen, shouldIgnoreFn } = ctx;
|
|
124
|
+
return !shouldIgnoreFn(text) && !seen.has(key);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function processPatternMatch(match, pattern, ctx) {
|
|
128
|
+
const { options, seen, results } = ctx;
|
|
129
|
+
const { context, group = 1, attr } = pattern;
|
|
130
|
+
if (isIconContainer(match[0])) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const { rawText, text } = extractMatchText(match, group, options.decodeFn);
|
|
134
|
+
const key = `${context}:${text}`;
|
|
135
|
+
if (shouldAddMatch({ text, key, seen, shouldIgnoreFn: options.shouldIgnoreFn })) {
|
|
136
|
+
seen.add(key);
|
|
137
|
+
results.push({ text, rawText, context, attr });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getExtractOptions(options) {
|
|
142
|
+
return {
|
|
143
|
+
shouldIgnoreFn: options.shouldIgnoreFn || shouldIgnore,
|
|
144
|
+
decodeFn: options.decodeFn || decodeHtmlEntities,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function processPatternMatches(content, pattern, ctx) {
|
|
149
|
+
for (const match of content.matchAll(pattern.regex)) {
|
|
150
|
+
processPatternMatch(match, pattern, ctx);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Extracts translatable text using regex patterns.
|
|
156
|
+
* @param {string} content
|
|
157
|
+
* @param {Array<Object>} patterns
|
|
158
|
+
* @param {Object} [options]
|
|
159
|
+
* @returns {Array<ExtractedText>}
|
|
160
|
+
*/
|
|
161
|
+
function extractWithPatterns(content, patterns, options = {}) {
|
|
162
|
+
const results = [];
|
|
163
|
+
const ctx = { options: getExtractOptions(options), seen: new Set(), results };
|
|
164
|
+
for (const pattern of patterns) {
|
|
165
|
+
processPatternMatches(content, pattern, ctx);
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks if text looks like a translation key (dot-separated).
|
|
172
|
+
* @param {string} text
|
|
173
|
+
* @returns {boolean}
|
|
174
|
+
* @example isTranslationKey('common.buttons.save') // true
|
|
175
|
+
*/
|
|
176
|
+
function isTranslationKey(text) {
|
|
177
|
+
return /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/i.test(text);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
decodeHtmlEntities,
|
|
182
|
+
shouldIgnore,
|
|
183
|
+
cleanTranslocoExpressions,
|
|
184
|
+
cleanTranslocoCode,
|
|
185
|
+
extractWithPatterns,
|
|
186
|
+
isTranslationKey,
|
|
187
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Path resolution for i18n directories and output files.
|
|
5
|
+
* Merges CLI arguments with config file settings.
|
|
6
|
+
* @module paths
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { getArgValue } = require('./config');
|
|
11
|
+
const { DEFAULT_EXCLUDED_FOLDERS } = require('./file-walker');
|
|
12
|
+
|
|
13
|
+
function resolveI18nDir(args, config, cwd) {
|
|
14
|
+
return getArgValue(args, config, {
|
|
15
|
+
flag: '--i18n-dir',
|
|
16
|
+
configKey: 'i18nDir',
|
|
17
|
+
defaultValue: path.join(cwd, 'src', 'assets', 'i18n'),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveSrcDir(args, config, cwd) {
|
|
22
|
+
return getArgValue(args, config, {
|
|
23
|
+
flag: '--src',
|
|
24
|
+
configKey: 'src',
|
|
25
|
+
defaultValue: path.join(cwd, 'src', 'app'),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveOutputFile(ctx) {
|
|
30
|
+
const { args, config, i18nDir, lang } = ctx;
|
|
31
|
+
return getArgValue(args, config, {
|
|
32
|
+
flag: '--output',
|
|
33
|
+
configKey: 'output',
|
|
34
|
+
defaultValue: path.join(i18nDir, lang ? `${lang}.json` : 'extracted.json'),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves all paths needed for extraction and translation
|
|
40
|
+
* @param {Object} ctx
|
|
41
|
+
* @returns {Object}
|
|
42
|
+
*/
|
|
43
|
+
function resolvePaths(ctx) {
|
|
44
|
+
const { args, config, cwd, lang = null } = ctx;
|
|
45
|
+
const i18nDir = resolveI18nDir(args, config, cwd);
|
|
46
|
+
const reportDir = path.join(cwd, '.i18n');
|
|
47
|
+
return {
|
|
48
|
+
srcDir: resolveSrcDir(args, config, cwd),
|
|
49
|
+
i18nDir,
|
|
50
|
+
reportDir,
|
|
51
|
+
backupDir: path.join(reportDir, 'backup'),
|
|
52
|
+
outputFile: resolveOutputFile({ args, config, i18nDir, lang }),
|
|
53
|
+
keyMappingFile: path.join(cwd, '.i18n-keys.json'),
|
|
54
|
+
excludedFolders: config.excludedFolders || DEFAULT_EXCLUDED_FOLDERS,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
resolvePaths,
|
|
60
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Plugin validation and help formatting.
|
|
5
|
+
* Validates plugin structure and generates CLI help output.
|
|
6
|
+
* @module plugin-interface
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @type {Array<string>} */
|
|
10
|
+
const PLUGIN_TYPES = ['parser', 'adapter', 'provider'];
|
|
11
|
+
|
|
12
|
+
const checkName = p =>
|
|
13
|
+
(!p.name || typeof p.name !== 'string') && 'Plugin must have a string "name"';
|
|
14
|
+
const checkType = p =>
|
|
15
|
+
(!p.type || !PLUGIN_TYPES.includes(p.type)) &&
|
|
16
|
+
`Plugin type must be one of: ${PLUGIN_TYPES.join(', ')}`;
|
|
17
|
+
const checkMeta = p => !p.meta?.description && 'Plugin must have meta.description';
|
|
18
|
+
|
|
19
|
+
function validateBasicFields(plugin) {
|
|
20
|
+
return [checkName(plugin), checkType(plugin), checkMeta(plugin)].filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateTypeSpecificMethods(plugin) {
|
|
24
|
+
const methodErrors = {
|
|
25
|
+
parser: () =>
|
|
26
|
+
typeof plugin.extract !== 'function' &&
|
|
27
|
+
'Parser plugins must have an extract(content, filePath, options) method',
|
|
28
|
+
adapter: () =>
|
|
29
|
+
typeof plugin.transform !== 'function' &&
|
|
30
|
+
'Adapter plugins must have a transform(content, text, key, context) method',
|
|
31
|
+
provider: () =>
|
|
32
|
+
typeof plugin.translate !== 'function' &&
|
|
33
|
+
typeof plugin.translateBatch !== 'function' &&
|
|
34
|
+
'Provider plugins must have translate() or translateBatch() method',
|
|
35
|
+
};
|
|
36
|
+
const validator = methodErrors[plugin.type];
|
|
37
|
+
return validator ? validator() : false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validates a plugin has required fields and methods.
|
|
42
|
+
* @param {Plugin} plugin
|
|
43
|
+
* @returns {PluginValidation}
|
|
44
|
+
*/
|
|
45
|
+
function validatePlugin(plugin) {
|
|
46
|
+
const errors = validateBasicFields(plugin);
|
|
47
|
+
const typeError = validateTypeSpecificMethods(plugin);
|
|
48
|
+
if (typeError) {
|
|
49
|
+
errors.push(typeError);
|
|
50
|
+
}
|
|
51
|
+
return { valid: errors.length === 0, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatPluginBasic(plugin) {
|
|
55
|
+
const source = plugin.source || 'builtin';
|
|
56
|
+
return [` ${plugin.name} (${source})`, ` ${plugin.meta?.description || 'No description'}`];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatPluginOptions(plugin) {
|
|
60
|
+
if (!plugin.options?.length) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
return plugin.options.map(opt => ` ${opt.flag.padEnd(20)} ${opt.description}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Formats plugin information for CLI help output.
|
|
68
|
+
* @param {Plugin} plugin
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function formatPluginHelp(plugin) {
|
|
72
|
+
const lines = formatPluginBasic(plugin);
|
|
73
|
+
if (plugin.extensions?.length) {
|
|
74
|
+
lines.push(` Extensions: ${plugin.extensions.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push(...formatPluginOptions(plugin));
|
|
77
|
+
if (plugin.env?.length) {
|
|
78
|
+
lines.push(` Env: ${plugin.env.map(e => e.name).join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { validatePlugin, formatPluginHelp };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Plugin discovery utilities for builtin, local, and npm sources.
|
|
5
|
+
* Scans directories and resolves plugin modules.
|
|
6
|
+
* @module plugin-resolver-utils
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('./fs-adapter');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const BUILTIN_DIR = path.join(__dirname, '..', 'plugins');
|
|
13
|
+
const LOCAL_DIR = '.i18n/plugins';
|
|
14
|
+
|
|
15
|
+
/** @type {Record<string, string>} */
|
|
16
|
+
const BUILTIN_ALIASES = {
|
|
17
|
+
'@i18nkit/parser-angular': 'parser-angular',
|
|
18
|
+
'@i18nkit/parser-primeng': 'parser-primeng',
|
|
19
|
+
'@i18nkit/parser-typescript': 'parser-typescript',
|
|
20
|
+
'@i18nkit/adapter-transloco': 'adapter-transloco',
|
|
21
|
+
'@i18nkit/provider-mymemory': 'provider-mymemory',
|
|
22
|
+
'@i18nkit/provider-deepl': 'provider-deepl',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PARSERS = ['parser-angular', 'parser-primeng', 'parser-typescript'];
|
|
26
|
+
const DEFAULT_ADAPTER = 'adapter-transloco';
|
|
27
|
+
const DEFAULT_PROVIDER = 'provider-mymemory';
|
|
28
|
+
const TYPE_TO_PLURAL = Object.freeze({
|
|
29
|
+
parser: 'parsers',
|
|
30
|
+
adapter: 'adapters',
|
|
31
|
+
provider: 'providers',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const tryLoadPath = p => (fs.existsSync(p) ? require(p) : null);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolves plugin from builtin or local directories.
|
|
38
|
+
* @param {string} identifier
|
|
39
|
+
* @param {string} cwd
|
|
40
|
+
* @returns {Plugin|null}
|
|
41
|
+
*/
|
|
42
|
+
function resolveFromBuiltinOrLocal(identifier, cwd) {
|
|
43
|
+
const aliased = BUILTIN_ALIASES[identifier] || identifier;
|
|
44
|
+
return (
|
|
45
|
+
tryLoadPath(path.join(BUILTIN_DIR, `${aliased}.js`)) ||
|
|
46
|
+
tryLoadPath(path.join(cwd, LOCAL_DIR, `${aliased}.js`))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves plugin from relative path.
|
|
52
|
+
* @param {string} identifier
|
|
53
|
+
* @param {string} cwd
|
|
54
|
+
* @returns {Plugin|null}
|
|
55
|
+
*/
|
|
56
|
+
function resolveFromRelative(identifier, cwd) {
|
|
57
|
+
if (!identifier.startsWith('./') && !identifier.startsWith('../')) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return tryLoadPath(path.resolve(cwd, identifier));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves plugin from npm package.
|
|
65
|
+
* @param {string} identifier
|
|
66
|
+
* @returns {Plugin}
|
|
67
|
+
* @throws {Error} If plugin not found
|
|
68
|
+
*/
|
|
69
|
+
function resolveFromNpm(identifier) {
|
|
70
|
+
try {
|
|
71
|
+
return require(identifier);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`Plugin not found: ${identifier}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scanDir(dir, mapper) {
|
|
78
|
+
if (!fs.existsSync(dir)) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return fs
|
|
82
|
+
.readdirSync(dir)
|
|
83
|
+
.filter(f => f.endsWith('.js'))
|
|
84
|
+
.map(mapper)
|
|
85
|
+
.filter(p => p?.name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Scans builtin plugins directory.
|
|
90
|
+
* @returns {Plugin[]}
|
|
91
|
+
*/
|
|
92
|
+
function scanBuiltin() {
|
|
93
|
+
return scanDir(BUILTIN_DIR, f => {
|
|
94
|
+
try {
|
|
95
|
+
return require(path.join(BUILTIN_DIR, f));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Scans local .i18n/plugins directory.
|
|
104
|
+
* @param {string} cwd
|
|
105
|
+
* @returns {Plugin[]}
|
|
106
|
+
*/
|
|
107
|
+
function scanLocal(cwd) {
|
|
108
|
+
return scanDir(path.join(cwd, LOCAL_DIR), f => {
|
|
109
|
+
try {
|
|
110
|
+
return require(path.join(cwd, LOCAL_DIR, f));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scanScopedDir(nodeModules, scope) {
|
|
118
|
+
const scopeDir = path.join(nodeModules, scope);
|
|
119
|
+
if (!fs.existsSync(scopeDir)) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
return fs.readdirSync(scopeDir).map(pkg => {
|
|
123
|
+
try {
|
|
124
|
+
return require(path.join(scopeDir, pkg));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Scans npm packages for i18nkit-* and @i18nkit/* plugins.
|
|
133
|
+
* @param {string} cwd
|
|
134
|
+
* @returns {Plugin[]}
|
|
135
|
+
*/
|
|
136
|
+
function scanNpm(cwd) {
|
|
137
|
+
const nodeModules = path.join(cwd, 'node_modules');
|
|
138
|
+
if (!fs.existsSync(nodeModules)) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const prefixed = fs
|
|
142
|
+
.readdirSync(nodeModules)
|
|
143
|
+
.filter(d => d.startsWith('i18nkit-'))
|
|
144
|
+
.map(d => {
|
|
145
|
+
try {
|
|
146
|
+
return require(path.join(nodeModules, d));
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const scoped = scanScopedDir(nodeModules, '@i18nkit');
|
|
152
|
+
return [...prefixed, ...scoped].filter(p => p?.name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
DEFAULT_PARSERS,
|
|
157
|
+
DEFAULT_ADAPTER,
|
|
158
|
+
DEFAULT_PROVIDER,
|
|
159
|
+
TYPE_TO_PLURAL,
|
|
160
|
+
resolveFromBuiltinOrLocal,
|
|
161
|
+
resolveFromRelative,
|
|
162
|
+
resolveFromNpm,
|
|
163
|
+
scanBuiltin,
|
|
164
|
+
scanLocal,
|
|
165
|
+
scanNpm,
|
|
166
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Plugin resolution engine with caching and auto-discovery.
|
|
5
|
+
* Resolves plugins from builtin, local (.i18n/plugins), and npm sources.
|
|
6
|
+
* @module plugin-resolver
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { validatePlugin } = require('./plugin-interface');
|
|
10
|
+
const {
|
|
11
|
+
DEFAULT_PARSERS,
|
|
12
|
+
DEFAULT_ADAPTER,
|
|
13
|
+
DEFAULT_PROVIDER,
|
|
14
|
+
TYPE_TO_PLURAL,
|
|
15
|
+
resolveFromBuiltinOrLocal,
|
|
16
|
+
resolveFromRelative,
|
|
17
|
+
resolveFromNpm,
|
|
18
|
+
scanBuiltin,
|
|
19
|
+
scanLocal,
|
|
20
|
+
scanNpm,
|
|
21
|
+
} = require('./plugin-resolver-utils');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Plugin resolution and registry management.
|
|
25
|
+
* Caches resolved plugins and maintains a registry of discovered plugins.
|
|
26
|
+
*/
|
|
27
|
+
class PluginResolver {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} [cwd=process.cwd()] - Working directory for resolution
|
|
30
|
+
*/
|
|
31
|
+
constructor(cwd = process.cwd()) {
|
|
32
|
+
this.cwd = cwd;
|
|
33
|
+
/** @type {Map<string, Plugin>} */
|
|
34
|
+
this.cache = new Map();
|
|
35
|
+
/** @type {PluginRegistry|null} */
|
|
36
|
+
this.registry = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a plugin by identifier (name, path, or npm package).
|
|
41
|
+
* @param {string} identifier - Plugin identifier
|
|
42
|
+
* @returns {Plugin|null}
|
|
43
|
+
*/
|
|
44
|
+
resolve(identifier) {
|
|
45
|
+
if (this.cache.has(identifier)) {
|
|
46
|
+
return this.cache.get(identifier);
|
|
47
|
+
}
|
|
48
|
+
const plugin =
|
|
49
|
+
resolveFromBuiltinOrLocal(identifier, this.cwd) ||
|
|
50
|
+
resolveFromRelative(identifier, this.cwd) ||
|
|
51
|
+
resolveFromNpm(identifier);
|
|
52
|
+
this.cache.set(identifier, plugin);
|
|
53
|
+
return plugin;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Discovers all available plugins from all sources.
|
|
58
|
+
* @returns {PluginRegistry}
|
|
59
|
+
*/
|
|
60
|
+
discoverAll() {
|
|
61
|
+
if (this.registry) {
|
|
62
|
+
return this.registry;
|
|
63
|
+
}
|
|
64
|
+
this.registry = {
|
|
65
|
+
parsers: [],
|
|
66
|
+
adapters: [],
|
|
67
|
+
providers: [],
|
|
68
|
+
all: [],
|
|
69
|
+
byName: new Map(),
|
|
70
|
+
errors: [],
|
|
71
|
+
};
|
|
72
|
+
this._loadFromSource('builtin', () => this._scanBuiltin());
|
|
73
|
+
this._loadFromSource('local', () => this._scanLocal());
|
|
74
|
+
this._loadFromSource('npm', () => this._scanNpm());
|
|
75
|
+
return this.registry;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_loadFromSource(sourceName, loader) {
|
|
79
|
+
try {
|
|
80
|
+
for (const plugin of loader()) {
|
|
81
|
+
this._registerPlugin({ ...plugin, source: sourceName });
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
this.registry.errors.push({ source: sourceName, error: err.message });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_handleInvalidPlugin(plugin, validation) {
|
|
89
|
+
this.registry.errors.push({
|
|
90
|
+
plugin: plugin.name || 'unknown',
|
|
91
|
+
source: plugin.source,
|
|
92
|
+
errors: validation.errors,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_addPluginToRegistry(plugin) {
|
|
97
|
+
this.registry.byName.set(plugin.name, plugin);
|
|
98
|
+
this.registry.all.push(plugin);
|
|
99
|
+
this.cache.set(plugin.name, plugin);
|
|
100
|
+
const plural = TYPE_TO_PLURAL[plugin.type];
|
|
101
|
+
if (plural) {
|
|
102
|
+
this.registry[plural].push(plugin);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_registerPlugin(plugin) {
|
|
107
|
+
const validation = validatePlugin(plugin);
|
|
108
|
+
if (!validation.valid) {
|
|
109
|
+
this._handleInvalidPlugin(plugin, validation);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!this.registry.byName.has(plugin.name)) {
|
|
113
|
+
this._addPluginToRegistry(plugin);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_scanBuiltin() {
|
|
118
|
+
return scanBuiltin();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_scanLocal() {
|
|
122
|
+
return scanLocal(this.cwd);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_scanNpm() {
|
|
126
|
+
return scanNpm(this.cwd);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {'parser'|'adapter'|'provider'} type
|
|
131
|
+
* @returns {Plugin[]}
|
|
132
|
+
*/
|
|
133
|
+
getByType(type) {
|
|
134
|
+
return this.discoverAll()[TYPE_TO_PLURAL[type]] || [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} name
|
|
139
|
+
* @returns {Plugin|undefined}
|
|
140
|
+
*/
|
|
141
|
+
getByName(name) {
|
|
142
|
+
return this.discoverAll().byName.get(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Filters plugins by detection context (framework, libraries detected).
|
|
147
|
+
* @param {DetectionContext} context
|
|
148
|
+
* @returns {Plugin[]}
|
|
149
|
+
*/
|
|
150
|
+
filterByDetection(context) {
|
|
151
|
+
return this.discoverAll().all.filter(plugin => {
|
|
152
|
+
if (typeof plugin.detect !== 'function') {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
return plugin.detect(context);
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Loads plugins from configuration with defaults.
|
|
165
|
+
* @param {Object} [config={}]
|
|
166
|
+
* @param {string[]} [config.parsers] - Parser plugin names
|
|
167
|
+
* @param {string} [config.adapter] - Adapter plugin name
|
|
168
|
+
* @param {string} [config.provider] - Provider plugin name
|
|
169
|
+
* @returns {{parsers: Plugin[], adapter: Plugin, provider: Plugin}}
|
|
170
|
+
*/
|
|
171
|
+
loadFromConfig(config = {}) {
|
|
172
|
+
const parsers = (config.parsers || DEFAULT_PARSERS)
|
|
173
|
+
.map(name => this.resolve(name))
|
|
174
|
+
.sort((a, b) => (a.priority || 100) - (b.priority || 100));
|
|
175
|
+
return {
|
|
176
|
+
parsers,
|
|
177
|
+
adapter: this.resolve(config.adapter || DEFAULT_ADAPTER),
|
|
178
|
+
provider: this.resolve(config.provider || DEFAULT_PROVIDER),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
reset() {
|
|
183
|
+
this.cache.clear();
|
|
184
|
+
this.registry = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @type {PluginResolver|null} */
|
|
189
|
+
let defaultResolver = null;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Gets or creates the default resolver for the given working directory.
|
|
193
|
+
* @param {string} [cwd=process.cwd()]
|
|
194
|
+
* @returns {PluginResolver}
|
|
195
|
+
*/
|
|
196
|
+
function getResolver(cwd = process.cwd()) {
|
|
197
|
+
if (!defaultResolver || defaultResolver.cwd !== cwd) {
|
|
198
|
+
defaultResolver = new PluginResolver(cwd);
|
|
199
|
+
}
|
|
200
|
+
return defaultResolver;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Convenience function to load plugins from config.
|
|
205
|
+
* @param {Object} [config={}]
|
|
206
|
+
* @param {string} [cwd=process.cwd()]
|
|
207
|
+
* @returns {{parsers: Plugin[], adapter: Plugin, provider: Plugin}}
|
|
208
|
+
*/
|
|
209
|
+
const loadPlugins = (config = {}, cwd = process.cwd()) => getResolver(cwd).loadFromConfig(config);
|
|
210
|
+
|
|
211
|
+
module.exports = { getResolver, loadPlugins };
|