Repository: platers/obsidian-linter (ts)

Issue:
Bug: YAML Title does not pull from filename
## Describe the Bug

If there are any headers in the file, "YAML Title" will pull the first header for the title instead of using the filename (or even an h1 later in the file) for the YAML title field.

## How to Reproduce

Steps to reproduce the behavior:

1. Create a file with a header besides an h1
2. Lint
3. The YAML will contain `title: headername`

## Expected Behavior

The YAML should contain `title: filename`

## Device

- [ ] Desktop
- [x] Mobile

Pull request:
Add 2 New Header Rules and Fix Issue with Yaml Title Grabbing Any Header
Fixes #519 
Relates to #32 

Changes Made:
- Added header align left and remove trailing header punctuation rules
- Fixed a bug that was introduced where getting the first H1 was actually getting the first header in the file
- Added a UT to help make sure we don't hit the issue again

Patch:
diff --git a/src/rules/headings-start-line.ts b/src/rules/headings-start-line.ts
new file mode 100644
index 0000000..3d5d1b1
--- /dev/null
+++ b/src/rules/headings-start-line.ts
@@ -0,0 +1,60 @@
+import {Options, RuleType} from '../rules';
+import RuleBuilder, {ExampleBuilder, OptionBuilderBase} from './rule-builder';
+import dedent from 'ts-dedent';
+import {ignoreListOfTypes, IgnoreTypes} from '../utils/ignore-types';
+import {allHeadersRegex} from '../utils/regex';
+
+class HeadingStartLineOptions implements Options {
+}
+
+@RuleBuilder.register
+export default class HeadingStartLine extends RuleBuilder<HeadingStartLineOptions> {
+  get OptionsClass(): new () => HeadingStartLineOptions {
+    return HeadingStartLineOptions;
+  }
+  get name(): string {
+    return 'Headings Start Line';
+  }
+  get description(): string {
+    return 'Headings that do not start a line will have their preceding whitespace removed to make sure they get recognized as headers.';
+  }
+  get type(): RuleType {
+    return RuleType.HEADING;
+  }
+  apply(text: string, options: HeadingStartLineOptions): string {
+    return ignoreListOfTypes([IgnoreTypes.code, IgnoreTypes.yaml], text, (text) => {
+      return text.replaceAll(allHeadersRegex, (heading: string) => {
+        return heading.trimStart();
+      });
+    });
+  }
+  get exampleBuilders(): ExampleBuilder<HeadingStartLineOptions>[] {
+    return [
+      new ExampleBuilder({
+        description: 'Removes spaces prior to a heading',
+        before: dedent`
+          ${''}   ## Other heading preceded by 2 spaces ##
+          _Note that if the spacing is enough for the header to be considered to be part of a codeblock it will not be affected by this rule._
+        `,
+        after: dedent`
+          ## Other heading preceded by 2 spaces ##
+          _Note that if the spacing is enough for the header to be considered to be part of a codeblock it will not be affected by this rule._
+        `,
+      }),
+      new ExampleBuilder({
+        description: 'Tags are not affected by this',
+        before: dedent`
+          ${''}  #test
+          ${''}  # Heading &amp;
+        `,
+        after: dedent`
+          ${''}  #test
+          # Heading &amp;
+        `,
+      }),
+    ];
+  }
+  get optionBuilders(): OptionBuilderBase<HeadingStartLineOptions>[] {
+    return [];
+  }
+}
diff --git a/src/rules/remove-trailing-punctuation-in-heading.ts b/src/rules/remove-trailing-punctuation-in-heading.ts
new file mode 100644
index 0000000..73fd623
--- /dev/null
+++ b/src/rules/remove-trailing-punctuation-in-heading.ts
@@ -0,0 +1,80 @@
+import {Options, RuleType} from '../rules';
+import RuleBuilder, {ExampleBuilder, OptionBuilderBase, TextOptionBuilder} from './rule-builder';
+import dedent from 'ts-dedent';
+import {ignoreListOfTypes, IgnoreTypes} from '../utils/ignore-types';
+import {allHeadersRegex} from '../utils/regex';
+
+class RemoveTrailingPunctuationInHeadingOptions implements Options {
+  punctuationToRemove?: string = '.,;:!。，；：！';
+}
+
+@RuleBuilder.register
+export default class RemoveTrailingPunctuationInHeading extends RuleBuilder<RemoveTrailingPunctuationInHeadingOptions> {
+  get OptionsClass(): new () => RemoveTrailingPunctuationInHeadingOptions {
+    return RemoveTrailingPunctuationInHeadingOptions;
+  }
+  get name(): string {
+    return 'Remove Trailing Punctuation in Heading';
+  }
+  get description(): string {
+    return 'Removes the specified punctuation from the end of headings making sure to ignore the semicolon at the end of [HTML entity references](https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references).';
+  }
+  get type(): RuleType {
+    return RuleType.HEADING;
+  }
+  apply(text: string, options: RemoveTrailingPunctuationInHeadingOptions): string {
+    return ignoreListOfTypes([IgnoreTypes.code, IgnoreTypes.yaml], text, (text) => {
+      return text.replaceAll(allHeadersRegex,
+          (heading: string, $1: string = '', $2: string = '', $3: string = '', $4: string = '', $5: string = '') => {
+            // ignore the html entities and entries without any heading text
+            // regex from https://stackoverflow.com/a/26128757/8353749
+            if ($4 == '' || $4.match(/&[^\s]+;$/mi)) {
+              return heading;
+            }
+
+            const lastHeadingChar = $4.charAt($4.length - 1);
+            if (options.punctuationToRemove.includes(lastHeadingChar)) {
+              return $1 + $2 + $3 + $4.substring(0, $4.length - 1) + $5;
+            }
+
+            return heading;
+          });
+    });
+  }
+  get exampleBuilders(): ExampleBuilder<RemoveTrailingPunctuationInHeadingOptions>[] {
+    return [
+      new ExampleBuilder({
+        description: 'Removes punctuation from the end of a heading',
+        before: dedent`
+          # Heading ends in a period.
+          ## Other heading ends in an exclamation mark! ##
+        `,
+        after: dedent`
+          # Heading ends in a period
+          ## Other heading ends in an exclamation mark ##
+        `,
+      }),
+      new ExampleBuilder({
+        description: 'HTML Entities at the end of a heading is ignored',
+        before: dedent`
+          # Heading 1
+          ## Heading &amp;
+        `,
+        after: dedent`
+          # Heading 1
+          ## Heading &amp;
+        `,
+      }),
+    ];
+  }
+  get optionBuilders(): OptionBuilderBase<RemoveTrailingPunctuationInHeadingOptions>[] {
+    return [
+      new TextOptionBuilder({
+        OptionsClass: RemoveTrailingPunctuationInHeadingOptions,
+        name: 'Trailing Punctuation',
+        description: 'The trailing punctuation to remove from the headings in the file.',
+        optionsKey: 'punctuationToRemove',
+      }),
+    ];
+  }
+}
diff --git a/src/utils/regex.ts b/src/utils/regex.ts
index 2df784d..22646f4 100644
--- a/src/utils/regex.ts
+++ b/src/utils/regex.ts
@@ -2,7 +2,8 @@
 import {makeSureContentHasEmptyLinesAddedBeforeAndAfter} from './strings';
 
 // Useful regexes
