Source: lib/compiler/modules.js

/**
 * @file modules.js
 * @description Wraps what the compiler generates into real ES modules.
 *
 * ## Where the boundary is
 *
 * The compiler owns Avenx semantics: templates, declarations, expressions,
 * scoped CSS, Atlas, the shape of a generated component class. The bundler owns
 * modules: resolution, the dependency graph, dead-code elimination, emission.
 *
 * This file is the seam. It takes the class declaration `ComponentParser`
 * produces — unchanged, still a bare `class X extends AvenxComponent` — and
 * gives it the module framing a graph can read: an import of the runtime, the
 * developer's own imports preserved verbatim, and a default export.
 *
 * Keeping the parser's output shape untouched is deliberate. It is what
 * `avenx-core/testing`, the Vite plugin and six test files consume, and there
 * is no reason a change to how modules are *linked* should change how a
 * component is *compiled*.
 *
 * ## Why the developer's imports are copied verbatim
 *
 * This is the fix for the defect that motivated the whole migration. The old
 * pipeline ran every module through `rewriteRuntimeImports`, which turned the
 * runtime import into destructuring and **deleted every other import**. A
 * component importing an npm package compiled to a green build and a
 * `ReferenceError` in the browser, because the import simply ceased to exist.
 *
 * Here an import statement is passed through untouched. If it names something
 * that does not exist, the bundler says so and the build fails. There is no
 * code path that removes one.
 * @module lib/compiler/modules
 */

import path from 'path';
import { runtimeImportStatement } from './codegen/expression.js';

/**
 * Turns an absolute path into a specifier a generated module can carry.
 *
 * Absolute POSIX-style paths, because the resolver treats them as paths rather
 * than package names and Windows separators would read as escape sequences
 * inside the generated string literal.
 * @param {string} target - Absolute path to the module.
 * @returns {string} A specifier safe to embed.
 */
export function pathSpecifier(target) {
  return JSON.stringify(target.split(path.sep).join('/'));
}

/**
 * Extracts the import declarations from a source file, in order.
 *
 * Used to carry a component's own imports into its generated module. The
 * scanner-based module reader is not used here because this runs on Avenx
 * template source, which is not JavaScript: an `import` line is the one
 * JavaScript-shaped construct allowed at the top of a `.component.js`, and it
 * is read as such.
 * @param {string} source - The component or page source.
 * @returns {string[]} The import statements, as written.
 */
export function collectImportStatements(source) {
  const statements = [];
  const pattern = /^[ \t]*import\s+(?:[\s\S]*?\s+from\s+)?['"][^'"]*['"];?[ \t]*$/gm;
  let match;
  while ((match = pattern.exec(source)) !== null) {
    statements.push(match[0].trim());
  }
  return statements;
}

/**
 * Builds the ES module for a compiled component or page.
 * @param {object} options - Module options.
 * @param {string} options.className - The generated class name.
 * @param {string} options.body - The class declaration `ComponentParser` produced.
 * @param {boolean} options.isPage - Whether the unit is a page.
 * @param {string[]} options.imports - The developer's own import statements.
 * @param {Array<{local: string, binding: string}>} options.bridgeBindings - Bridges
 *   the unit imported, so the class body's binding names resolve.
 * @returns {string} The module source.
 */
export function componentModule({ className, body, isPage, imports, bridgeBindings }) {
  const base = isPage ? 'AvenxPage' : 'AvenxComponent';

  // The class body refers to bridges by their stable binding name so that the
  // generated code is the same whatever the developer called the import. One
  // alias per bridge reconciles the two without touching either.
  const aliases = bridgeBindings
    .filter((entry) => entry.local !== entry.binding)
    .map((entry) => `const ${entry.binding} = ${entry.local};`);

  // The base class plus the expression primitives the compiled closures call.
  // Imported unconditionally rather than only when the class body has a table:
  // an unused named import is removed by the bundler's tree shaker, and making
  // the import conditional would mean the emitter has to know what the
  // generator did.
  return [
    runtimeImportStatement(base),
    ...imports,
    ...aliases,
    body,
    `export default ${className};`,
    '',
  ].join('\n');
}

/**
 * Builds the ES module for a bridge.
 *
 * A bridge file is already valid JavaScript, so almost nothing happens to it:
 * its default export is named so the runtime can be told what to call it, and
 * that is all. Its own imports — the runtime, other bridges — stay as they were
 * written and become real edges in the graph, which is what replaced the old
 * alias-and-concatenate scheme.
 * @param {object} options - Module options.
 * @param {string} options.name - The bridge's declared name.
 * @param {string} options.binding - The stable binding name for the bridge.
 * @param {string} options.source - The bridge source, after env substitution.
 * @returns {string} The module source.
 */
