All files / lib/core/tooling loadComponent.js

90.37% Statements 122/135
68.75% Branches 11/16
100% Functions 3/3
90.37% Lines 122/135

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 19x 19x 19x 19x 19x 19x 19x 19x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 23x 23x     23x 23x 23x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 255x 20x 20x     20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x               19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 20x     19x 20x  
/**
 * @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;
}