Source: lib/bundler/resolve.js

/**
 * @file resolve.js
 * @description Turns an import specifier into a module the bundler can read.
 *
 * ## What this replaces
 *
 * There was no module resolution. `rewriteRuntimeImports` matched the runtime
 * entry and deleted everything else, so `import { format } from 'date-fns'` in
 * a component became nothing at all — a green build and a `ReferenceError` the
 * first time the action ran. Resolution is the difference between a build that
 * knows what an application depends on and one that guesses.
 *
 * ## The rules, in order
 *
 * 1. **Virtual modules.** The compiler owns component, page and entry
 *    generation, and hands those in as sources rather than files. They resolve
 *    by id and win over anything on disk.
 * 2. **Relative and absolute paths.** Probed for an exact file, then for the
 *    Avenx and JavaScript extensions, then for a directory index. A specifier
 *    that names a `.component.js` or `.page.js` resolves to the *compiled*
 *    virtual module, never to the raw template file — the raw file is not
 *    JavaScript and would not parse.
 * 3. **Node builtins.** Rejected, by name, with the reason. A browser bundle
 *    that quietly contains `fs` is a bundle that fails at load; saying so at
 *    build time is the whole point.
 * 4. **Bare specifiers.** The Node algorithm, walking `node_modules` upward
 *    from the importing file, honouring `exports`, `browser`, `module` and
 *    `main` in that order of preference for a browser target.
 *
 * Nothing here falls back to "skip it". A specifier that cannot be resolved is
 * a {@link ResolveError}, and the build fails with the importer and the
 * specifier — because the alternative is what this file exists to end.
 * @module lib/bundler/resolve
 */

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
 * The root of the installed `avenx-core` package, derived from this file.
 * @type {string}
 */
export const AVENX_PACKAGE_ROOT = path.resolve(__dirname, '..', '..');

/**
 * Specifiers that mean "the Avenx browser runtime".
 *
 * The compiler has always accepted all three spellings, and a deep path into
 * `lib/core` besides, so resolution accepts exactly what the old rewriter did.
 * @type {RegExp}
 */
const RUNTIME_SPECIFIER = /^(avenx-core(\/(runtime|core))?)$/;

/**
 * Node builtin modules, with and without the `node:` prefix.
 *
 * Listed rather than probed, because the answer must not depend on which Node
 * version is running the build: an application importing `fs` is broken in a
 * browser on every version, and the diagnostic should say so identically.
 * @type {Set<string>}
 */
const NODE_BUILTINS = new Set([
  'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants',
  'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'http',
  'http2', 'https', 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks', 'process',
  'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder', 'sys',
  'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib',
]);

/**
 * Extensions probed for a path specifier that names no file directly.
 * @type {string[]}
 */
const EXTENSIONS = ['.js', '.mjs', '.component.js', '.page.js', '.bridge.js', '.guard.js', '.json'];

/**
 * Extensions an Avenx bundle has no way to represent as a module.
 *
 * The old pipeline deleted these imports silently, so `import './theme.css'`
 * in `main.app.js` looked like it did something and did nothing at all. Saying
 * so is strictly better than either silence or a resolution error that reads
 * as a missing file.
 * @type {Set<string>}
 */
const ASSET_EXTENSIONS = new Set([
  '.css', '.scss', '.sass', '.less', '.styl',
  '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.ico',
  '.woff', '.woff2', '.ttf', '.otf', '.eot',
  '.mp3', '.mp4', '.webm', '.wav',
]);

/**
 * Raised when a specifier cannot be resolved to a module.
 */
export class ResolveError extends Error {
  /**
   * @param {string} specifier - The unresolvable specifier.
   * @param {string} importer - The module that asked for it.
   * @param {string} reason - Why resolution failed.
   */
  constructor(specifier, importer, reason) {
    super(reason);
    this.name = 'ResolveError';
    /** @type {string} */
    this.specifier = specifier;
    /** @type {string} */
    this.importer = importer;
    /** @type {string} */
    this.reason = reason;
  }
}

/**
 * Whether a specifier names the Avenx runtime entry.
 * @param {string} specifier - The import specifier.
 * @returns {boolean} True for `avenx-core`, `avenx-core/runtime` or `avenx-core/core`.
 */
export function isRuntimeSpecifier(specifier) {
  return RUNTIME_SPECIFIER.test(specifier);
}

