core_plugin-resolver-utils.js

'use strict';

/**
 * @fileoverview Plugin discovery utilities for builtin, local, and npm sources.
 * Scans directories and resolves plugin modules.
 * @module plugin-resolver-utils
 */

const fs = require('./fs-adapter');
const path = require('path');

const BUILTIN_DIR = path.join(__dirname, '..', 'plugins');
const LOCAL_DIR = '.i18nkit/plugins';

/** @type {Record<string, string>} */
const BUILTIN_ALIASES = {
  '@i18nkit/parser-angular': 'parser-angular',
  '@i18nkit/parser-primeng': 'parser-primeng',
  '@i18nkit/parser-typescript': 'parser-typescript',
  '@i18nkit/adapter-transloco': 'adapter-transloco',
  '@i18nkit/provider-mymemory': 'provider-mymemory',
  '@i18nkit/provider-deepl': 'provider-deepl',
};

const DEFAULT_PARSERS = ['parser-angular', 'parser-primeng', 'parser-typescript'];
const DEFAULT_ADAPTER = 'adapter-transloco';
const DEFAULT_PROVIDER = 'provider-mymemory';
const TYPE_TO_PLURAL = Object.freeze({
  parser: 'parsers',
  adapter: 'adapters',
  provider: 'providers',
});

const tryLoadPath = p => (fs.existsSync(p) ? require(p) : null);

/**
 * Resolves plugin from builtin or local directories.
 * @param {string} identifier
 * @param {string} cwd
 * @returns {Plugin|null}
 */
function resolveFromBuiltinOrLocal(identifier, cwd) {
  const aliased = BUILTIN_ALIASES[identifier] || identifier;
  return (
    tryLoadPath(path.join(BUILTIN_DIR, `${aliased}.js`)) ||
    tryLoadPath(path.join(cwd, LOCAL_DIR, `${aliased}.js`))
  );
}

/**
 * Resolves plugin from relative path.
 * @param {string} identifier
 * @param {string} cwd
 * @returns {Plugin|null}
 */
function resolveFromRelative(identifier, cwd) {
  if (!identifier.startsWith('./') && !identifier.startsWith('../')) {
    return null;
  }
  return tryLoadPath(path.resolve(cwd, identifier));
}

/**
 * Resolves plugin from npm package.
 * @param {string} identifier
 * @returns {Plugin}
 * @throws {Error} If plugin not found
 */
function resolveFromNpm(identifier) {
  try {
    return require(identifier);
  } catch {
    throw new Error(`Plugin not found: ${identifier}`);
  }
}

function scanDir(dir, mapper) {
  if (!fs.existsSync(dir)) {
    return [];
  }
  return fs
    .readdirSync(dir)
    .filter(f => f.endsWith('.js'))
    .map(mapper)
    .filter(p => p?.name);
}

/**
 * Scans builtin plugins directory.
 * @returns {Plugin[]}
 */
function scanBuiltin() {
  return scanDir(BUILTIN_DIR, f => {
    try {
      return require(path.join(BUILTIN_DIR, f));
    } catch {
      return null;
    }
  });
}

/**
 * Scans local .i18n/plugins directory.
 * @param {string} cwd
 * @returns {Plugin[]}
 */
function scanLocal(cwd) {
  return scanDir(path.join(cwd, LOCAL_DIR), f => {
    try {
      return require(path.join(cwd, LOCAL_DIR, f));
    } catch {
      return null;
    }
  });
}

function scanScopedDir(nodeModules, scope) {
  const scopeDir = path.join(nodeModules, scope);
  if (!fs.existsSync(scopeDir)) {
    return [];
  }
  return fs.readdirSync(scopeDir).map(pkg => {
    try {
      return require(path.join(scopeDir, pkg));
    } catch {
      return null;
    }
  });
}

/**
 * Scans npm packages for i18nkit-* and @i18nkit/* plugins.
 * @param {string} cwd
 * @returns {Plugin[]}
 */
function scanNpm(cwd) {
  const nodeModules = path.join(cwd, 'node_modules');
  if (!fs.existsSync(nodeModules)) {
    return [];
  }
  const prefixed = fs
    .readdirSync(nodeModules)
    .filter(d => d.startsWith('i18nkit-'))
    .map(d => {
      try {
        return require(path.join(nodeModules, d));
      } catch {
        return null;
      }
    });
  const scoped = scanScopedDir(nodeModules, '@i18nkit');
  return [...prefixed, ...scoped].filter(p => p?.name);
}

module.exports = {
  DEFAULT_PARSERS,
  DEFAULT_ADAPTER,
  DEFAULT_PROVIDER,
  TYPE_TO_PLURAL,
  resolveFromBuiltinOrLocal,
  resolveFromRelative,
  resolveFromNpm,
  scanBuiltin,
  scanLocal,
  scanNpm,
};