export function bridgeModule({ name, binding, source }) {
  const named = source.replace(/export\s+default\s+/, `const ${binding} = `);

  return [
    "import { defineBridgeName as __avx_defineBridgeName } from 'avenx-core/runtime';",
    named.trimEnd(),
    `__avx_defineBridgeName(${JSON.stringify(name)}, ${binding});`,
    `export default ${binding};`,
    '',
  ].join('\n');
}

/**
 * Builds the application entry module.
 *
 * `main.app.js` is the developer's file and stays theirs. The compiler adds
 * what it discovered for them — the pages under `src/pages/`, the bridges
 * something imports — as ordinary imports and registrations, in the place the
 * old pipeline injected them, so a project that worked before works now.
 * @param {object} options - Entry options.
 * @param {string} options.source - `main.app.js`, after env substitution.
 * @param {Array<{name: string, file: string, kind: string}>} options.registrations -
 *   Units to import and register.
 * @param {string[]} [options.prelude] - Ids of modules to import before
 *   anything else: the documented globals, the development tools, the Rewind
 *   configuration.
 * @returns {string} The module source.
 */
export function entryModule({ source, registrations, prelude = [] }) {
  const imports = [];
  const calls = [];

  for (const id of prelude) {
    imports.push(`import ${pathSpecifier(id)};`);
  }

  registrations.forEach((entry, index) => {
    const local = `__avx_${entry.kind}_${index}`;
    imports.push(`import ${local} from ${pathSpecifier(entry.file)};`);
    const method = entry.kind === 'page' ? 'registerPage' : entry.kind === 'bridge' ? 'registerBridge' : 'register';
    calls.push(`app.${method}(${JSON.stringify(entry.name)}, ${local});`);
  });

  let body = source;

  if (calls.length > 0) {
    // The developer may have called their application something other than
    // `app`, and may have marked where registrations belong.
    let appName = 'app';
    const appMatch = body.match(/(?:const|let|var)?\s*([\w$.]+)\s*=\s*new\s+AvenxApp\(/);
    if (appMatch) {
      appName = appMatch[1].trim();
    }

    let block = calls.join('\n');
    if (appName !== 'app') {
      block = block.replace(/\bapp\.register/g, `${appName}.register`);
    }

    if (body.includes('// @avenx-inject')) {
      body = body.replace('// @avenx-inject', block);
    } else {
      const declaration = /((?:const|let|var)?\s*[\w$.]+\s*=\s*new\s+AvenxApp\([\s\S]*?\);?)/;
      if (declaration.test(body)) {
        body = body.replace(declaration, `$1\n${block}`);
      } else {
        body = `${block}\n${body}`;
      }
    }
  }

  return [...imports, body, ''].join('\n');
}

/**
 * Builds the module that installs Avenx's documented globals.
 *
 * ## A deliberate narrowing
 *
 * The concatenated build had no module system, so generated component classes
 * reached the runtime through bare globals and `globalThis.Avenx` had to carry
 * the whole export surface for anything else to be reachable at all.
 *
 * Generated modules now import what they use, so the global object is no longer
 * a mechanism — it is a compatibility surface. It carries exactly the names
 * `lib/core/globals.js` declares public. Importing the whole namespace to
 * publish it would pin every module in the runtime into every bundle, which
 * would trade a real saving for an escape hatch that imports already provide.
 * @param {string[]} names - The public global names.
 * @param {string} namespace - The namespace global's name.
 * @returns {string} The module source.
 */
export function globalsModule(names, namespace) {
  return [
    `import { ${names.join(', ')} } from 'avenx-core/runtime';`,
    '',
    `const __avx_public = { ${names.join(', ')} };`,
    '',
    'const __avx_root =',
    "  typeof globalThis !== 'undefined'",
    '    ? globalThis',
    "    : typeof window !== 'undefined'",
    '      ? window',
    "      : typeof global !== 'undefined'",
    '        ? global',
    '        : null;',
    '',
    'if (__avx_root) {',
    `  __avx_root[${JSON.stringify(namespace)}] = __avx_public;`,
    '  for (const __avx_name of Object.keys(__avx_public)) {',
    '    __avx_root[__avx_name] = __avx_public[__avx_name];',
    '  }',
    '}',
    '',
  ].join('\n');
}

/**
 * Builds the module that configures Rewind for this project.
 *
 * A project that leaves `rewind` alone gets no module at all, so the defaults
 * baked into the journal are the only thing shipped and the bundle is
 * unchanged from what it was before Rewind existed.
 * @param {object} settings - Non-default journal settings.
 * @returns {string} The module source.
 */
export function rewindConfigModule(settings) {
  return [
    "import { journal } from 'avenx-core/runtime';",
    '',
    `journal.configure(${JSON.stringify(settings)});`,
    '',
  ].join('\n');
}

/**
 * Builds the development-only module that exposes the trace recorder.
 *
 * ## Why this module exists at all
 *
 * The recorder used to ship to every bundle, production included, because a
 * pre-bundled runtime blob had no way to leave anything out. Now it ships when
 * something references it, and in a production build nothing does -- which
 * makes the documentation's promise that recording "never reaches a production
 * build" structurally true rather than nearly true.
 *
 * `avenx serve --trace` still has to work, and it works by calling
 * `window.Avenx.installTraceRecorder(...)` from an injected script. So a
 * development build imports the recorder here, which both keeps it in the graph
 * and puts it where the dev server looks for it. Development and production
 * differ in exactly this: whether the debugging tool is present.
 * @param {string} namespace - The namespace global's name.
 * @returns {string} The module source.
 */
export function devtoolsModule(namespace) {
  const names = [
    'installTraceRecorder',
    'uninstallTraceRecorder',
    'isRecording',
    'TRACE_ENDPOINT',
    'startRecording',
    'stopRecording',
    'activeRecorder',
  ];

  return [
    `import { ${names.join(', ')} } from 'avenx-core/runtime';`,
    '',
    `const __avx_devtools = { ${names.join(', ')} };`,
    '',
    'const __avx_root =',
    "  typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : null;",
    '',
    `if (__avx_root && __avx_root[${JSON.stringify(namespace)}]) {`,
    `  Object.assign(__avx_root[${JSON.stringify(namespace)}], __avx_devtools);`,
    '}',
    '',
  ].join('\n');
}

/**
 * Builds the module that installs the expression interpreter.
 *
 * The counterpart of the recorder above, and present for the same reason: a
 * development build should keep working while a template is being edited, and a
 * production build should not carry a JavaScript parser that can never run.
 * @returns {string} The module source.
 */
export function interpreterModule() {
  return [
    '// Installs the expression interpreter.',
    '//',
    '// A development build keeps working while a template is being edited: an',
    '// expression the generator could not compile -- reported as AVX_W48 -- is',
    '// interpreted instead of failing. A production build imports nothing here,',
    '// so the parser, the tree-walking evaluator and the old source-text sandbox',
    '// are unreachable and the bundler drops them. That is the whole of the',
    '// difference, and it is why a production bundle contains no `new Function`.',
    "import 'avenx-core/runtime/interpreter';",
    '',
  ].join('\n');
}

/**
 * A module that pulls the string renderer into the bundle.
 *
 * Added to the entry graph only when at least one component in the build could
 * not be compiled to a render program. An application whose every template
 * compiles never references the renderer, so the bundler drops it -- about
 * 87 KB of source that most applications no longer execute a line of.
 * @returns {string} The module source.
 */
export function stringRendererModule() {
  return [
    '// Installs the string renderer.',
    '//',
    '// This build contains at least one component whose template the compiler',
    '// could not lower -- reported as AVX_W47 with the construct that caused it',
    '// -- so the pre-IR rendering path has to be here to render it. A build',
    '// where every template compiles imports nothing here, and the patcher, the',
    '// list manager, the defer manager and the template renderer all leave the',
    '// graph.',
    "import 'avenx-core/runtime/string-renderer';",
    '',
  ].join('\n');
}

/**
 * A module that registers a built-in component.
 *
 * Added to the entry graph only when a template in the build references the
 * tag. `<VirtualList>` drags the template renderer and the DOM patcher with it,
 * which is roughly 60 KB an application that never writes the tag has no reason
 * to carry.
 * @param {string} specifier - The published entry point that registers it.
 * @param {string} tag - The tag name, for the comment.
 * @returns {string} The module source.
 */
export function builtinComponentModule(specifier, tag) {
  return [
    `// Registers <${tag}>, because a template in this build references it.`,
    '//',
    '// A build with no such template imports nothing here, and the component',
    '// and everything it drives leave the graph.',
    `import '${specifier}';`,
    '',
  ].join('\n');
}