Source: lib/core/tooling/loadComponent.js

/**
 * @file loadComponent.js
 * @description Compiles a single Avenx component file into a usable class.
 *
 * Avenx component files are not JavaScript modules. A `.component.js` holds
 * `<state>`, `<action>` and template markup, and only means anything after the
 * compiler has turned it into a class. So a test cannot `import` one — which
 * is a problem for generated regression tests, whose whole purpose is to mount
 * the component a trace was recorded against.
 *
 * This runs the same `ComponentParser` the build uses on one file and
 * evaluates the class it emits. Because it is the real compiler, a generated
 * test exercises the same code the application ships, not a hand-written
 * approximation of it.
 *
 * Lives under `tooling/` rather than `testing/` because it reads from disk:
 * the house rule is that `fs` and `path` stay out of anything the browser
 * runtime can reach.
 * @module lib/core/tooling/loadComponent
 */

import fs from 'fs';
import path from 'path';
import ComponentParser from '../../compiler/ComponentParser.js';
import StyleProcessor from '../../compiler/StyleProcessor.js';
import { EXPRESSION_OPS } from '../expression/ops.js';
import {
  findBridgeImports,
  bridgeNameFromFile,
  bridgeBindingName,
} from '../../compiler/BridgeParser.js';
import { AvenxComponent } from '../runtime/AvenxComponent.js';
import { AvenxPage } from '../runtime/AvenxPage.js';

/**
 * Derives the class name the compiler will emit for a component file.
 * @param {string} filePath - Path to the component or page file.
 * @returns {string} The PascalCase class name.
 */
export function classNameFor(filePath) {
  return path
    .basename(filePath)
    .replace(/\.(component|page)?\.(js|html|avx)$/i, '')
    .split(/[-_]/)
    .filter(Boolean)
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
    .join('');
}

/**
 * Lists the bridges a component file imports.
 *
 * A component that imports a bridge compiles to a class that references a
 * binding the *bundle* supplies. Loading such a component outside a bundle
 * means supplying those bindings, so a caller needs to know which are wanted.
 * @param {string} filePath - Path to the component or page file.
 * @returns {string[]} Bridge names, e.g. `['cart', 'auth']`.
 */
export function bridgeDependencies(filePath) {
  const resolved = path.resolve(filePath);
  if (!fs.existsSync(resolved)) {
    return [];
  }
  const source = fs.readFileSync(resolved, 'utf-8');
  return findBridgeImports(resolved, source).map((entry) => bridgeNameFromFile(entry.resolved));
}

/**
 * Compiles a component or page file and returns the class.
 * @param {string} filePath - Absolute or cwd-relative path to the file.
 * @param {object} [options] - Compilation options.
 * @param {'component'|'page'} [options.type] - Inferred from the filename when omitted.
 * @param {object} [options.config] - Project configuration to compile with.
 * @param {object} [options.bridges] - Bridge instances keyed by bridge name, for a
 *   component that imports one. Import the bridge module and pass its default
 *   export; {@link bridgeDependencies} lists which are needed.
 * @returns {Function} The compiled component or page class.
 * @throws {Error} When the file does not exist, does not compile to a class, or
 *   imports a bridge that was not supplied.
 */
export function loadComponent(filePath, options = {}) {
  const resolved = path.resolve(filePath);
  if (!fs.existsSync(resolved)) {
    throw new Error(`Cannot load component: ${resolved} does not exist.`);
  }

  const type = options.type || (/\.page\.js$/i.test(resolved) ? 'page' : 'component');
  const parser = new ComponentParser(new StyleProcessor({}, options.config || {}), [], options.config || null);
  const source = parser.parse(resolved, type);
  const className = classNameFor(resolved);

  // A component that imports a bridge compiles to a reference to
  // `__avx_bridge_<name>`, which the bundle declares. Outside a bundle those
  // bindings have to be provided, so they become parameters of the factory.
  const required = bridgeDependencies(resolved);
  const supplied = options.bridges || {};
  const missing = required.filter((name) => !(name in supplied));
  if (missing.length > 0) {
    throw new Error(
      `${path.basename(resolved)} imports the bridge${missing.length === 1 ? '' : 's'} ` +
        `${missing.map((name) => `"${name}"`).join(', ')}. Pass ${missing.length === 1 ? 'it' : 'them'} to ` +
        `loadComponent(path, { bridges: { ${missing.join(', ')} } }) — import the bridge module and pass its ` +
        'default export.',
    );
  }

  const bindingNames = required.map(bridgeBindingName);
  const bindingValues = required.map((name) => supplied[name]);

  // The compiler emits `class X extends AvenxComponent { ... }` as a bare
  // declaration, which is exactly what a bundle concatenates. Evaluating it
  // with the base classes and bridge bindings in scope is the same thing the
  // bundle does, and keeps this helper from having to understand the shape of
  // the generated code.
  // The class body also refers to the expression primitives its compiled
  // closures call. In a real build those arrive as named imports the bundler
  // resolves; here they are injected alongside the base classes for the same
  // reason -- so this helper does not have to understand the generated code.
  const opNames = Object.keys(EXPRESSION_OPS);
  const opValues = opNames.map((name) => EXPRESSION_OPS[name]);

  const factory = new Function(
    'AvenxComponent',
    'AvenxPage',
    ...opNames,
    ...bindingNames,
    `${source}\nreturn ${className};`,
  );

  const ComponentClass = factory(AvenxComponent, AvenxPage, ...opValues, ...bindingValues);
  if (typeof ComponentClass !== 'function') {
    throw new Error(`Compiling ${resolved} did not produce a class called ${className}.`);
  }
  return ComponentClass;
}