/**
 * Reads and caches a `package.json`.
 * @param {string} file - Absolute path to the manifest.
 * @param {Map<string, object|null>} cache - Shared manifest cache.
 * @returns {object|null} The parsed manifest, or null when absent or invalid.
 */
function readManifest(file, cache) {
  if (cache.has(file)) {
    return cache.get(file);
  }
  let manifest;
  try {
    manifest = JSON.parse(fs.readFileSync(file, 'utf-8'));
  } catch {
    manifest = null;
  }
  cache.set(file, manifest);
  return manifest;
}

/**
 * Picks a target out of an `exports` value for a browser build.
 *
 * Conditions are tried in the order a browser bundler should prefer them:
 * `browser` before `import` before `module` before `default`. `require` is
 * accepted last, because a package that offers only CommonJS is still better
 * bundled than reported missing — {@link module:lib/bundler/interop} decides
 * what to do with the format once the file is read.
 * @param {any} value - An `exports` entry: string, conditions object, or array.
 * @returns {string|null} A relative target, or null when nothing applies.
 */
function selectCondition(value) {
  if (typeof value === 'string') {
    return value;
  }
  if (Array.isArray(value)) {
    for (const entry of value) {
      const picked = selectCondition(entry);
      if (picked) return picked;
    }
    return null;
  }
  if (!value || typeof value !== 'object') {
    return null;
  }
  for (const condition of ['browser', 'import', 'module', 'default', 'require']) {
    if (condition in value) {
      const picked = selectCondition(value[condition]);
      if (picked) return picked;
    }
  }
  return null;
}

/**
 * Resolves a subpath against a manifest's `exports` field.
 * @param {object} manifest - The parsed package manifest.
 * @param {string} subpath - `.` for the package root, otherwise `./name`.
 * @returns {string|null} A relative target, or null when `exports` does not cover it.
 */
function resolveExports(manifest, subpath) {
  const table = manifest.exports;
  if (table === undefined || table === null) {
    return null;
  }

  // A bare string or a conditions object with no subpath keys means the whole
  // package resolves to one target, and only `.` is exported.
  const hasSubpathKeys =
    typeof table === 'object' && !Array.isArray(table) && Object.keys(table).some((key) => key.startsWith('.'));

  if (!hasSubpathKeys) {
    return subpath === '.' ? selectCondition(table) : null;
  }

  if (table[subpath] !== undefined) {
    return selectCondition(table[subpath]);
  }

  // Pattern entries: `"./*": "./dist/*.js"`.
  for (const [pattern, value] of Object.entries(table)) {
    const star = pattern.indexOf('*');
    if (star === -1) continue;
    const prefix = pattern.slice(0, star);
    const suffix = pattern.slice(star + 1);
    if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
    const middle = subpath.slice(prefix.length, subpath.length - suffix.length || undefined);
    const target = selectCondition(value);
    if (target) return target.replace('*', middle);
  }

  return null;
}

/**
 * Applies a package's `browser` field remapping to a resolved file.
 *
 * The string form replaces the entry point. The object form maps individual
 * paths, and a `false` value means "this module is empty in a browser", which
 * is how packages ship Node-only branches — honouring it is what keeps a
 * `fs` shim from reaching the bundle.
 * @param {object} manifest - The parsed manifest.
 * @param {string} packageDir - The package root.
 * @param {string} file - The file resolution produced.
 * @returns {string|false} The remapped file, or false when it is stubbed out.
 */
function applyBrowserField(manifest, packageDir, file) {
  const field = manifest.browser;
  if (!field || typeof field !== 'object') {
    return file;
  }
  for (const [from, to] of Object.entries(field)) {
    if (!from.startsWith('.')) continue;
    if (path.resolve(packageDir, from) === file) {
      return to === false ? false : path.resolve(packageDir, to);
    }
  }
  return file;
}

/**
 * Real paths, keyed by the path that was walked to reach them.
 *
 * `fs.realpathSync` is a syscall per segment, and a large graph probes the same
 * package directory thousands of times. The cache is module-scoped rather than
 * per-Resolver because the answer is a property of the filesystem, not of a
 * build.
 * @type {Map<string, string>}
 */
const realPaths = new Map();

