Convert Node.js requires to dash-case

Recently I was called in to help get a product ready for production that has been through a few different developers and has hundreds of files with no rhyme or reason to its use of camelCase.js, TitleCase.js, and snake_case.js.

And while, it may seem like something that doesn’t matter that much - it does.

Consistency makes a project easier to navigate, to think around, and to organize for testing.

(think of it like broken window theory, but for your project - it’s a quick win and it cleans up the head space)

Just use dash-case

dash-case or kebab-case, as some call it, is the most pragmatic naming convention for files for three reasons:

  1. Many file systems are case sensitive - so always use lowercase!
  2. It’s easier to read separators than it is to read cases.
  3. The decades old Linux convention of dashes is easier to read than underscores in word processors and browsers, which sometimes use underlines for file or URL paths.

I’ve been around a number of times when a project is being switched from development on Mac or Windows to deployment and something subtle goes wrong related to naming conventions and the filesystem. Fortunately those are usually pretty easy to fix.

Although more rare, I’ve also seen the opposite happen - a “Project.js” and “project.js” in the same folder, causing problems. Or a “foobar.js” and “foo-bar.js” in the same folder, which doesn’t cause a technical problem, but is mentally confusing.

Converting imports and requires

Here’s a little script I cooked up for the occasion. I’m posting it here so that I have it the next time I need it - which will probably won’t be never.

Usage

The node script only handles one file at a time. It could be made more efficient by walking the filesystem from within node, but I found it easier to test and use by combining it with a little bash:

#!/bin/bash

set -e
set -u

# walk the project directory and call dashify.js on each file
# also output the `git mv` commands into their own script
find . -type f -exec node dashify.js {} \; | tee git-mv.sh

# this was generated by dashify.js to rename files as needed
bash git-mv.sh

# Use prettier to solve any line-length or whitespace issues with the auto-
# replacement, and make sure we didn't cause any syntax errors.
prettier --write './**/*.{js,css,html,json,md}'

To Dash Case

Here’s the function for converting file names from other most other casing systems to dash case.

You could make a shorter version of this with more complex RegExp, but I prefer the clarity of several that are easier to read.

// toDashCase for file names
function toDashCase(str) {
  return (
    str
      // catches CAPS-only
      // FOOBAR => foobar
      .replace(/^([A-Z]+)$/g, function (m) {
        return m.toLowerCase();
      })

      // catches CAPS acronyms
      // APIErrorFOOBarBazQux => api-error-foo-barBazQux
      .replace(/([A-Z0-9]+)([A-Z][a-z0-9])/g, function (_, a, b) {
        return a.toLowerCase() + '-' + b.toLowerCase();
      })

      // catches starts with a Cap
      // UserName => userName
      .replace(/^([A-Z]+)/g, function (m) {
        return m.toLowerCase();
      })

      // catches camelCase
      // api-error-foo-barBazQux => api-error-foo-bar-baz-qux
      .replace(/([A-Z]+)/g, function (m) {
        return '-' + m.toLowerCase();
      })

      // catches underscores
      // foo_bar => foo-bar
      .replace(/_/g, '-')

      // catches accidental leading dashes
      // /-foobar => /foobar
      .replace(/\/-/g, '/')

      // -foobar => /foobar
      .replace(/^-/g, '')
  );
}

Convert and Disambiguate

In this example I’m only handling require, but you could change it slightly to handle import as well.

Also, rather that doing a child_process.exec or spawn, this prints out the git mv commands to be run after all changes have been made.

(this only handles one file at a time)

'use strict';

var path = require('path');
var fsSync = require('fs');
var fs = require('fs').promises;

async function dashify(oldFilename) {
  if (!oldFilename) {
    console.error('#error: no filename given');
    return;
  }
  if (!['.cjs', '.mjs', '.js'].includes(path.extname(oldFilename))) {
    console.warn("#warn: skipping non-js file '%s'", oldFilename);
    return;
  }

  var filename = path.resolve(oldFilename);
  var oldTxt = await fs.readFile(filename, 'utf8');

  // We can use a simple regex rather than a full JS parser because all of the
  // project files are already prettified, so we know exactly what the requires
  // will look like: require('./path/to/foobar')
  var txt = oldTxt.replace(/(require\('\.)(.*)('\))/g, function (_, a, b, c) {
    // a = "require('."
    // b = /path/to/oldFile
    // c = "')"
    var oldRequire = a + b + c;

    // x = /path/to/old-file
    var x = toDashCase(b);
    var pathname = path.resolve(path.dirname(filename), '.' + b);
    x = disambiguate(pathname, x);

    var newRequire = a + x + c;
    if (newRequire != oldRequire) {
      // log the change as a comment
      console.log('#', result);
    }
    //console.log('#', result, '=>', path.relative(path.dirname(filename), '.' + x));
    return result;
  });
  // if we need to replace other strings that are filename-dependent, we can
  // do that too
  //.replace(/modelClass: '(\w+)'/g, function (_, name) {
  //  var x = toDashCase(name);
  //  var result = "modelClass: './" + x + ".js'";
  //  console.log('# ' + result);
  //  return result;
  //});

  // write the changes back to the OLD file
  await fs.writeFile(oldFilename, txt);

  var newFilename = toDashCase(oldFilename);
  if (newFilename != oldFilename) {
    // rather than moving the files in-process, or spawning out to 'git mv',
    // we just output the 'git mv' so that it can be written to a file
    console.info('mkdir -p', path.dirname(newFilename), ';');
    console.info('git mv', oldFilename, newFilename, ';');
    console.info('git add', newFilename, ';');

    // For other use cases we could move the files directly
    //await fs.mkdir(path.dirname(newFilename), { recursive: true });
    //await fs.unlink(oldFilename);
  } else if (txt != oldTxt) {
    // it may be that the file was updated, but does not need to be moved
    console.info('git add', oldFilename, ';');
  }
}

// change require('./path/to/foobar') into either
//   require('./path/to/foobar.js')
// or
//   require('./path/to/foobar/')
// Note that files with .js take precedence over directories
function disambiguate(pathname, x) {
  // ./path/to/foobar.js is not ambigious
  if ('.js' === path.extname(x)) {
    return x;
  }

  // ./path/to/foobar/ is not ambigious
  if ('/' === x[x.length - 1]) {
    return x;
  }

  // ./path/to/foobar may be refer to foobar.js or foobar/index.js
  // foobar.js takes precedence over foobar/
  var stat;
  try {
    stat = fsSync.statSync(pathname + '.js');
  } catch (e) {
    // ignore
  }

  if (stat) {
    // found 'foobar.js'
    x += '.js';
    return x;
  }

  // must be 'foobar/index.js'
  if (!/\/$/.test(x)) {
    x += '/';
  }
  return x;
}

module.exports = dashify;

if (require.main == module) {
  var oldFilename = process.argv[2];
  if (!oldFilename) {
    console.error('Usage: node ./dashify.js ./path/to/file.js');
    process.exit();
  }

  dashify(oldFilename).catch(function (err) {
    console.error(oldFilename, err);
  });
}