• scripts/publish/config.ts:
export default {
  /** A list of glob patterns to define the files to include in the package */
  includeFiles: [
    // Theme lib
    'lib/theme/**/*.(js|jsx|ts|tsx)',
    'lib/styled*',
    'lib/date-utils*',
    // Avatar
    'components/Avatar*',
    // Typography
    'components/Text..*',
    // Toasts
    'components/Toast*',
    // Layout
    'components/Grid..*',
    'components/Container',
    // Misc helpers
    'components/Currency.*',
    // Filters
    'components/filters/*',
    // Loading
    'components/Loading*',
    // Styled components
    'components/StyledAmountPicker.*',
    'components/StyledButtonSet.*',
    'components/StyledButton.*',
    'components/StyledCard.*',
    'components/StyledCarousel.*',
    'components/StyledCheckbox.*',
    // 'components/StyledCollectiveCard.*', // Depends on Link
    'components/StyledDropdown.*',
    // 'components/StyledDropzone.*', // Not included as it contains API calls. Should be separated in two components, a "dumb" one and a "plugged" one
    'components/StyledFilters.*',
    'components/StyledHr.*',
    'components/StyledInputAmount.*',
    'components/StyledInputField.*',
    // 'components/StyledInputFormikField.*', // To enable, add formik to peerDependencies
    'components/StyledInputGroup.*',
    'components/StyledInputLocation.*',
    'components/StyledInputMask.*',
    'components/StyledInputPercentage.*',
    'components/StyledInputSlider.*',
    'components/EditTags.*',
    'components/StyledInput.*',
    'components/StyledKeyframes.*',
    'components/StyledLinkButton.*',
    'components/StyledLink.*',
    // 'components/StyledMembershipCard.*', // Contains a reference to StyledCollectiveCard, and thus to Link
    // 'components/StyledModal.*', // Contains a reference to Router for `warnIfUnsavedChanges`
    'components/StyledMultiEmailInput.*',
    'components/StyledProgressBar.*',
    'components/StyledRadioList.*',
    'components/StyledRoundButton.*',
    'components/StyledSelectCreatable.*',
    'components/StyledSelectFilter.*',
    'components/StyledSelect.*',
    'components/StyledSpinner.*',
    'components/StyledTag.*',
    'components/StyledTextarea.*',
    'components/StyledTooltip.*',
  ],
  /** Will be marked as peerDependencies. Remember to update scripts/publish-components/static/README.md. */
  peerDependencies: ['react', 'react-dom', 'styled-components'],
};
  • scripts/publish/helpers.ts:
import readline from 'readline';
 
import { pickBy } from 'lodash';
 
/**
 * A wrapper around `pickBy` that allows to pass a list of either:
 * - strings: the keys to pick
 * - regexes: the keys to pick if they match the regex
 */
export const pickByPatterns = (object, patterns) => {
  return pickBy(object, (_, name) => {
    return patterns.some(pattern => {
      if (typeof pattern === 'string') {
        return name === pattern;
      } else {
        return pattern.test(name);
      }
    });
  });
};
 
export const confirm = question => {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
 
  return new Promise(resolve => {
    rl.question(`${question}\n> `, input => {
      if (['y', 'yes', 'sure', 'ok'].includes(input.toLowerCase())) {
        resolve(true);
      } else {
        rl.close();
        resolve(false);
      }
    });
  });
};
  • scripts/publish/index.ts:
/* eslint-disable no-process-exit */
/* eslint-disable no-console */
 
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
 
import RollupPluginBabel from '@rollup/plugin-babel';
import RollupPluginCommonJS from '@rollup/plugin-commonjs';
import RollupPluginImages from '@rollup/plugin-image';
import RollupPluginJSON from '@rollup/plugin-json';
import RollupPluginTypescript from '@rollup/plugin-typescript';
import { Command } from 'commander';
import fsExtra from 'fs-extra';
import { glob } from 'glob';
import { flatten, omit, pick } from 'lodash';
import { rollup } from 'rollup';
 
import config from './config';
import { confirm, pickByPatterns } from './helpers';
 
// Define program options
const program = new Command()
  .description('Helper publish Frontend components to the NPM registry')
  .argument('[version]', 'Version number to publish')
  .option('--build-only', 'If set, the package will be build but not published')
  .parse();
 
// Load some content
const options = program.opts();
const tmpDir = path.join(__dirname, '.tmp');
const staticFilesDir = path.join(__dirname, 'static');
const projectRoot = path.join(__dirname, '../..');
const projectNodeModules = path.join(projectRoot, 'node_modules');
const basePackageJSON = require(path.join(projectRoot, 'package.json'));
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
 
