Repository: FHIR/sushi (ts)

Issue:
SUSHI does not properly encode menu item names
Including something like this in `sushi-config.yaml`'s `menu` causes problems:
```yaml
Specification:
    Measures: measures.html
    Plans & Interventions: interventions.html
    Conformance & Workflow: workflow.html
```
The problem is that `&` needs to be encoded to `&amp;` in the XML menu.

If we fix SUSHI to do this encoding, we need to be careful not to double-encode items that were already encoded by the user.

See: https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/Menu.20issue/near/285404188

Pull request description:
Escape Characters in menu.xml
This PR supports encoding the five characters that need to escaped in XML. To do this, because there are only five and they are defined in XML, I just encoded each character when it is in any menu item in a `menu.xml` that we generated from the `sushi-config.yaml`. This also checks to make sure we don't double encode any character if the user has already encoded it. For example, if the user has written `Profile &amp; Extensions`, we want to just leave that `&` character and not try to re-encoded it.

Also, based on the suggestion on the issue, if SUSHI detects that there are any escaped characters provided by the user in the configuration, SUSHI will output a warning letting them know we will handle that now and they can replace their character with the un-encoded version, which will be easier to read in the config file.

To test this, you can include both encoded and unencoded characters in a menu.xml file, run SUSHI and check that it properly encodes characters that need to be encoded and warns about characters that are already encoded.

You can also run the IG Publisher to see how the menu works. If you run `master` or the current SUSHI release with a configuration that includes a character that needs to be escaped, the menu will not render correctly in the IG. If you run this branch with the same configuration, you should see that the menu generated by the IG Publisher renders and works as expected. I tested the IG Publisher with menu items with each of the characters that need to be encoded in XML, and it actually works fine with `<`, `>`, `"`, and `'` and only breaks when an `&` is used in the name. However, the IG Publisher correctly handles when they all are escaped as well and two places I read online said it's safer to just always escape all five characters, so that's what I went with.

This PR fixes #1097.