/**
 * Canonicalises a path so that one file has one identity.
 *
 * A module's identity in the graph is the path that resolved to it. Without
 * this, a file reachable by two paths becomes two modules -- and that is not a
 * corner case: `npm link`, a `file:` dependency, a pnpm store and every
 * workspace layout put a symlink between an application and its dependencies.
 *
 * Duplicated source is the cheap half of the damage. The expensive half is
 * module state. `lib/core/renderer/stringRenderer.js` holds the registry the
 * fallback renderer installs itself into; emitted twice, the install fills one
 * copy and `AvenxComponent` reads the other, so a component whose template the
 * IR refused throws at its first render.
 *
 * Node's own ESM loader canonicalises this way unless `--preserve-symlinks` is
 * passed, and Rollup, webpack and esbuild all do the same. Avenx now agrees
 * with them.
 *
 * Falls back to the path as given when it cannot be resolved: a path that does
 * not exist is not this function's error to raise, and the caller is already
 * about to say so with a specifier and an importer.
 * @param {string} target - An absolute path.
 * @returns {string} The canonical path, or `target` when it cannot be read.
 */
export function realPath(target) {
  const cached = realPaths.get(target);
  if (cached !== undefined) {
    return cached;
  }
  let resolved;
  try {
    resolved = fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target);
  } catch {
    resolved = target;
  }
  realPaths.set(target, resolved);
  return resolved;
}

/**
 * Probes a path for a real file, trying Avenx and JavaScript extensions and a
 * directory index.
 *
 * Every return goes through {@link realPath}, so the id the graph keys a module
 * by is the file's own path rather than whichever route reached it.
 * @param {string} candidate - An absolute path with or without an extension.
 * @returns {string|null} The file that exists, or null.
 */
export function probeFile(candidate) {
  const stat = (target) => {
    try {
      return fs.statSync(target);
    } catch {
      return null;
    }
  };

  const direct = stat(candidate);
  if (direct && direct.isFile()) {
    return realPath(candidate);
  }

  if (!direct) {
    for (const extension of EXTENSIONS) {
      const withExtension = `${candidate}${extension}`;
      const found = stat(withExtension);
      if (found && found.isFile()) {
        return realPath(withExtension);
      }
    }
    return null;
  }

  if (direct.isDirectory()) {
    for (const extension of ['.js', '.mjs']) {
      const index = path.join(candidate, `index${extension}`);
      const found = stat(index);
      if (found && found.isFile()) {
        return realPath(index);
      }
    }
  }

  return null;
}

/**
 * Resolves module specifiers for one build.
 *
 * Holds the virtual-module table the compiler populated and a manifest cache,
 * so repeated resolution across a large graph reads each `package.json` once.
 */
export class Resolver {
  /**
   * @param {object} options - Resolver options.
   * @param {Map<string, string>} [options.virtualModules] - Module id to source.
   * @param {string} [options.runtimeEntry] - Absolute path of the runtime barrel.
   * @param {string[]} [options.roots] - Extra directories to search for packages.
   */
  constructor({ virtualModules = new Map(), runtimeEntry = null, roots = [] } = {}) {
    /** @type {Map<string, string>} */
    this.virtualModules = virtualModules;
    // Canonicalised for the same reason every probed path is: the runtime entry
    // is returned directly for `avenx-core` and `avenx-core/runtime` without
    // going through probeFile, and it has to name the same module that a
    // relative import of lib/core/index.js names.
    /** @type {string} */
    this.runtimeEntry = realPath(runtimeEntry || path.join(AVENX_PACKAGE_ROOT, 'lib', 'core', 'index.js'));
    /** @type {string[]} */
    this.roots = roots;
    /** @type {Map<string, object|null>} */
    this.manifests = new Map();
    /** @type {Map<string, string>} */
    this.cache = new Map();
  }

  /**
   * Whether an id names a module the compiler generated rather than a file.
   * @param {string} id - The module id.
   * @returns {boolean} True for a virtual module.
   */
  isVirtual(id) {
    return this.virtualModules.has(id);
  }

  /**
   * Resolves a specifier to a module id.
   * @param {string} specifier - The import specifier as written.
   * @param {string} importer - Absolute path or virtual id of the importing module.
   * @returns {string} The resolved module id.
   * @throws {ResolveError} When the specifier names nothing resolvable.
   */
  resolve(specifier, importer) {
    const key = `${importer}\u0000${specifier}`;
    const cached = this.cache.get(key);
    if (cached) {
      return cached;
    }
    const resolved = this.resolveUncached(specifier, importer);
    this.cache.set(key, resolved);
    return resolved;
  }

