symfony / webpack-encore

A simple but powerful API for processing & compiling assets built around Webpack
https://symfony.com/doc/current/frontend.html
MIT License
2.23k stars 197 forks source link

How to automatically place third-party assets (fonts, etc.) into a structure similar to Composer's vendor directory? #1163

Closed Ambient-Impact closed 1 year ago

Ambient-Impact commented 1 year ago

Hi there. I'm fairly new to Webpack and Encore, and am in the process of porting an ancient build process over. I can't seem to find an easy out of the box way to automatically place third-party assets pulled in via Yarn into a similar logical structure like Composer does with its vendor directory, i.e. each package in its own sub-directory, optionally grouped under the vendor name.

My solution right now is with a custom filename callback like so:

/**
 * Webpack filename callback to place assets into a local vendor directory.
 *
 * @param {Object} pathData
 *
 * @return {String}
 *
 * @todo Can we detect if the referenced asset is coming from Yarn only put it
 *   into vendor in that case, falling back to leaving them at their default
 *   location otherwise?
 */
function vendorAssetFileName(pathData) {

  /**
   * Path parts as parsed by path.parse().
   *
   * @type {Object}
   */
  const pathParts = path.parse(pathData.module.rawRequest);

  // Note that pathData.contentHash doesn't always seem to contain a valid hash,
  // but the longer pathData.module.buildInfo.hash always seems to.
  return `vendor/${pathParts.dir}/${pathParts.name}.${
    pathData.module.buildInfo.hash
  }${pathParts.ext}`;

};

And then use it like so:

Encore..configureFontRule({
  type:     'asset/resource',
  filename: vendorAssetFileName,
});

This feels like a bit of a hack though. There must be a better way?

weaverryan commented 1 year ago

Hi @Ambient-Impact!

Hmm. So, for example, if you import some CSS file like import 'bootstrap/dist/css/bootstrap.css', you want that to be built into something like public/build/vendor/bootstrap.css? If so, I'm not sure that Webpack, in general, is the solution you want. Webpack basically says "Tell me all of the JS and CSS files that you need, and I will package them into a super efficient set of files". You do have some control over those files, but I don't think it's meant to be controllable to the point of saying which input files go into which specific output files.

Cheers!

Ambient-Impact commented 1 year ago

@weaverryan In this specific case the example I used was a specific font pulled in via Yarn, so I was thinking more like assets such as that. CSS isn't a problem as that's compiled from Sass, and Webpack does that well.

To clarify a bit where I'm coming from: I'm a Drupal developer, and want to be able to pull in dependencies via Yarn (e.g. ally.js, fastdom, and quite a few others), and make them web accessible, but define these as Drupal libraries so that Drupal can automatically attach them to pages/routes they're used in, and have it handle the bundling.

Would Encore.copyFiles() be more appropriate for this?

Ambient-Impact commented 1 year ago

Update: Encore.copyFiles() was the solution I went with.

https://github.com/Ambient-Impact/drupal-modules/blob/b31396dccd6945afcda019d9bd7f0aec7f515089/webpack.vendor.js

/**
 * @file
 * Copies Yarn dependendencies to a publicly accessible vendor directory.
 *
 * Note that this specifically only handles "dependencies" and not
 * "devDependencies", as the former is used for run-time dependencies while the
 * latter are for building the front-end assets but not intended to be publicly
 * accessible themselves.
 *
 * @todo Refactor this script so it can dynamically fetch "dependencies" from
 *   whatever the current package is calling it, so that it can be reused.
 *
 * @todo Have this script also update *.libraries.yml versions when pulling in
 *   updated packages so that version strings change, forcing clients to
 *   download the correct versions?
 *
 * @see https://git.drupalcode.org/project/drupal/-/blob/9.5.x/core/scripts/js/vendor-update.js
 *   Drupal core script as a loose inspiration for this. Copies a list of
 *   Node/Yarn package files to core/assets/vendor and updates their versions in
 *   core.libraries.yml.
 */

'use strict';

const Encore = require('@symfony/webpack-encore');
const path = require('path');

/**
 * The vendor directory, relative to the current script location.
 *
 * @type {String}
 *
 * @todo Make this relative to the calling package and not the script.
 *
 * @todo Make this configurable?
 */
const vendorDir = './vendor';

/**
 * Array of package names to be copied to the public vendor directory.
 *
 * Note that all of these must be declared as direct dependencies of this
 * package or Yarn will throw an error when trying to access them. This means
 * any dependencies of these dependencies need to be listed in the package.json.
 *
 * @type {String[]}
 */
const packageNames = [
  'ally.js', 'autosize', 'chillout', 'fastdom', 'fontfaceobserver',
  'fr-offcanvas', 'headroom.js', 'jquery-hoverintent', 'jquery.onfontresize',
  'js-cookie', 'photoswipe', 'popper.js', 'smoothscroll-polyfill', 'strictdom',
  'tippy.js',
];

/**
 * Configuration array for Encore.copyFiles().
 *
 * This contains objects with 'from' and 'to' keys defining the Yarn virtual
 * filesystem paths and their corresponding output paths under the vendor
 * directory to be copied to so that they're publicly accessible.
 *
 * @type {Object[]}
 *
 * @see https://github.com/symfony/webpack-encore/blob/main/index.js
 *   Documents the API.
 */
let copyConfig = [];

for (let i = 0; i < packageNames.length; i++) {

  try {

    // The resolved virtual filesystem path to the root directory of this
    // package. Note that Node will throw an error if we can't access the
    // package.json due to the package only using the more modern "exports"
    // field without a "./package.json" key.
    //
    // @see https://nodejs.org/api/packages.html#exports
    //
    // @todo Use the Yarn PnP API to access this, removing the chance Node will
    //   throw this error?
    let packagePath = path.dirname(require.resolve(
      `${packageNames[i]}/package.json`
    ));

    copyConfig.push({
      from: packagePath,
      to:   `${packageNames[i]}/[path][name].[ext]`
    });

  } catch (error) {
    console.error(
      `Could not access ${packageNames[i]}/package.json; this can occur if the package defines an "exports" field that doesn't contain a "./package.json" key. See: https://nodejs.org/api/packages.html#exports
      Error was:`, error);
  }

}

// @see https://symfony.com/doc/current/frontend/encore/installation.html#creating-the-webpack-config-js-file
if (!Encore.isRuntimeEnvironmentConfigured()) {
  Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
.setOutputPath(path.resolve(__dirname, vendorDir))

// Encore will complain if the public path doesn't start with a slash.
.setPublicPath('/')
.setManifestKeyPrefix('')

// We need to set either this or Encore.enableSingleRuntimeChunk() otherwise
// Encore will refuse to run.
.disableSingleRuntimeChunk()

.copyFiles(copyConfig)

module.exports = Encore.getWebpackConfig();