const main = async () => {
  // Prepare directory structure
  fs.rmSync(tmpDir, { recursive: true, force: true });
  fs.mkdirSync(tmpDir);
 
  // ==== 2. Copy all files to publish ====
  console.log('Copying files to tmp folder...');
 
  // Static files
  glob.sync(`${staticFilesDir}/*`, { dot: true }).forEach(sourcePath => {
    const targetPath = sourcePath.replace(staticFilesDir, tmpDir);
    fsExtra.copySync(sourcePath, targetPath);
  });
 
  // ==== 3. Build ====
  console.log('Building...');
  const filesToInclude = flatten(
    config.includeFiles.map(pattern => glob.sync(pattern, { cwd: projectRoot, absolute: true })),
  );
 
  // Filter external dependencies, and build a map of external dependencies
  const peerDependencies = pickByPatterns(basePackageJSON.dependencies, config.peerDependencies);
  const usedDependencies = new Set<string>();
  const filterExternal = (dependency, parent) => {
    if (dependency.startsWith(projectNodeModules) || parent?.startsWith(projectNodeModules)) {
      const dependencyRelativeParts = dependency.replace(projectNodeModules, '').split(path.sep);
      const isOrgPackage = dependencyRelativeParts[1][0] === '@';
      const dependencyName = isOrgPackage ? dependencyRelativeParts.slice(1, 3).join('/') : dependencyRelativeParts[1];
      usedDependencies.add(dependencyName);
      return true;
    } else if (dependency[0] !== '/' && dependency[0] !== '.') {
      const isOrgPackage = dependency[0] === '@';
      const dependencyRelativeParts = dependency.split(path.sep);
      const dependencyName = isOrgPackage ? dependencyRelativeParts.slice(0, 2).join('/') : dependencyRelativeParts[0];
      usedDependencies.add(dependencyName);
      return true;
    } else {
      return false;
    }
  };
 
  const bundle = await rollup({
    watch: { chokidar: { cwd: projectRoot } },
    input: filesToInclude,
    external: filterExternal,
    plugins: [
      // RollupPluginResolve({ extensions }),
      RollupPluginCommonJS({ include: /node_modules/ }),
      RollupPluginJSON(),
      RollupPluginImages(), // Not ideal as images will be base-64 encoded and thus bigger than optimized PNG
      RollupPluginTypescript({
        include: filesToInclude,
        noForceEmit: true,
        compilerOptions: {
          declaration: true,
          noEmit: false,
          emitDeclarationOnly: true,
          declarationDir: tmpDir,
          outDir: tmpDir,
          sourceMap: false,
          allowJs: false,
          jsx: 'react',
        },
      }),
      RollupPluginBabel({
        extensions,
        babelHelpers: 'runtime',
        exclude: 'node_modules/**',
      }),
    ],
  });
 
  // Write files to disk
  const bundleOutputOptions = { dir: tmpDir, preserveModules: true };
  await bundle.write({ ...bundleOutputOptions, format: 'esm' });
 
  await bundle.close();
 
  // ==== 1. Generate `package.json` ====
  const version = program.args[0];
  console.log('Generating `package.json`...');
  const packageJSON = {
    name: '@opencollective/frontend-components',
    version: version || 'Unpublished',
    private: false,
    repository: basePackageJSON.repository,
    engines: basePackageJSON.engines,
    type: 'module',
    dependencies: omit(
      pick(basePackageJSON.dependencies, Array.from(usedDependencies).sort()),
      Object.keys(peerDependencies),
    ),
    peerDependencies,
    devDependencies: peerDependencies,
  };
 
  // Output package.json
  const outputFile = path.join(tmpDir, 'package.json');
  fs.writeFileSync(outputFile, JSON.stringify(packageJSON, null, 2));
 
  // ==== 4. Publish ====
  if (!options['buildOnly']) {
    if (!version) {
      throw new Error('You must specify a version number to publish the components');
    }
 
    // Dry run
    execSync(`npm publish ${tmpDir} --access public --dry-run`, { stdio: 'inherit' });
 
    // Actually publish
    if (await confirm('You are about to publish the components listed above. Are you sure you want to continue?')) {
      execSync(`npm publish ${tmpDir} --access public`, { stdio: 'inherit' });
    }
  }
};
 
main()
  .then(() => {
    if (!options['buildOnly']) {
      fs.rmSync(tmpDir, { recursive: true, force: true });
    }
    process.exit(0);
  })
  .catch(e => {
    console.error(e);
    fs.rmSync(tmpDir, { recursive: true, force: true });
    process.exit(1);
  });