Source: lib/core/tooling/componentTagNaming.js

import fs from 'fs';
import path from 'path';

const registryCache = new Map();

/**
 * Converts an Avenx component filename into its canonical PascalCase name.
 * @param {string} fileName
 * @returns {string}
 */
export function componentNameFromFile(fileName) {
  const baseName = String(fileName)
    .replace(/^.*[/\\]/, '')
    .replace(/\.component\.js$/i, '');
  return baseName
    .split(/[-_]/)
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
    .join('');
}

/**
 * Finds components registered by the Avenx compiler.
 * Avenx registers components by scanning src/components for
 * .component.js files and normalizing their filenames.
 * @param {string} projectRoot
 * @param {string} [componentsDir]
 * @returns {Set<string>}
 */
export function findRegisteredComponents(projectRoot, componentsDir = 'src/components') {
  const root = path.resolve(projectRoot);
  const directory = path.resolve(root, componentsDir);
  const cacheKey = directory;

  if (registryCache.has(cacheKey)) {
    return new Set(registryCache.get(cacheKey));
  }

  const names = new Set();

  const visit = (currentDir) => {
    if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) {
      return;
    }

    for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
      const fullPath = path.join(currentDir, entry.name);

      if (entry.isDirectory()) {
        visit(fullPath);
      } else if (entry.isFile() && entry.name.endsWith('.component.js')) {
        names.add(componentNameFromFile(entry.name));
      }
    }
  };

  visit(directory);

  registryCache.set(cacheKey, new Set(names));
  return new Set(names);
}

/**
 * Resolves the configured Avenx components directory.
 * @param {string} projectRoot
 * @param {string} [componentsDir]
 * @returns {string}
 */
export function resolveComponentsDir(projectRoot, componentsDir) {
  if (componentsDir) {
    return componentsDir;
  }

  const configPath = path.join(path.resolve(projectRoot), 'avenx.config.json');

  if (!fs.existsSync(configPath)) {
    return 'src/components';
  }

  try {
    const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

    const srcDir =
      typeof config.srcDir === 'string' && config.srcDir.trim() !== ''
        ? config.srcDir.trim()
        : 'src';

    return path.join(srcDir, 'components');
  } catch {
    return 'src/components';
  }
}

/**
 * Masks text while preserving line positions.
 * @param {string} value
 * @returns {string}
 */
function mask(value) {
  return value.replace(/[^\r\n]/g, ' ');
}

/**
 * Removes Avenx metadata blocks that are not part of the template.
 * @param {string} source
 * @returns {string}
 */
export function extractLintableTemplate(source) {
  let template = source;

  const patterns = [
    /<!--[\s\S]*?-->/g,
    /<state\b[\s\S]*?\/>/gi,
    /<computed\b[\s\S]*?\/>/gi,
    /<action\b[\s\S]*?<\/action>/gi,
    /<resource\b[\s\S]*?<\/resource>/gi,
    /<resource\b[\s\S]*?\/>/gi,
  ];

  for (const pattern of patterns) {
    template = template.replace(pattern, mask);
  }

  return template;
}

/**
 * Finds registered component tags that are not written in PascalCase.
 * @param {string} source
 * @param {Set<string>} registeredComponents
 * @returns {Array<{tagName: string, expectedName: string, index: number}>}
 */
export function findInvalidComponentTags(source, registeredComponents) {
  const template = extractLintableTemplate(source);
  const invalidTags = [];
  const tagRegex = /<([A-Za-z][A-Za-z0-9:_-]*)\b/g;

  let match;

  while ((match = tagRegex.exec(template)) !== null) {
    const tagName = match[1];

    if (registeredComponents.has(tagName)) {
      continue;
    }

    const comparableTag = tagName.replace(/[-_]/g, '').toLowerCase();

    const normalized = [...registeredComponents].find(
      (componentName) =>
        componentName.replace(/[-_]/g, '').toLowerCase() === comparableTag,
    );

    if (normalized && normalized !== tagName) {
      invalidTags.push({
        tagName,
        expectedName: normalized,
        index: match.index + 1,
      });
    }
  }

  return invalidTags;
}

/**
 * Finds the nearest package root.
 * @param {string} filePath
 * @param {string} fallbackRoot
 * @returns {string}
 */
export function findProjectRoot(filePath, fallbackRoot) {
  let currentDir = path.dirname(path.resolve(filePath));
  const fallback = path.resolve(fallbackRoot);

  while (true) {
    if (fs.existsSync(path.join(currentDir, 'package.json'))) {
      return currentDir;
    }

    const parent = path.dirname(currentDir);
    const relativeToFallback = path.relative(fallback, parent);

    if (
      parent === currentDir ||
      relativeToFallback.startsWith('..') ||
      path.isAbsolute(relativeToFallback)
    ) {
      break;
    }

    currentDir = parent;
  }

  return fallback;
}