Repository: apple/swift-docc (swift)

Issue:
Links to same-page section that start with "#" fail.
### Description

It's possible to link to headings (although not topic sections, but that's unrelated) by adding a # and a heading name to the documentation link. For example, `<doc:ArticleName#HeadingName>`. 

For links to headings on the same page it's possible to omit the article name (or symbol name) and only write  `<doc:HeadingName>`. However, adding the leading # causes the link to fail  `<doc:#HeadingName>`. 

### Checklist

- [X] If possible, I've reproduced the issue using the `main` branch of this package.
- [X] This issue hasn't been addressed in an [existing GitHub issue](https://github.com/apple/swift-docc/issues).

### Expected Behavior

Both  `<doc:HeadingName>` and  `<doc:#HeadingName>` should link work for linking to a heading on the current page.

### Actual behavior

`<doc:#HeadingName>` result in an error about "" (empty string) not being found at the current page.

### Steps To Reproduce

In a project with a documentation catalog; add an article with some heading and write a link to that heading on the same page. For example

```md
# Some article

An article with a heading that it also links to: <doc:#Something>

## Something
```

### Swift-DocC Version Information

5.9

### Swift Compiler Version Information

```shell
swift-driver version: 1.85 Apple Swift version 5.9 (swiftlang-5.9.0.118.1 clang-1500.0.32.1)
Target: arm64-apple-macosx14.0
```

Pull request:
<!--
If you're opening a PR to cherry-pick a change for a release branch, use this template instead:
https://github.com/apple/swift-docc/blob/main/.github/PULL_REQUEST_TEMPLATE/CHERRY_PICK.md
-->

Bug/issue #, if applicable: Closes #632

## Summary

This pull request makes it so that articles can contain links to specific headings on the same page, using both `<doc:Heading>`, and `<doc:#Heading>` syntax.

## Dependencies

None, once the PR is ready, itself — I think there are three failing test cases still.

## Testing

I've added a test in `testArticleSelfAnchorLinks.swift`, and verified this behavior manually, too. To test that a `#heading` link works, you can add a subheading link to an existing document bundle, and rebuild the docs.

## Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

- [x] Added tests
- [ ] **pending**: Ran the `./bin/test` script and it succeeded
- [x] Updated documentation if necessary — N/A for this PR I think?

Patch:
diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift
index f6842763f3..87d5ac8fcf 100644
--- a/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift
+++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/PathHierarchy.swift
@@ -764,10 +764,16 @@ extension PathHierarchy {
         let isAbsolute = path.first == "/"
             || String(components.first ?? "") == NodeURLGenerator.Path.documentationFolderName
             || String(components.first ?? "") == NodeURLGenerator.Path.tutorialsFolderName
-       
+
+        // If there is a # character in the last component, split that into two components
         if let hashIndex = components.last?.firstIndex(of: "#") {
             let last = components.removeLast()
-            components.append(last[..<hashIndex])
+            // Allow anrhor-only links where there's nothing before #.
+            // In case the pre-# part is empty, and we're omitting empty components, don't add it in.
+            let pathName = last[..<hashIndex]
+            if !pathName.isEmpty || !omittingEmptyComponents {
+                components.append(pathName)
+            }
             
             let fragment = String(last[hashIndex...].dropFirst())
             return (components.map(Self.parse(pathComponent:)) + [PathComponent(full: fragment, name: fragment, kind: nil, hash: nil)], isAbsolute)


Test patch:
diff --git a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift
index d31de01a40..dedde1ab95 100644
--- a/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift
+++ b/Tests/SwiftDocCTests/Infrastructure/PathHierarchyTests.swift
@@ -1133,6 +1133,30 @@ class PathHierarchyTests: XCTestCase {
         XCTAssertNil(articleNode.symbol, "General documentation link find the article")
     }
     
+    func testArticleSelfAnchorLinks() throws {
+        try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
+        let (_, _, context) = try testBundleAndContext(copying: "MixedLanguageFramework") { url in
+            try """
+            # ArticleWithHeading
+
+            ## TestTargetHeading
+
+            This article has the same path as a symbol. See also:
+            - <doc:TestTargetHeading>
+            - <doc:#TestTargetHeading>
+
+            """.write(to: url.appendingPathComponent("ArticleWithHeading.md"), atomically: true, encoding: .utf8)
+        }
+
+        let tree = try XCTUnwrap(context.hierarchyBasedLinkResolver?.pathHierarchy)
+        let articleNode = try tree.findNode(path: "/MixedLanguageFramework/ArticleWithHeading", onlyFindSymbols: false)
+
+        let linkNode = try tree.find(path: "TestTargetHeading", parent: articleNode.identifier, onlyFindSymbols: false)
+        let anchorLinkNode = try tree.find(path: "#TestTargetHeading", parent: articleNode.identifier, onlyFindSymbols: false)
+        XCTAssertNotNil(linkNode)
+        XCTAssertNotNil(anchorLinkNode)
+    }
+
     func testOverloadedSymbols() throws {
         try XCTSkipUnless(LinkResolutionMigrationConfiguration.shouldUseHierarchyBasedLinkResolver)
         let (_, context) = try testBundleAndContext(named: "OverloadedSymbols")
@@ -1555,6 +1579,7 @@ class PathHierarchyTests: XCTestCase {
         assertParsedPathComponents("first/", [("first", nil, nil)])
         assertParsedPathComponents("first//second", [("first", nil, nil), ("second", nil, nil)])
         assertParsedPathComponents("first/second#third", [("first", nil, nil), ("second", nil, nil), ("third", nil, nil)])
+        assertParsedPathComponents("#first", [("first", nil, nil)])
 
         // Check disambiguation
         assertParsedPathComponents("path-hash", [("path", nil, "hash")])