  /**
   * Resolution without the memo.
   * @param {string} specifier - The import specifier.
   * @param {string} importer - The importing module.
   * @returns {string} The resolved module id.
   * @throws {ResolveError} When the specifier names nothing resolvable.
   * @private
   */
  resolveUncached(specifier, importer) {
    if (this.virtualModules.has(specifier)) {
      return specifier;
    }

    if (isRuntimeSpecifier(specifier)) {
      return this.runtimeEntry;
    }

    const bareBuiltin = specifier.startsWith('node:') ? specifier.slice(5) : specifier;
    if (NODE_BUILTINS.has(bareBuiltin)) {
      throw new ResolveError(
        specifier,
        importer,
        `"${specifier}" is a Node.js builtin and has no browser implementation. ` +
          'An Avenx application bundle runs in a browser, so it cannot contain it.',
      );
    }

    const extension = path.extname(specifier).toLowerCase();
    if (ASSET_EXTENSIONS.has(extension)) {
      throw new ResolveError(
        specifier,
        importer,
        `Avenx does not bundle ${extension} files, so this import cannot be honoured.\n` +
          (extension === '.css' || extension === '.scss' || extension === '.sass' || extension === '.less'
            ? 'Component styles belong in a matching .component.css or .page.css file, and application-wide ' +
              'styles in a <@global> block. A stylesheet that is genuinely external belongs in a <link> tag ' +
              'in index.html.'
            : 'Reference the asset by URL from your template or stylesheet instead.'),
      );
    }

    if (specifier.startsWith('.') || path.isAbsolute(specifier)) {
      return this.resolvePath(specifier, importer);
    }

    return this.resolveBare(specifier, importer);
  }

  /**
   * Resolves a relative or absolute specifier.
   * @param {string} specifier - The specifier.
   * @param {string} importer - The importing module.
   * @returns {string} The resolved module id.
   * @throws {ResolveError} When nothing exists at that path.
   * @private
   */
  resolvePath(specifier, importer) {
    const base = this.directoryOf(importer);
    const candidate = path.resolve(base, specifier);

    // A generated module wins over the file it was generated from. A
    // `.component.js` on disk is Avenx template source, not JavaScript, and
    // handing it to the module reader would fail on markup.
    for (const suffix of ['', '.component.js', '.page.js', '.js']) {
      const virtualId = `${candidate}${suffix}`;
      if (this.virtualModules.has(virtualId)) {
        return virtualId;
      }
    }

    const file = probeFile(candidate);
    if (!file) {
      throw new ResolveError(
        specifier,
        importer,
        `no file exists at ${candidate} (tried it directly, with ${EXTENSIONS.join(', ')}, and as a directory index)`,
      );
    }
    return this.applyOwningBrowserField(file, specifier);
  }

  /**
   * Applies the `browser` field of the package a resolved file belongs to.
   *
   * A package stubs its Node-only modules by mapping them to `false`, and those
   * modules are usually reached by a *relative* import from inside the package
   * rather than by a bare specifier. Honouring the field only at the package
   * entry would therefore miss exactly the case it exists for.
   *
   * Restricted to files under `node_modules`: this is how third-party packages
   * describe themselves, and silently emptying a module in an application's own
   * source would be a surprise rather than a service.
   * @param {string} file - The resolved file.
   * @param {string} specifier - The specifier that produced it, for the stub id.
   * @returns {string} The file, or the id of an empty module standing in for it.
   * @private
   */
  applyOwningBrowserField(file, specifier) {
    const marker = `${path.sep}node_modules${path.sep}`;
    if (!file.includes(marker)) {
      return file;
    }

    let dir = path.dirname(file);
    for (;;) {
      const manifestPath = path.join(dir, 'package.json');
      if (fs.existsSync(manifestPath)) {
        const manifest = readManifest(manifestPath, this.manifests) || {};
        const mapped = applyBrowserField(manifest, dir, file);
        return mapped === false ? this.emptyModuleId(specifier) : mapped;
      }
      const parent = path.dirname(dir);
      if (parent === dir || !dir.includes(marker)) {
        return file;
      }
      dir = parent;
    }
  }