Patch:
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index 0902aee..452a7ee 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -644,7 +644,7 @@ export class IGExporter {
     const menuXMLDefaultPath = path.join(this.inputPath, 'input', 'includes', 'menu.xml');
     const menuXMLOutputPath = path.join(igPath, 'fsh-generated', 'includes', 'menu.xml');
 
-    // If user provided file and config, log a warning but prefer the config.
+    // If user provided file and config, log a warning but prefer the file.
     if (existsSync(menuXMLDefaultPath) && this.config.menu) {
       const filePathString = path.join(
         path.basename(this.inputPath),
@@ -668,7 +668,7 @@ export class IGExporter {
       return;
     }
 
-    // Always use config menu if defined
+    // Use config menu if defined and no file is provided.
     if (this.config.menu) {
       let menu = `<ul xmlns="http://www.w3.org/1999/xhtml" class="nav navbar-nav">${EOL}`;
       this.config.menu.forEach(item => {
@@ -712,7 +712,7 @@ export class IGExporter {
         if (item.openInNewTab) menuItem += 'target="_blank" ';
         menuItem += `href="${item.url}">`;
       }
-      menuItem += item.name;
+      menuItem += this.encodeMenuName(item.name);
       if (item.url) {
         menuItem += '</a>';
       }
@@ -730,7 +730,9 @@ export class IGExporter {
    */
   private buildSubMenu(item: ConfigurationMenuItem, spaces: number): string {
     const prefixSpaces = ' '.repeat(spaces);
-    let subMenu = `${prefixSpaces}<a data-toggle="dropdown" href="#" class="dropdown-toggle">${item.name}${EOL}`;
+    let subMenu = `${prefixSpaces}<a data-toggle="dropdown" href="#" class="dropdown-toggle">${this.encodeMenuName(
+      item.name
+    )}${EOL}`;
     subMenu += `${prefixSpaces}${'  '}<b class="caret"></b>${EOL}`;
     subMenu += `${prefixSpaces}</a>${EOL}`;
     subMenu += `${prefixSpaces}<ul class="dropdown-menu">${EOL}`;
@@ -747,6 +749,24 @@ export class IGExporter {
     return subMenu;
   }
 
+  private encodeMenuName(name: string): string {
+    let escapedName = name;
+    if (/&quot;|&apos;|&lt;|&gt;|&amp;/g.test(name)) {
+      logger.warn(
+        'SUSHI now supports automatically escaping characters in XML. You can safely replace the ' +
+          `escaped character with the unescaped character in the following menu configuration item: ${name}. ` +
+          'SUSHI will escape it when generating the menu.xml file.'
+      );
+    }
+    escapedName = escapedName.replace(/"/g, '&quot;');
+    escapedName = escapedName.replace(/'/g, '&apos;');
+    escapedName = escapedName.replace(/</g, '&lt;');
+    escapedName = escapedName.replace(/>/g, '&gt;');
+    // Replace & with &amp; but we don't want to replace any previously escaped characters, which will start with &.
+    escapedName = escapedName.replace(/&(?!quot;|apos;|lt;|gt;|amp;)/g, '&amp;');
+    return escapedName;
+  }
+
   /**
    * Add each of the resources from the package to the ImplementationGuide JSON file.
    * Configuration may specify resources to omit.


Test patch:
diff --git a/test/ig/IGExporter.menu-xml.test.ts b/test/ig/IGExporter.menu-xml.test.ts
index d7daae1..6b4179d 100644
--- a/test/ig/IGExporter.menu-xml.test.ts
+++ b/test/ig/IGExporter.menu-xml.test.ts
@@ -10,7 +10,8 @@ import {
   simpleMenuXMLContent,
   simpleMenuXMLWithOpenInNewTabContent,
   subMenuXMLContent,
-  subMenuWithWarningXMLContent
+  subMenuWithWarningXMLContent,
+  menuWithEscapedCharacters
 } from './fixtures/menuXMLContent';
 
 describe('IGExporter', () => {
@@ -160,5 +161,51 @@ describe('IGExporter', () => {
       expect(loggerSpy.getAllMessages('warn')).toHaveLength(1);
       expect(loggerSpy.getLastMessage()).toMatch(/The Flowers menu item specifies a sub-menu/s);
     });
+
+    it('should replace characters in menu item names that need to be escaped in XML', () => {
+      const config = { ...minimalConfig };
+      config.menu = [
+        { name: 'Animals <3', url: 'animals.html' },
+        {
+          name: 'Plants >:)',
+          subMenu: [
+            { name: 'Trees and "Leaves"', url: 'trees.html' },
+            { name: "Flowers' Blossoms", url: 'buds.html' },
+            { name: 'Cacti & Succulents', url: 'prickly.com', openInNewTab: true }
+          ]
+        }
+      ];
+      const pkg = new Package(config);
+      const exporter = new IGExporter(pkg, null, '');
+      exporter.addMenuXML(tempOut);
+      const menuPath = path.join(tempOut, 'fsh-generated', 'includes', 'menu.xml');
+      expect(fs.existsSync(menuPath)).toBeTruthy();
+      const content = fs.readFileSync(menuPath, 'utf8');
+      expect(content).toContain(menuWithEscapedCharacters.replace(/\n/g, EOL));
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+    });
+
+    it('should log a warning if any characters are escaped by the user to indicate SUSHI supports escaping the characters for them', () => {
+      const config = { ...minimalConfig };
+      config.menu = [
+        { name: 'Animals &lt;3', url: 'animals.html' },
+        {
+          name: 'Plants &gt;:)',
+          subMenu: [
+            { name: 'Trees and &quot;Leaves"', url: 'trees.html' },
+            { name: 'Flowers&apos; Blossoms', url: 'buds.html' },
+            { name: 'Cacti &amp; Succulents', url: 'prickly.com', openInNewTab: true }
+          ]
+        }
+      ];
+      const pkg = new Package(config);
+      const exporter = new IGExporter(pkg, null, '');
+      exporter.addMenuXML(tempOut);
+      const menuPath = path.join(tempOut, 'fsh-generated', 'includes', 'menu.xml');
+      expect(fs.existsSync(menuPath)).toBeTruthy();
+      const content = fs.readFileSync(menuPath, 'utf8');
+      expect(content).toContain(menuWithEscapedCharacters.replace(/\n/g, EOL));
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(5); // Each menu item has an escaped character in it
+    });
   });
 });
diff --git a/test/ig/fixtures/menuXMLContent.ts b/test/ig/fixtures/menuXMLContent.ts
index 72ab82a..7c90489 100644
--- a/test/ig/fixtures/menuXMLContent.ts
+++ b/test/ig/fixtures/menuXMLContent.ts
@@ -76,3 +76,26 @@ export const subMenuWithWarningXMLContent = `
     </ul>
   </li>
 </ul>`;
+
+export const menuWithEscapedCharacters = `
+<ul xmlns="http://www.w3.org/1999/xhtml" class="nav navbar-nav">
+  <li>
+    <a href="animals.html">Animals &lt;3</a>
+  </li>
+  <li class="dropdown">
+    <a data-toggle="dropdown" href="#" class="dropdown-toggle">Plants &gt;:)
+      <b class="caret"></b>
+    </a>
+    <ul class="dropdown-menu">
+      <li>
+        <a href="trees.html">Trees and &quot;Leaves&quot;</a>
+      </li>
+      <li>
+        <a href="buds.html">Flowers&apos; Blossoms</a>
+      </li>
+      <li>
+        <a target="_blank" href="prickly.com">Cacti &amp; Succulents</a>
+      </li>
+    </ul>
+  </li>
+</ul>`;