2
\$\begingroup\$

This program converts legacy markup in the form seen in the tests to the resulting markdown seen in the test's respective comment.

The full program is as uploaded as a snippet, or you may see below

let tests = [
  "OneMatch$$$TwoMatch"                  // [One Match](OneMatch)[Two Match](TwoMatch)
, "OneMatch$$$TwoMatch$$$ThreeMatch"     // [One Match](OneMatch)[Two Match](TwoMatch)[Three Match](ThreeMatch)
, "E$$$scapeMatch"                       // Escape Match
, "SimpleMatch"                          // [Simple Match](SimpleMatch)
, "Prefixed$$$MatchyMatch"               // Prefixed [Matchy Match](MatchyMatch)
, "MatchMatchWith$$$Suffix"              // [Match Match With](MatchMatchWith) Suffix
, "MatchesWithPlural$$$s"                // [Matches With Plural](MatchesWithPlural)s
, "This$$$Gets$$$Escaped"                // This Gets Escaped
]

The $$$ delimits valid matches. Furthermore, a match is only valid if it is composed of two or more StartCased words.

Possible matches are checked against a regex and either formatted, or just returned:

function parseWord(word) {
  let linkRE = /\b[A-Z][a-z]+([A-Z][a-z]+)+\b/g
  let validLinks = word.match(linkRE)
  let stripped = word.replace(/\${3}/, '')

  if (validLinks) {
      return formatLinks(validLinks, stripped)
  }

  return _.startCase(stripped)
}

If we are to format the match we find the difference between the matched link and the entire found word. From there we decide whether to add the difference as a prefix or suffix to the link:

function formatLinks(matches, stripped) {
  let formatted_links = []
  for (match of matches) {
      let diff = difference(match, stripped)

      if (isSimpleLink(diff)) {
          formatted_links.push(formatAsSimpleLink(diff))
      } else if (hasPrefix(diff)) {
          formatted_links.push(formatAsPrefixedLink(diff))
      } else if (hasSuffix(diff)) {
          formatted_links.push(formatAsSuffixedLink(diff))
      }
  }

  // There might be multiple links within a word
  // in which case we join them and strip any duplicated parts
  // (which are otherwise interpreted as suffixes)
  if (formatted_links.length > 1) {
      return combineLinks(formatted_links)
  }

  // Default case
  return formatted_links[0]
}

The function, difference, is fast-diff from npm. Which is defined as such:

let result = difference('Good dog', 'Bad dog');
// [[-1, "Goo"], [1, "Ba"], [0, "d dog"]]

Here are the remaining helper functions (or you may view the snippet for the rest):

function combineLinks(links) {
  links.join('').replace(/(?<=\)).*?(?=\[)/g, '')
}

function formatAsSimpleLink(diff) {
  let link = diff[0][1]
  let text = _.startCase(link)
  return `[${text}](${link})`
}

function formatAsPrefixedLink(diff) {
  let link = diff[1][1]
  let text = _.startCase(link)
  let prefix = _.startCase(diff[0][1])
  return `${prefix} [${text}](${link})`
}

function formatAsSuffixedLink(diff) {
  let link = diff[0][1]
  let text = _.startCase(link)
  let suffix = diff[1][1]

  // don't capitalize plurals
  if (suffixIsPlural(suffix) == false) {
      suffix = _.startCase(diff[1][1])
      return `[${text}](${link}) ${suffix}`
  }

  return `[${text}](${link})${suffix}`
}

function isSimpleLink(diff) {
  return diff.length == 1
}

function hasPrefix(diff) {
  return diff[0][0] == 1 && diff[1][0] == 0
}

function hasSuffix(diff) {
  return diff[0][0] == 0 && diff[1][0] == 1
}

function suffixIsPlural(suffix) {
  return suffix.slice(-1) == 's'
}

My main concerns are:

  • The looping in formatLinks, which is really only useful if the word has a suffix which is also a link
  • The magic numbers used to check the diff, e.g., diff[0][1]
  • If there is a better way to tackle the problem.
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

The magic numbers used to check the diff, e.g., diff[0][0] == 1 && diff[1][0] == 0

One small improvement is that fast_diff exports enum values that can be used instead, eg:

differencesArr[0][0] === diff.INSERT
differencesArr[1][0] === diff.EQUAL

The looping in formatLinks, which is really only useful if the word has a suffix which is also a link

The whole looping mechanism looks quite suspicious to me:

  • Only the first $$$ is replaced by the .replace(/\${3}/, '') - if the word contains multiple $$$s, those beyond the first will be left in place

  • Inside formatLinks, you perform tests on and examine only the first element of the diff array, which is strange if there happen to be multiple matches

  • Then you have to clean the string afterwards - eg OneMatch$$$TwoMatch$$$ThreeMatch turns into [One Match](OneMatch) Two Match Three MatchOne Match [Two Match](TwoMatch)One Match Two Match [Three Match](ThreeMatch), which needs to be transformed again. Seems inelegant and error-prone.

