Repository: apple/swift-docc (swift)

Issue:
DocC emits error for topic section missing topic group heading and ignores anonymous curation
### Description

Swift-DocC’s support for anonymous topic sections (#393) doesn’t seem to work for article-only catalogs. 

### 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

I expect to be able to write topic groups with no topic headings in an article-only catalog that uses `@TechnologyRoot`.

### Actual behavior

I see the following warnings:

```
/Users/ekusters/Downloads/AnonymousTopicTest.docc/Article1.md:5:1: warning: Missing required subheading for Topics section.
A Topics section requires at least one topic, represented by a level-3 subheading. A Topics section without topics won’t render any content.”
/Users/ekusters/Downloads/AnonymousTopicTest.docc/Test.md:7:1: warning: Missing required subheading for Topics section.
A Topics section requires at least one topic, represented by a level-3 subheading. A Topics section without topics won’t render any content.”
```

And the built Archive does not include my curations.

### Steps To Reproduce

1. Open the [attached DocC catalog](https://github.com/apple/swift-docc/files/9758348/AnonymousTopicTest.docc.zip)

2. `docc preview`

### Swift-DocC Version Information

61100a4

### Related Issues

- rdar://100994549

Pull request description:
<!--
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: rdar://100994549, resolves #404

## Summary

The initial implementation of anonymous topics sections didn’t
account for the use-case of a single, anonymous topic section.

The current markup parsing logic expects to find at least one H3 heading
inside a valid topic section. So while the rest of DocC’s navigator
logic was ready to handle this markup, the initial check
for the existence and range of the task group in the markup
failed to return a value for this use case.

## Testing

Confirm that the DocC catalog included in #404 renders as expected.

## Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

- [x] Added tests
- [x] Ran the `./bin/test` script and it succeeded
- [x] Updated documentation if necessary

Patch:
diff --git a/Sources/SwiftDocC/Checker/Checkers/TopicsSectionWithoutSubheading.swift b/Sources/SwiftDocC/Checker/Checkers/TopicsSectionWithoutSubheading.swift
deleted file mode 100644
index c5a5c9f50f..0000000000
--- a/Sources/SwiftDocC/Checker/Checkers/TopicsSectionWithoutSubheading.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- This source file is part of the Swift.org open source project
-
- Copyright (c) 2021 Apple Inc. and the Swift project authors
- Licensed under Apache License v2.0 with Runtime Library Exception
-
- See https://swift.org/LICENSE.txt for license information
- See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-*/
-
-import Foundation
-import Markdown
-
-/**
- A Topics section  should have at least one subheading.
- */
-public struct TopicsSectionWithoutSubheading: Checker {
-    public var problems = [Problem]()
-
-    private var sourceFile: URL?
-
-    /// Creates a new checker that detects Topics sections without subheadings.
-    ///
-    /// - Parameter sourceFile: The URL to the documentation file that the checker checks.
-    public init(sourceFile: URL?) {
-        self.sourceFile = sourceFile
-    }
-
-    public mutating func visitDocument(_ document: Document) -> () {
-        let headings = document.children.compactMap { $0 as? Heading }
-        for (index, heading) in headings.enumerated() {
-            guard heading.isTopicsSection, isMissingSubheading(heading, remainingHeadings: headings.dropFirst(index + 1)) else {
-                continue
-            }
-
-            let explanation = """
-            A Topics section requires at least one topic, represented by a level-3 subheading. A Topics section without topics won’t render any content.”
-            """
-
-            let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: heading.range, identifier: "org.swift.docc.TopicsSectionWithoutSubheading", summary: "Missing required subheading for Topics section.", explanation: explanation)
-            problems.append(Problem(diagnostic: diagnostic))
-        }
-    }
-
-    private func isMissingSubheading(_ heading: Heading, remainingHeadings: ArraySlice<Heading>) -> Bool {
-        if let nextHeading = remainingHeadings.first {
-            return nextHeading.level <= heading.level
-        }
-
-        return true
-    }
-}
diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
index 57a54f541c..9597fe8ab5 100644
--- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
+++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
@@ -400,7 +400,6 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
             MissingAbstract(sourceFile: source).any(),
             NonOverviewHeadingChecker(sourceFile: source).any(),
             SeeAlsoInTopicsHeadingChecker(sourceFile: source).any(),
-            TopicsSectionWithoutSubheading(sourceFile: source).any(),
         ])
         checker.visit(document)
         diagnosticEngine.emit(checker.problems)
diff --git a/Sources/SwiftDocC/Model/DocumentationMarkup.swift b/Sources/SwiftDocC/Model/DocumentationMarkup.swift
index da47867501..1846b068ca 100644
--- a/Sources/SwiftDocC/Model/DocumentationMarkup.swift
+++ b/Sources/SwiftDocC/Model/DocumentationMarkup.swift
@@ -247,6 +247,14 @@ struct DocumentationMarkup {
                         topicsFirstTaskGroupIndex = index
                     }
                 }
