Implementing TitleCapitalization in StackEdit

Bruno Skvorc
Share

While working on posts for the PHP Channel here at SitePoint, I often forget how to properly capitalize titles. I usually end up going to TitleCapitalization for a quick fix, but I often fantasize about having a button next to StackEdit’s title field for a quick auto-apply. Well, we’ve already covered getting a local instance (or several) of StackEdit up and running. Why not build the button, too?

03

Getting Ready

To prepare for the upgrade, we need to check out a local copy of StackEdit. I will, of course, be using my trusty old Homestead Improved box, just like here. You can use your own Linux OS, it’s up to you, but this is definitely simplest.

git clone https://github.com/swader/homestead-improved hi_stackedit
cd hi_stackedit
vagrant up
vagrant ssh

Once inside the VM, we clone StackEdit.

cd ~/Code
git clone https://github.com/benweet/stackedit
cd stackedit
npm install --no-bin-link

Note that if you get “Failed to resolve” errors while running this command, and a bunch of other errors in a typically node “verbose” fashion, it just means there’s some dependencies that have gone missing. Go into package.json and remove the hashtag value from line 23 and line 28 if they’re still there. These tags reference a version that no longer exists, and the author of StackEdit still hasn’t updated StackEdit’s files to reflect this at the time of this writing.

bower install

This will take a while. If you have BowerPHP installed, you can use that instead.

To run the local copy, execute the following:

(export PORT=5000 && node server.js)

Then, visit homestead.app:5000 in your browser (or whichever host you set up, if not the default homestead.app).

Implementation

Alright, let’s get down to it. The implementation will consist of two parts – the UI, and the logic.

UI

Let’s add the button.

StackEdit is somewhat convoluted to extend, UI-wise. The project itself, when installed, contains over 30000 files with downloaded dependencies and the lot. This is ridiculous for a web app, and very difficult to index for any IDE, especially seeing as JavaScript is a bit messy. There are several steps in adding a button to the interface. The look we’re going for is this:

02

A “checkmark” icon next to the document title, in the form of a Glyphicon which matches the rest of the UI depending on the theme in use. I used the checkmark because the Glyphicons are already included with Bootstrap in StackEdit. It might not be contextually perfect, but it’s the quickest way to get what we want without editing too many files (and we’ll be editing a lot of them by default, adding to this overhead is pointless).

The view we need to edit is public/res/html/bodyEditor.html – we’ll be adding a new icon container around line 44:

<li><div class="working-indicator"></div></li>
<li><div class="capitalize-button"></div></li>
<li><a class="btn btn-success file-title-navbar" href="#" title="Rename document"> </a></li>

We added a “capitalize-button” container after the “working-indicator” container, so our button appears next to the title, where it matches its context the most. This is just the container, though.

All the buttons in the StackEdit UI are built with JS. This happens in the file public/res/libs/Markdown.Editor.js. First, let’s add the button label. At the top of the file is a defaultStrings array. Edit it to include our Title Capitalization label, like so:

[...]
        help: "Markdown Editing Help",
        
        titlecapitalization: "Autocapitalize Title"
    };