If there is a better way to tackle the problem.

It looks like there are two fundamental categories a substring can fall into:

  • One which is escaped, and no []()s should be produced, like the whole span of E$$$scapeMatch
  • One which is not escaped, like the whole span of OneMatch$$$TwoMatch

To put each substring into one of these categories, I'd split by $$$ which split true, unescaped matches. For example:

OneMatch$$$TwoMatch -> ['OneMatch', 'TwoMatch']
E$$$scapeMatch -> ['E$$$scapeMatch']

Then you can iterate over each possible match, transforming the normal matches to their proper format, and leaving the escaped matches alone (save for removing their $$$s). Join the array of results, and there'll be 2 things left to do:

  • Add spacing at the edges of match brackets, if they're next to a prefix or suffix (which can be done with a simple regex)
  • Insert spaces between each word outside of brackets (which can be done with a simple regex)
const parseInput = input => input
    // Split into an array containing separate unescaped matches, escaped sequences, or prefixes/suffixes:
    .split(/\${3}(?=[A-Z]|s?$)/)
    // Add []() brackets around unescaped matches and remove all $s:
    .map(transformSubstr)
    .join('')
    // Add spacing around brackets:
    .replace(/(?<=\w)\[/g, ' [')
    // Except right before an "s" suffix:
    .replace(/\)(?!s)(?=\w)/g, ') ')
    // Add spaces between each word outside of brackets:
    .replace(
        /(?!s$)\w+(?=\[|$)/g,
        words => words.replace(/\B[A-Z]/g, ' $&')
    );

const transformSubstr = (substr) => {
    const normalWordsMatch = substr.match(/\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b/g);
    // If this section is an escaped match, a prefix, or a suffix, there won't be a match
    if (!normalWordsMatch) return substr.replace(/\$/g, '');
    const words = normalWordsMatch[0].match(/[A-Z][a-z]+/g);
    return `[${words.join(' ')}](${words.join('')})`;
};

const tests = [
  "OneMatch{a1fc471a-9483-4af3-8960-7104aa8d3b97}$TwoMatch{183d6425-9d46-4c32-8ce1-55b02e15b3c1}$scapeMatch"                       // Escape Match
, "SimpleMatch"                          // [Simple Match](SimpleMatch)
, "Prefixed{12abb1ad-bc6b-49b9-af33-314c963350ac}$Suffix"              // [Match Match With](MatchMatchWith) Suffix
, "MatchesWithPlural{d63f36d7-04f1-446b-80d3-6c09885cb6f3}$Gets$$$Escaped"                // This Gets Escaped
]

const parseInput = input => input
    // Split into an array containing separate unescaped matches, escaped sequences, or prefixes/suffixes:
    .split(/\${3}(?=[A-Z]|s?$)/)
    // Add []() brackets around unescaped matches:
    .map(transformSubstr)
    .join('')
    // Add spacing around brackets:
    .replace(/(?<=\w)\[/g, ' [')
    // Except right before an "s" suffix:
    .replace(/\)(?!s)(?=\w)/g, ') ')
    // Add spaces between each word outside of brackets:
    .replace(
        /(?!s$)\w+(?=\[|$)/g,
        words => words.replace(/\B[A-Z]/g, ' $&')
    );

const transformSubstr = (substr) => {
    const normalWordsMatch = substr.match(/\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b/g);
    // If this section is an escaped match, a prefix, or a suffix, there won't be a match
    if (!normalWordsMatch) return substr.replace(/\$/g, '');
    const words = normalWordsMatch[0].match(/[A-Z][a-z]+/g);
    return `[${words.join(' ')}](${words.join('')})`;
};
for (const input of tests) {
  console.log(parseInput(input));
}

I don't see any need for either of the external libraries anymore.


A couple nitpicks about your original code, if you were to keep using it:

Always use const when you can, which should be possible 95% of the time - only use let to warn future readers of the code that you may be reassigning the variable in the future.

Avoid sloppy comparison with == and != - better to use === and !==, whose rules are much easier to understand.

Always declare variables before using them - with for (match of matches), you're implicitly creating a global match variable. (Use for (const match of matches) instead)

parseWord name? There can be multiple words inside the variable passed to parseWord, eg SimpleMatch (though there's no space). Maybe call it something like parseInput.

Single-letter match? The current pattern being used in all the code here so far requires words to contain at least two characters. Is that really desirable? Consider, for example, what you'd want to happen given markup of ACuteCat$$$ADog. If that's permitted, you'd just need to change the [a-z]+ to [a-z]*.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.