Repository: platers/obsidian-linter (ts)

Issue:
[BUG] `compact-yaml` is also applied to non-YAML `---`
In a file without YAML frontmatter, where there is still an `---`, it is still treated as YAML hr, and `compact-yaml` is therefore (wrongly) applied.

As the Obsidian Documentation explicitly states that [text is only treated as yaml when the triple dash at the very top of the document](https://help.obsidian.md/Advanced+topics/YAML+front+matter), I think that this should apply to `compact-yaml` (and other yaml-related rules) as well.

Pull request description:
Format yaml
DropdownOption for capitalize headings.

formatYAML method for forcing YAML rules to only change the YAML. Fixes #59 

Auto test to make sure YAMLs dont break tests.

Improve YAML and code block regex's

Patch:
diff --git a/docs/rules.md b/docs/rules.md
index 0173562..33003b0 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -18,7 +18,7 @@ Before:
 
 ```markdown
 ---
-tags: #one #two #three
+tags: #one #two #three #nested/four/five
 ---
 ```
 
@@ -26,7 +26,7 @@ After:
 
 ```markdown
 ---
-tags: one, two, three
+tags: one, two, three, nested/four/five
 ---
 ```
 Example: Format Tags in YAML frontmatter
@@ -55,11 +55,11 @@ Keep track of the date the file was last edited in the YAML front matter. Gets d
 
 Options:
 - Date Created: Insert the file creation date
-  - Default: `true`
+	- Default: `true`
 - Date Modified: Insert the date the file was last modified
-  - Default: `true`
+	- Default: `true`
 - Format: Date format
-  - Default: `dddd, MMMM Do YYYY, h:mm:ss a`
+	- Default: `dddd, MMMM Do YYYY, h:mm:ss a`
 
 Example: Adds a header with the date.
 
@@ -180,42 +180,43 @@ Alias: `capitalize-headings`
 Headings should be formatted with capitalization
 
 Options:
-- Title Case: Format headings with title case capitalization
-  - Default: `true`
-- All Caps: Format headings with all capitals
-  - Default: `false`
+- Style: The style of capitalization to use
+	- Default: `Title Case`
+	- `Title Case`: Capitalize using title case rules
+	- `All Caps`: Capitalize the first letter of each word
+	- `First Letter`: Only capitalize the first letter
 
-Example: The first letter of a heading should be capitalized
+Example: With `Title Case=true`
 
 Before:
 
 ```markdown
 # this is a heading 1
-## this is a heading 2
+## THIS IS A HEADING 2
+### a heading 3
 ```
 
 After:
 
 ```markdown
-# This is a heading 1
-## This is a heading 2
+# This is a Heading 1
+## This is a Heading 2
+### A Heading 3
 ```
-Example: With `Title Case=true`
+Example: With `First Letter=true`
 
 Before:
 
 ```markdown
 # this is a heading 1
-## THIS IS A HEADING 2
-### a heading 3
+## this is a heading 2
 ```
 
 After:
 
 ```markdown
-# This is a Heading 1
-## This is a Heading 2
-### A Heading 3
+# This is a heading 1
+## This is a heading 2
 ```
 Example: With `All Caps=true`
 
@@ -370,7 +371,7 @@ All headings have a blank line both before and after (except where the heading i
 
 Options:
 - Bottom: Insert a blank line after headings
-  - Default: `true`
+	- Default: `true`
 
 Example: Headings should be surrounded by blank lines
 
@@ -545,7 +546,7 @@ Converts leading spaces to tabs.
 
 Options:
 - Tabsize: Number of spaces that will be converted to a tab
-  - Default: `4`
+	- Default: `4`
 
 Example: Converting spaces to tabs with `tabsize = 3`
 
diff --git a/src/docs.ts b/src/docs.ts
index acaaf5a..38e7415 100644
--- a/src/docs.ts
+++ b/src/docs.ts
@@ -1,5 +1,6 @@
 import {readFileSync, writeFileSync} from 'fs';
 import dedent from 'ts-dedent';
+import {DropdownOption} from './option';
 import {rules} from './rules';
 
 const autogen_warning = '<!--- This file was automatically generated. See docs.ts and *_template.md files for the source. -->';
@@ -46,9 +47,17 @@ for (const rule of rules) {
   `).join('\n');
 
   const options_list = rule.options.slice(1).map((option) => {
-    return dedent`
+    let text = dedent`
                   - ${option.name}: ${option.description}
-                    - Default: \`${option.defaultValue}\``;
+                  \t- Default: \`${option.defaultValue}\``;
+
+    if (option instanceof DropdownOption) {
+      for (const record of option.options) {
+        text += `\n\t- \`${record.value}\`: ${record.description}`;
+      }
+    }
+
+    return text;
   }).join('\n');
   let options = '';
   if (options_list.length > 0) {
diff --git a/src/main.ts b/src/main.ts
index 2545e6d..eb11c59 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,7 +3,7 @@ import {LinterSettings, Options, rules} from './rules';
 import {getDisabledRules} from './utils';
 import Diff from 'diff';
 import moment from 'moment';
-import {BooleanOption, MomentFormatOption, TextOption} from './option';
+import {BooleanOption, DropdownOption, MomentFormatOption, TextOption} from './option';
 import dedent from 'ts-dedent';
 
 export default class LinterPlugin extends Plugin {
@@ -198,6 +198,23 @@ class SettingTab extends PluginSettingTab {
               });
             });
       };