  /**
   * Resolves a bare package specifier through `node_modules`.
   * @param {string} specifier - The specifier, e.g. `lodash` or `lodash/fp`.
   * @param {string} importer - The importing module.
   * @returns {string} The resolved module id.
   * @throws {ResolveError} When the package or its subpath cannot be found.
   * @private
   */
  resolveBare(specifier, importer) {
    const scoped = specifier.startsWith('@');
    const parts = specifier.split('/');
    const name = scoped ? parts.slice(0, 2).join('/') : parts[0];
    const subpath = specifier.slice(name.length).replace(/^\//, '');

    const searched = [];
    for (const dir of this.packageDirectories(importer, name)) {
      searched.push(dir);
      const manifestPath = path.join(dir, 'package.json');
      const manifest = readManifest(manifestPath, this.manifests) || {};

      const resolved = this.resolveInPackage(dir, manifest, subpath);
      if (resolved === false) {
        // `"browser": { "./node-only.js": false }` — the package itself says
        // this module is empty in a browser. Honour that rather than bundling
        // a Node implementation.
        return this.emptyModuleId(specifier);
      }
      if (resolved) {
        return resolved;
      }
    }

    if (specifier === 'avenx-core' || specifier.startsWith('avenx-core/')) {
      // Building inside the Avenx repository itself, or against a checkout
      // rather than an install. The package that owns this file is the one the
      // application means.
      const local = this.resolveInPackage(
        AVENX_PACKAGE_ROOT,
        readManifest(path.join(AVENX_PACKAGE_ROOT, 'package.json'), this.manifests) || {},
        specifier.slice('avenx-core'.length).replace(/^\//, ''),
      );
      if (local) {
        return local;
      }
    }

    throw new ResolveError(
      specifier,
      importer,
      searched.length > 0
        ? `no package named "${name}" was found. Looked in:\n  ${searched.join('\n  ')}`
        : `no package named "${name}" was found in any node_modules directory above ${this.directoryOf(importer)}`,
    );
  }

  /**
   * Resolves a subpath inside a package directory.
   * @param {string} dir - The package root.
   * @param {object} manifest - Its parsed manifest.
   * @param {string} subpath - The subpath, `''` for the package root.
   * @returns {string|false|null} A file, `false` when browser-stubbed, or null.
   * @private
   */
  resolveInPackage(dir, manifest, subpath) {
    const exportsTarget = resolveExports(manifest, subpath === '' ? '.' : `./${subpath}`);

    let candidate;
    if (exportsTarget) {
      candidate = path.resolve(dir, exportsTarget);
    } else if (subpath !== '') {
      candidate = path.resolve(dir, subpath);
    } else {
      const browserMain = typeof manifest.browser === 'string' ? manifest.browser : null;
      const main = browserMain || manifest.module || manifest.main || 'index.js';
      candidate = path.resolve(dir, main);
    }

    const file = probeFile(candidate);
    if (!file) {
      return null;
    }
    return applyBrowserField(manifest, dir, file);
  }

  /**
   * Yields every `node_modules/<name>` directory that exists above an importer.
   * @param {string} importer - The importing module.
   * @param {string} name - The package name.
   * @returns {string[]} Existing package directories, nearest first.
   * @private
   */
  packageDirectories(importer, name) {
    const found = [];
    const bases = [this.directoryOf(importer), ...this.roots];

    for (const base of bases) {
      let dir = base;
      for (;;) {
        const candidate = path.join(dir, 'node_modules', name);
        if (fs.existsSync(path.join(candidate, 'package.json')) && !found.includes(candidate)) {
          found.push(candidate);
        }
        const parent = path.dirname(dir);
        if (parent === dir) break;
        dir = parent;
      }
    }

    return found;
  }

  /**
   * The directory a module's relative imports resolve against.
   *
   * A virtual module is generated *for* a source file and keeps that file's id,
   * so its relative imports resolve exactly as the developer wrote them.
   * @param {string} importer - Module id.
   * @returns {string} An absolute directory.
   * @private
   */
  directoryOf(importer) {
    return path.dirname(importer);
  }

  /**
   * Registers and returns an empty module standing in for a browser stub.
   *
   * The id begins with a NUL so it can never collide with a real path, which is
   * the same convention other bundlers use for a module that exists only in
   * memory.
   * @param {string} specifier - The specifier being stubbed.
   * @returns {string} The virtual module id.
   * @private
   */
  emptyModuleId(specifier) {
    const id = `\u0000avenx:empty:${specifier}`;
    if (!this.virtualModules.has(id)) {
      this.virtualModules.set(id, `// "${specifier}" is browser-stubbed by its own package.\nexport default {};\n`);
    }
    return id;
  }
}