+                // The first topic group in a topic section is allowed to be "anonymous", or without
+                // an H3 heading. We account for this by treating both UnorderedLists and Paragraphs as
+                // valid children indicating the start of a task group.
+                else if child is UnorderedList {
+                    topicsFirstTaskGroupIndex = index
+                } else if child is Paragraph {
+                    topicsFirstTaskGroupIndex = index
+                }
                 
                 if topicsIndex == nil { topicsIndex = index }
                 


Test patch:
diff --git a/Tests/SwiftDocCTests/Checker/Checkers/TopicsSectionWithoutSubheadingTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/TopicsSectionWithoutSubheadingTests.swift
deleted file mode 100644
index ae26aacc8c..0000000000
--- a/Tests/SwiftDocCTests/Checker/Checkers/TopicsSectionWithoutSubheadingTests.swift
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- This source file is part of the Swift.org open source project
-
- Copyright (c) 2021 Apple Inc. and the Swift project authors
- Licensed under Apache License v2.0 with Runtime Library Exception
-
- See https://swift.org/LICENSE.txt for license information
- See https://swift.org/CONTRIBUTORS.txt for Swift project authors
-*/
-
-import XCTest
-@testable import SwiftDocC
-import Markdown
-
-class TopicsSectionWithoutSubheadingTests: XCTestCase {
-    func testEmptyDocument() {
-        let checker = visitDocument(Document())
-        XCTAssertTrue(checker.problems.isEmpty)
-    }
-    
-    func testTopicsSectionHasSubheading() {
-        let markupSource = """
-# Title
-
-Testing One
-
-## Topics
-
-### Test 2
-
-Testing Two
-
-# Test
-"""
-        let checker = visitSource(markupSource)
-        XCTAssertTrue(checker.problems.isEmpty)
-    }
-    
-    func testTopicsSectionHasNoSubheading() {
-        let markupSource = """
-# Title
-
-Abstract.
-
-## Topics
-
-## Information
-
-### Topic B
-"""
-        
-        let document = Document(parsing: markupSource)
-        let checker = visitDocument(document)
-        XCTAssertEqual(1, checker.problems.count)
-        
-        let problem = checker.problems[0]
-        XCTAssertTrue(problem.possibleSolutions.isEmpty)
-        
-        let noSubheadingHeading = document.child(at: 2)! as! Heading
-        let diagnostic = problem.diagnostic
-        XCTAssertEqual("org.swift.docc.TopicsSectionWithoutSubheading", diagnostic.identifier)
-        XCTAssertEqual(noSubheadingHeading.range, diagnostic.range)
-    }
-
-    func testTopicsSectionIsFinalHeading() {
-        let markupSource = """
-# Title
-
-Abstract.
-
-## User
-
-## Information
-
-## Topics
-"""
-        
-        let document = Document(parsing: markupSource)
-        let checker = visitDocument(document)
-        XCTAssertEqual(1, checker.problems.count)
-        
-        let problem = checker.problems[0]
-        XCTAssertTrue(problem.possibleSolutions.isEmpty)
-        
-        let noSubheadingHeading = document.child(at: 4)! as! Heading
-        let diagnostic = problem.diagnostic
-        XCTAssertEqual("org.swift.docc.TopicsSectionWithoutSubheading", diagnostic.identifier)
-        XCTAssertEqual(noSubheadingHeading.range, diagnostic.range)
-    }
-}
-
-extension TopicsSectionWithoutSubheadingTests {
-    func visitSource(_ source: String) -> TopicsSectionWithoutSubheading {
-        let document = Document(parsing: source)
-        return visitDocument(document)
-    }
-    
-    func visitDocument(_ document: Document) -> TopicsSectionWithoutSubheading {
-        var checker = TopicsSectionWithoutSubheading(sourceFile: nil)
-        checker.visit(document)
-        
-        return checker
-    }
-}
diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift
index 139356c14e..ff1c76b6d8 100644
--- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift
+++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift
@@ -3174,4 +3174,45 @@ Document
             ]
         )
     }
+    
+    func testTopicsSectionWithSingleAnonymousTopicGroup() throws {
+        let (_, bundle, context) = try testBundleAndContext(
+            copying: "TestBundle",
+            configureBundle: { url in
+                try """
+                # Article
+                
+                Abstract.
+                
+                ## Topics
+                
+                - ``MyKit/MyProtocol``
+                - ``MyKit/MyClass``
+                
+                """.write(to: url.appendingPathComponent("article.md"), atomically: true, encoding: .utf8)
+            }
+        )
+         
+        let articleReference = ResolvedTopicReference(
+            bundleIdentifier: bundle.identifier,
+            path: "/documentation/Test-Bundle/article",
+            sourceLanguage: .swift
+        )
+        
+        let articleNode = try context.entity(with: articleReference)
+        
+        var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: articleNode.reference, source: nil)
+        let articleRenderNode = try XCTUnwrap(translator.visit(articleNode.semantic) as? RenderNode)
+        
+        XCTAssertEqual(
+            articleRenderNode.topicSections.flatMap { taskGroup in
+                [taskGroup.title] + taskGroup.identifiers
+            },
+            [
+                nil,
+                "doc://org.swift.docc.example/documentation/MyKit/MyProtocol",
+                "doc://org.swift.docc.example/documentation/MyKit/MyClass",
+            ]
+        )
+    }
 }