+
+      DropdownOption.prototype.display = function(containerEl: HTMLElement, settings: LinterSettings, plugin: LinterPlugin): void {
+        new Setting(containerEl)
+            .setName(this.name)
+            .setDesc(this.description)
+            .addDropdown((dropdown) => {
+              dropdown.setValue(settings.ruleConfigs[this.ruleName][this.name]);
+              for (const option of this.options) {
+                dropdown.addOption(option.value, option.value);
+              }
+              dropdown.onChange((value) => {
+                this.setOption(value, settings);
+                plugin.settings = settings;
+                plugin.saveData(plugin.settings);
+              });
+            });
+      };
     }
 
     display(): void {
diff --git a/src/option.ts b/src/option.ts
index 755dd71..2393ef4 100644
--- a/src/option.ts
+++ b/src/option.ts
@@ -46,3 +46,23 @@ export class TextOption extends Option {
 export class MomentFormatOption extends Option {
   public defaultValue: boolean;
 }
+
+export class DropdownRecord {
+  public value: string;
+  public description: string;
+
+  constructor(value: string, description: string) {
+    this.value = value;
+    this.description = description;
+  }
+}
+
+export class DropdownOption extends Option {
+  public defaultValue: string;
+  public options: DropdownRecord[];
+
+  constructor(name: string, description: string, defaultValue: string, options: DropdownRecord[], ruleName?: string | null) {
+    super(name, description, defaultValue, ruleName);
+    this.options = options;
+  }
+}
diff --git a/src/rules.ts b/src/rules.ts
index 26726ed..5bc11a7 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -1,7 +1,7 @@
 import dedent from 'ts-dedent';
 import moment from 'moment';
-import {headerRegex, ignoreCodeBlocksAndYAML, initYAML, insert} from './utils';
-import {Option, BooleanOption, MomentFormatOption, TextOption} from './option';
+import {formatYAML, headerRegex, ignoreCodeBlocksAndYAML, initYAML, insert} from './utils';
+import {Option, BooleanOption, MomentFormatOption, TextOption, DropdownOption, DropdownRecord} from './option';
 
 export type Options = { [optionName: string]: any };
 type ApplyFunction = (text: string, options?: Options) => string;
@@ -266,8 +266,11 @@ export const rules: Rule[] = [
       'Removes leading and trailing blank lines in the YAML front matter.',
       RuleType.SPACING,
       (text: string) => {
-        text = text.replace(/^---\n+/, '---\n');
-        return text.replace(/\n+---/, '\n---');
+        return formatYAML(text, (text) => {
+          text = text.replace(/^---\n+/, '---\n');
+          text = text.replace(/\n+---/, '\n---');
+          return text;
+        });
       },
       [
         new Example(
@@ -381,8 +384,10 @@ export const rules: Rule[] = [
       'Remove Hashtags from tags in the YAML frontmatter, as they make the tags there invalid.',
       RuleType.YAML,
       (text: string) => {
-        return text.replace(/\ntags: [\w#\/ ,-]+(?=.*\n---)/is, function(tagsYAML) {
-          return tagsYAML.replaceAll('#', '').replaceAll(' ', ', ').replaceAll(',,', ',').replace('tags:,', 'tags:');
+        return formatYAML(text, (text) => {
+          return text.replace(/\ntags: [\w#\/ ,-]+(?=.*\n---)/is, function(tagsYAML) {
+            return tagsYAML.replaceAll('#', '').replaceAll(' ', ', ').replaceAll(',,', ',').replace('tags:,', 'tags:');
+          });
         });
       },
       [
@@ -390,12 +395,12 @@ export const rules: Rule[] = [
             'Format Tags in YAML frontmatter',
             dedent`
          ---
-         tags: #one #two #three
+         tags: #one #two #three #nested/four/five
          ---
         `,
             dedent`
          ---
-         tags: one, two, three
+         tags: one, two, three, nested/four/five
          ---
         `,
         ),
@@ -421,20 +426,22 @@ export const rules: Rule[] = [
       (text: string, options = {}) => {
         text = initYAML(text);
 
-        if (options['Date Created'] === true) {
-          text = text.replace(/\ndate created:.*\n/, '\n');
-          const yaml_end = text.indexOf('\n---');
-          const formatted_date = moment(options['metadata: file created time']).format(options['Format']);
-          text = insert(text, yaml_end, `\ndate created: ${formatted_date}`);
-        }
-        if (options['Date Modified'] === true) {
-          text = text.replace(/\ndate modified:.*\n/, '\n');
-          text = text.replace(/\ndate updated:.*\n/, '\n'); // for backwards compatibility
-          const yaml_end = text.indexOf('\n---');
-          const formatted_date = moment(options['metadata: file modified time']).format(options['Format']);
-          text = insert(text, yaml_end, `\ndate modified: ${formatted_date}`);
-        }
-        return text;
+        return formatYAML(text, (text) => {
+          if (options['Date Created'] === true) {
+            text = text.replace(/\ndate created:.*\n/, '\n');
+            const yaml_end = text.indexOf('\n---');
+            const formatted_date = moment(options['metadata: file created time']).format(options['Format']);
+            text = insert(text, yaml_end, `\ndate created: ${formatted_date}`);
+          }
+          if (options['Date Modified'] === true) {
+            text = text.replace(/\ndate modified:.*\n/, '\n');
+            text = text.replace(/\ndate updated:.*\n/, '\n'); // for backwards compatibility
+            const yaml_end = text.indexOf('\n---');
+            const formatted_date = moment(options['metadata: file modified time']).format(options['Format']);
+            text = insert(text, yaml_end, `\ndate modified: ${formatted_date}`);
+          }
+          return text;
+        });
       },
       [
         new Example(
@@ -594,33 +601,37 @@ export const rules: Rule[] = [
             if (!match) {
               continue;
             }
-            if (options['Title Case'] == true) {
-              const headerWords = lines[i].match(/\S+/g);
-              const ignoreNames = ['macOS', 'iOS', 'iPhone', 'iPad', 'JavaScript', 'TypeScript', 'AppleScript'];
-              const ignoreAbbreviations = ['CSS', 'HTML', 'YAML', 'PDF', 'USA', 'EU', 'NATO', 'ASCII'];
-              const keepCasing = [...ignoreNames, ...ignoreAbbreviations];
-              const ignoreShortWords = ['via', 'a', 'an', 'the', 'and', 'or', 'but', 'for', 'nor', 'so', 'yet', 'at', 'by', 'in', 'of', 'on', 'to', 'up', 'as', 'is', 'if', 'it', 'for', 'to', 'with', 'without', 'into', 'onto', 'per'];
-              for (let j = 1; j < headerWords.length; j++) {
-                const isWord = headerWords[j].match(/^[A-Za-z'-]+[\.\?!,:;]?$/);
-                if (!isWord) {
-                  continue;
-                }
+            switch (options['Style']) {
+              case 'Title Case':
+                const headerWords = lines[i].match(/\S+/g);
+                const ignoreNames = ['macOS', 'iOS', 'iPhone', 'iPad', 'JavaScript', 'TypeScript', 'AppleScript'];
+                const ignoreAbbreviations = ['CSS', 'HTML', 'YAML', 'PDF', 'USA', 'EU', 'NATO', 'ASCII'];
+                const keepCasing = [...ignoreNames, ...ignoreAbbreviations];
+                const ignoreShortWords = ['via', 'a', 'an', 'the', 'and', 'or', 'but', 'for', 'nor', 'so', 'yet', 'at', 'by', 'in', 'of', 'on', 'to', 'up', 'as', 'is', 'if', 'it', 'for', 'to', 'with', 'without', 'into', 'onto', 'per'];
+                for (let j = 1; j < headerWords.length; j++) {
+                  const isWord = headerWords[j].match(/^[A-Za-z'-]+[\.\?!,:;]?$/);
+                  if (!isWord) {
+                    continue;
+                  }
 
-                const keepWordcasing = keepCasing.includes(headerWords[j]);
-                if (!keepWordcasing) {
-                  headerWords[j] = headerWords[j].toLowerCase();
-                  const ignoreWord = ignoreShortWords.includes(headerWords[j]);
-                  if (!ignoreWord || j == 1) { // ignore words that are not capitalized in titles except if they are the first word
-                    headerWords[j] = headerWords[j][0].toUpperCase() + headerWords[j].slice(1);
+                  const keepWordcasing = keepCasing.includes(headerWords[j]);
+                  if (!keepWordcasing) {
+                    headerWords[j] = headerWords[j].toLowerCase();
+                    const ignoreWord = ignoreShortWords.includes(headerWords[j]);
+                    if (!ignoreWord || j == 1) { // ignore words that are not capitalized in titles except if they are the first word
+                      headerWords[j] = headerWords[j][0].toUpperCase() + headerWords[j].slice(1);
+                    }
                   }
                 }
-              }
 
-              lines[i] = lines[i].replace(headerRegex, `${headerWords.join(' ')}`);
-            } else if (options['All Caps'] == true) {
-              lines[i] = lines[i].toUpperCase(); // convert full heading to uppercase
-            } else {
-              lines[i] = lines[i].replace(/^#*\s([a-z])/, (string) => string.toUpperCase()); // capitalize first letter of heading
+                lines[i] = lines[i].replace(headerRegex, `${headerWords.join(' ')}`);
+                break;
+              case 'All Caps':
+                lines[i] = lines[i].toUpperCase(); // convert full heading to uppercase
+                break;
+              case 'First Letter':
+                lines[i] = lines[i].replace(/^#*\s([a-z])/, (string) => string.toUpperCase()); // capitalize first letter of heading
+                break;
             }
           }
           return lines.join('\n');
@@ -628,29 +639,30 @@ export const rules: Rule[] = [
       },
       [
         new Example(
-            'The first letter of a heading should be capitalized',
+            'With `Title Case=true`',
             dedent`
         # this is a heading 1
-        ## this is a heading 2
+        ## THIS IS A HEADING 2
+        ### a heading 3
         `,
             dedent`
-        # This is a heading 1
-        ## This is a heading 2
+        # This is a Heading 1
+        ## This is a Heading 2
+        ### A Heading 3
         `,
+            {'Style': 'Title Case'},
         ),
         new Example(
-            'With `Title Case=true`',
+            'With `First Letter=true`',
             dedent`
         # this is a heading 1
-        ## THIS IS A HEADING 2
-        ### a heading 3
+        ## this is a heading 2
         `,
             dedent`
-        # This is a Heading 1
-        ## This is a Heading 2
-        ### A Heading 3
+        # This is a heading 1
+        ## This is a heading 2
         `,
-            {'Title Case': true},
+            {'Style': 'First Letter'},
         ),
         new Example(
             'With `All Caps=true`',
@@ -662,12 +674,17 @@ export const rules: Rule[] = [
         # THIS IS A HEADING 1
         ## THIS IS A HEADING 2
         `,
-            {'All Caps': true, 'Title Case': false},
+            {'Style': 'All Caps'},
         ),
       ],
       [
-        new BooleanOption('Title Case', 'Format headings with title case capitalization', true),
-        new BooleanOption('All Caps', 'Format headings with all capitals', false),
+        new DropdownOption('Style', 'The style of capitalization to use', 'Title Case',
+            [
+              new DropdownRecord('Title Case', 'Capitalize using title case rules'),
+              new DropdownRecord('All Caps', 'Capitalize the first letter of each word'),
+              new DropdownRecord('First Letter', 'Only capitalize the first letter'),
+            ],
+        ),
       ],
   ),
 
diff --git a/src/utils.ts b/src/utils.ts
index 90d9fc1..c22644a 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -4,8 +4,8 @@ import {load} from 'js-yaml';
 // Useful regexes
 
 export const headerRegex = /^(\s*)(#+)(\s*)(.*)$/;
-export const fencedRegexTemplate = '^XXX\s*\n((?:.|\n)*?)\nXXX\s*?(?:\n|$)';
-export const yamlRegex = new RegExp(fencedRegexTemplate.replaceAll('X', '-'));
+export const fencedRegexTemplate = '^XXX\.*?\n(?:((?:.|\n)*?)\n)?XXX(?=\s|$)$';
+export const yamlRegex = new RegExp('^---\n(?:((?:.|\n)*?)\n)?---(?=\n|$)');
 export const backtickBlockRegexTemplate = fencedRegexTemplate.replaceAll('X', '`');
 export const tildeBlockRegexTemplate = fencedRegexTemplate.replaceAll('X', '~');
 export const indentedBlockRegex = '^((\t|( {4})).*\n)+';
@@ -56,14 +56,21 @@ export function getDisabledRules(text: string): string[] {
  * @return {string} The processed text
  */
 export function ignoreCodeBlocksAndYAML(text: string, func: (text: string) => string): string {
-  const codePlaceholder = 'PLACEHOLDER 321417\n';
   const codeMatches = text.match(codeBlockRegex);
+  const codePlaceholder = 'PLACEHOLDER 321417';
+  if (codeMatches) {
+    for (const match of codeMatches) {
+      text = text.replace(match, codePlaceholder);
+    }
+  }
 
-  const yamlPlaceholder = '---\n---\n';
+  const yamlPlaceholder = '---\n---';
   const yamlMatches = text.match(yamlRegex);
+  if (yamlMatches) {
+    text = text.replace(yamlMatches[0], yamlPlaceholder);
+  }
+
 
-  text = text.replace(codeBlockRegex, codePlaceholder);
-  text = text.replace(yamlRegex, yamlPlaceholder);
   text = func(text);
 
   if (yamlMatches) {
@@ -79,6 +86,19 @@ export function ignoreCodeBlocksAndYAML(text: string, func: (text: string) => st
   return text;
 }
 
+export function formatYAML(text: string, func: (text: string) => string): string {
+  if (!text.match(yamlRegex)) {
+    return text;
+  }
+
+  let yaml = text.match(yamlRegex)[0];
+  yaml = func(yaml);
+  text = text.replace(yamlRegex, yaml);
+
+  return text;
+}
+
+
 /**
  * Adds an empty YAML block to the text if it doesn't already have one.
  * @param {string} text - The text to process
@@ -101,3 +121,8 @@ export function initYAML(text: string): string {
 export function insert(str: string, index: number, value: string): string {
   return str.substr(0, index) + value + str.substr(index);
 }
+
+// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
+export function escapeRegExp(string: string): string {
+  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+}


Test patch:
diff --git a/src/test.ts b/src/test.ts
index a6ab50e..1871f24 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -1,16 +1,39 @@
 import dedent from 'ts-dedent';
 import {rules, Rule, rulesDict, Example} from './rules';
-import {getDisabledRules} from './utils';
+import {escapeRegExp, getDisabledRules, yamlRegex} from './utils';
 
 describe('Examples pass', () => {
   for (const rule of rules) {
     describe(rule.name, () => {
-      test.each(rule.examples)('$description', (testObject: Example) => {
-        if (testObject.options && Object.keys(testObject.options).length > 0) {
-          const options = Object.assign(rule.getDefaultOptions(), testObject.options);
-          expect(rule.apply(testObject.before, options)).toBe(testObject.after);
-        } else {
-          expect(rule.apply(testObject.before)).toBe(testObject.after);
+      test.each(rule.examples)('$description', (example: Example) => {
+        const options = rule.getDefaultOptions();
+        if (example.options) {
+          Object.assign(options, example.options);
+        }
+        expect(rule.apply(example.before, options)).toBe(example.after);
+      });
+    });
+  }
+});
+
+describe('Augmented examples pass', () => {
+  for (const rule of rules) {
+    describe(rule.name, () => {
+      test.each(rule.examples)('$description', (example: Example) => {
+        const options = rule.getDefaultOptions();
+        if (example.options) {
+          Object.assign(options, example.options);
+        }
+
+        // Add a YAML
+        if (rule.type !== 'YAML' && !example.before.match(yamlRegex)) {
+          const yaml = dedent`
+            ---
+            foo: bar
+            ---\n\n`;
+
+          const before = yaml + example.before;
+          expect(rule.apply(before, options)).toMatch(new RegExp(`${escapeRegExp(yaml)}\n?${escapeRegExp(example.after)}`));
         }
       });
     });
@@ -147,7 +170,7 @@ describe('Rules tests', () => {
         ## I Can't Do This
         ## Comma, Comma, Comma
         `;
-      expect(rulesDict['capitalize-headings'].apply(before, {'Title Case': true})).toBe(after);
+      expect(rulesDict['capitalize-headings'].apply(before, {'Style': 'Title Case'})).toBe(after);
     });
   });
   describe('File Name Heading', () => {
@@ -171,6 +194,41 @@ describe('Rules tests', () => {
     });
   });
 });
+describe('Paragraph blank lines', () => {
+  it('Ignores codeblocks', () => {
+    const before = dedent`
+    ---
+    front matter
+    front matter
+    ---
+
+    Hello
+    World
+    \`\`\`python
+    # comment not header
+    a = b
+    c = d
+    \`\`\`
+    `;
+    const after = dedent`
+    ---
+    front matter
+    front matter
+    ---
+
+    Hello
+
+    World
+
+    \`\`\`python
+    # comment not header
+    a = b
+    c = d
+    \`\`\`
+    `;
+    expect(rulesDict['paragraph-blank-lines'].apply(before)).toBe(after);
+  });
+});
 describe('Consecutive blank lines', () => {
   it('Handles ignores code blocks', () => {
     const before = dedent`