Then, scroll down to the makeSpritedButtonRow function in the same file, and add the following just above the if (helpOptions) { block:

buttons.titlecapitalization = makeButton("wmd-titlecapitalization", getString("titlecapitalization"), "-240px", bindCommand(function (chunk, postProcessing) {
                alert("Hello");
            }));

This will create a button that matches the rest of the editor’s theme, and will give it a title attribute with the string we defined, so we see it when we mouse over the button. It will also make it say “Hello” when clicked. It still won’t show up in the interface, however. To do that, we need to edit public/res/core.js.

Find the comment // Add customized buttons in that file, and go to the end of that block. There, add the following:

$("#wmd-titlecapitalization").append($('<i class="icon-check">')).prependTo($('.capitalize-button'));

This will find our button container and insert our newly created button inside it. If you now refresh the editor in debug mode (homestead.app:5000/editor?debug) and click the button, you should see a “Hello” alert, as defined by the callback in Markdown.Editor.js.

Logic

Now that the button has been added, let’s make it do what we want it to do.

First, let’s fetch the text of the title field. Edit Markdown.Editor.js. Replace alert("Hello"); in the button’s callback with the following:

console.log($(".title-container a").text());

Clicking the button now should produce the current document title in the console. So far so good.

To get the logic of what we want to do, we’re going to “borrow” the code from TitleCapitalization.com. If you look at the source, you’ll notice it’s all there in the bottom script tag. Cleaning it up a bit to remove the site specific things, we end up with this:

(function(){
    var prepositions = [
      'a',
      'abaft',
      'aboard',
      'about',
      'above',
      'absent',
      'across',
      'afore',
      'after',
      'against',
      'along',
      'alongside',
      'amid',
      'amidst',
      'among',
      'amongst',
      'an',
      'apropos',
      'apud',
      'around',
      'as',
      'aside',
      'astride',
      'at',
      'athwart',
      'atop',
      'barring',
      'before',
      'behind',
      'below',
      'beneath',
      'beside',
      'besides',
      'between',
      'beyond',
      'but',
      'by',
      'circa',
      'concerning',
      'despite',
      'down',
      'during',
      'except',
      'excluding',
      'failing',
      'following',
      'for',
      'from',
      'given',
      'in',
      'including',
      'inside',
      'into',
      'lest',
      'like',
      'mid',
      'midst',
      'minus',
      'modulo',
      'near',
      'next',
      'notwithstanding',
      'of',
      'off',
      'on',
      'onto',
      'opposite',
      'out',
      'outside',
      'over',
      'pace',
      'past',
      'per',
      'plus',
      'pro',
      'qua',
      'regarding',
      'round',
      'sans',
      // while it technically can be a preoposition, 
      // (http://www.merriam-webster.com/thesaurus/save[preposition])
      // it is usually used as a verb
      // 'save',
      'since',
      'than',
      'through',
      'thru',
      'throughout',
      'thruout',
      'till',
      'times',
      'to',
      'toward',
      'towards',
      'under',
      'underneath',
      'unlike',
      'until',
      'unto',
      'up',
      'upon',
      'versus',
      'vs\.',
      'vs',
      'v\.',
      'v',
      'via',
      'vice',
      'with',
      'within',
      'without',
      'worth'
    ];
    var articles = [
      'a',
      'an',
      'the'
    ];
    var conjunctions = [
      'and',
      'but',
      'for',
      'so',
      'nor',
      'or',
      'yet'
    ];
    // var small = "(a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v[.]?|via|vs[.]?)";
    var punct = "([!\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]*)";

    var all_lower_case = '(' + (prepositions.concat(articles).concat(conjunctions)).join('|') + ')';
    console.log('all lower case', all_lower_case);
    
    window.titleCaps = function(title){
      var parts = [], split = /[:.;?!] |(?: |^)["Ò]/g, index = 0;

      title = title.replace(/[\u2018\u2019]/g, "'")
        .replace(/[\u201C\u201D]/g, '"');
      
      while (true) {
        var m = split.exec(title);
  
        parts.push( title.substring(index, m ? m.index : title.length)
          .replace(/\b([A-Za-z][a-z.'Õ]*)\b/g, function(all){
            return /[A-Za-z]\.[A-Za-z]/.test(all) ? all : upper(all);
          })
          //.replace(RegExp("\\b" + small + "\\b", "ig"), lower)
          //.replace(RegExp("^" + punct + small + "\\b", "ig"), function(all, punct, word){
          //  return punct + upper(word);
          //})
          //.replace(RegExp("\\b" + small + punct + "$", "ig"), upper));
          .replace(RegExp("\\b" + all_lower_case + "\\b", "ig"), lower)
          .replace(RegExp("^" + punct + all_lower_case + "\\b", "ig"), function(all, punct, word){
            return punct + upper(word);
          })
          .replace(RegExp("\\b" + all_lower_case + punct + "$", "ig"), upper));
        
        index = split.lastIndex;
        
        if ( m ) parts.push( m[0] );
        else break;
      }
      
      return parts.join("").replace(/ V(s?)\. /ig, " v$1. ")
        .replace(/(['Õ])S\b/ig, "$1s")
        .replace(/\b(AT&T|Q&A)\b/ig, function(all){
          return all.toUpperCase();
        });
    };
      
    function lower(word){
      return word.toLowerCase();
    }
      
    function upper(word){
      return word.substr(0,1).toUpperCase() + word.substr(1);
    }
  })();

If you paste this into your console right now, you’ll have access to a root function called “titleCaps” which accepts a string and prints out a title-capitalized string. This is exactly what we need.

Edit the callback for the button once more, and change it into this:

var titleContainer = $('.title-container a');
var capitalized = capitalize($(titleContainer).text());
$(titleContainer).text(capitalized);
$(".input-file-title").val(capitalized);

Now all we’re missing is the capitalize function. Looking around the code of Markdown.Editor.js, we can see that generic functions are there as is (see properlyEncoded, for example). As such, we don’t need to have second thoughts about including ours like that, too. At the end of the file, before the last })();, add the following:

var prepositions = [
        'a',
        'abaft',
        'aboard',
        'about',
        'above',
        'absent',
        'across',
        'afore',
        'after',
        'against',
        'along',
        'alongside',
        'amid',
        'amidst',
        'among',
        'amongst',
        'an',
        'apropos',
        'apud',
        'around',
        'as',
        'aside',
        'astride',
        'at',
        'athwart',
        'atop',
        'barring',
        'before',
        'behind',
        'below',
        'beneath',
        'beside',
        'besides',
        'between',
        'beyond',
        'but',
        'by',
        'circa',
        'concerning',
        'despite',
        'down',
        'during',
        'except',
        'excluding',
        'failing',
        'following',
        'for',
        'from',
        'given',
        'in',
        'including',
        'inside',
        'into',
        'lest',
        'like',
        'mid',
        'midst',
        'minus',
        'modulo',
        'near',
        'next',
        'notwithstanding',
        'of',
        'off',
        'on',
        'onto',
        'opposite',
        'out',
        'outside',
        'over',
        'pace',
        'past',
        'per',
        'plus',
        'pro',
        'qua',
        'regarding',
        'round',
        'sans',
        'since',
        'than',
        'through',
        'thru',
        'throughout',
        'thruout',
        'till',
        'times',
        'to',
        'toward',
        'towards',
        'under',
        'underneath',
        'unlike',
        'until',
        'unto',
        'up',
        'upon',
        'versus',
        'vs\.',
        'vs',
        'v\.',
        'v',
        'via',
        'vice',
        'with',
        'within',
        'without',
        'worth'
    ];
    var articles = [
        'a',
        'an',
        'the'
    ];
    var conjunctions = [
        'and',
        'but',
        'for',
        'so',
        'nor',
        'or',
        'yet'
    ];
    var punct = "([!\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]*)";

    var all_lower_case = '(' + (prepositions.concat(articles).concat(conjunctions)).join('|') + ')';
    console.log('all lower case', all_lower_case);

    var capitalize = function(title){
        var parts = [], split = /[:.;?!] |(?: |^)["Ò]/g, index = 0;

        title = title.replace(/[\u2018\u2019]/g, "'")
            .replace(/[\u201C\u201D]/g, '"');

        while (true) {
            var m = split.exec(title);

            parts.push( title.substring(index, m ? m.index : title.length)
                .replace(/\b([A-Za-z][a-z.'Õ]*)\b/g, function(all){
                    return /[A-Za-z]\.[A-Za-z]/.test(all) ? all : upper(all);
                })
                .replace(RegExp("\\b" + all_lower_case + "\\b", "ig"), lower)
                .replace(RegExp("^" + punct + all_lower_case + "\\b", "ig"), function(all, punct, word){
                    return punct + upper(word);
                })
                .replace(RegExp("\\b" + all_lower_case + punct + "$", "ig"), upper));

            index = split.lastIndex;

            if ( m ) parts.push( m[0] );
            else break;
        }

        return parts.join("").replace(/ V(s?)\. /ig, " v$1. ")
            .replace(/(['Õ])S\b/ig, "$1s")
            .replace(/\b(AT&T|Q&A)\b/ig, function(all){
                return all.toUpperCase();
            });
    };

    function lower(word){
        return word.toLowerCase();
    }

    function upper(word){
        return word.substr(0,1).toUpperCase() + word.substr(1);
    }

If you test this now, you’ll notice a title like “Hello world” gets capitalized into “Hello World”. Clicking into the title field, you’ll notice it applies to the text inside, too – everything has been properly capitalized:

01

Conclusion

In this post, we implemented a desirable new function into StackEdit, the MarkDown editor, by first hosting it locally. We added a button, stole the functionality from TitleCapitalization, and recycled it into our environment. We can now use this upgrade to send a pull request to the project owner, if we so choose. By the time you read this, it might be accepted, and it might be rejected, but whatever the case, our local copy has the functionality implemented and we can work with it as intended.

Comments? Feedback? Let me know!