-export const headerRegex = /^(\s*)(#+)(\s+)(.*)$/m;
+export const headerRegex = /^([ \t]*)(#+)([ \t]+)([^#\n\r]*)([ \t]+#+)?$/m;
+export const allHeadersRegex = new RegExp(headerRegex.source, headerRegex.flags + 'g');
 export const fencedRegexTemplate = '^XXX\\.*?\n(?:((?:.|\n)*?)\n)?XXX(?=\\s|$)$';
 export const yamlRegex = /^---\n((?:(((?!---)(?:.|\n)*?)\n)?))---(?=\n|$)/;
 export const backtickBlockRegexTemplate = fencedRegexTemplate.replaceAll('X', '`');
@@ -88,9 +89,9 @@ export function ensureEmptyLinesAroundTables(text: string): string {
  * @return {string} The text for the first header one if present or an empty string.
  */
 export function getFirstHeaderOneText(text: string) {
-  const result = text.match(headerRegex);
-  if (result && result[4]) {
-    let headerText = result[4];
+  const result = text.match(/^#\s+(.*)/m);
+  if (result && result[1]) {
+    let headerText = result[1];
     headerText = headerText.replaceAll(wikiLinkRegex, (_, _2, $2: string, $3: string) => {
       return $3 ?? $2;
     });


Test patch:
diff --git a/__tests__/yaml-title.test.ts b/__tests__/yaml-title.test.ts
index 2f3a76a..09c9beb 100644
--- a/__tests__/yaml-title.test.ts
+++ b/__tests__/yaml-title.test.ts
@@ -111,5 +111,21 @@ ruleTest({
         # This is a [[Heading]]
       `,
     },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/519
+      testName: 'Make sure that the first header is captured even if another header precedes it.',
+      before: dedent`
+        ### not the title
+
+        # Title
+      `,
+      after: dedent`
+        ---
+        title: Title
+        ---
+        ### not the title
+
+        # Title
+      `,
+    },
   ],
 });