@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -0
  4. package/bin/cli.js +48 -0
  5. package/bin/commands/apply.js +48 -0
  6. package/bin/commands/check-sync.js +35 -0
  7. package/bin/commands/extract-utils.js +216 -0
  8. package/bin/commands/extract.js +198 -0
  9. package/bin/commands/find-orphans.js +36 -0
  10. package/bin/commands/help.js +34 -0
  11. package/bin/commands/index.js +79 -0
  12. package/bin/commands/translate.js +51 -0
  13. package/bin/commands/version.js +17 -0
  14. package/bin/commands/watch.js +34 -0
  15. package/bin/core/applier-utils.js +144 -0
  16. package/bin/core/applier.js +165 -0
  17. package/bin/core/args.js +147 -0
  18. package/bin/core/backup.js +74 -0
  19. package/bin/core/command-interface.js +69 -0
  20. package/bin/core/config.js +108 -0
  21. package/bin/core/context.js +86 -0
  22. package/bin/core/detector.js +152 -0
  23. package/bin/core/file-walker.js +159 -0
  24. package/bin/core/fs-adapter.js +56 -0
  25. package/bin/core/help-generator.js +208 -0
  26. package/bin/core/index.js +63 -0
  27. package/bin/core/json-utils.js +213 -0
  28. package/bin/core/key-generator.js +75 -0
  29. package/bin/core/log-utils.js +26 -0
  30. package/bin/core/orphan-finder.js +208 -0
  31. package/bin/core/parser-utils.js +187 -0
  32. package/bin/core/paths.js +60 -0
  33. package/bin/core/plugin-interface.js +83 -0
  34. package/bin/core/plugin-resolver-utils.js +166 -0
  35. package/bin/core/plugin-resolver.js +211 -0
  36. package/bin/core/sync-checker-utils.js +99 -0
  37. package/bin/core/sync-checker.js +199 -0
  38. package/bin/core/translator.js +197 -0
  39. package/bin/core/types.js +297 -0
  40. package/bin/core/watcher.js +119 -0
  41. package/bin/plugins/adapter-transloco.js +156 -0
  42. package/bin/plugins/parser-angular.js +56 -0
  43. package/bin/plugins/parser-primeng.js +79 -0
  44. package/bin/plugins/parser-typescript.js +66 -0
  45. package/bin/plugins/provider-deepl.js +65 -0
  46. package/bin/plugins/provider-mymemory.js +192 -0
  47. package/package.json +123 -0
  48. 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
+ '&amp;': '&',
34
+ '&lt;': '<',
35
+ '&gt;': '>',
36
+ '&quot;': '"',
37
+ '&#39;': "'",
38
+ '&apos;': "'",
39
+ '&nbsp;': ' ',
40
+ '&ndash;': '–',
41
+ '&mdash;': '—',
42
+ '&laquo;': '«',
43
+ '&raquo;': '»',
44
+ '&euro;': '€',
45
+ '&copy;': '©',
46
+ '&reg;': '®',
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('&amp;') // '&'
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 };