-
Notifications
You must be signed in to change notification settings - Fork 166
Add a helper function for rendering a Topics/SeeAlso section as HTML #1382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,152 @@ | ||||||
| /* | ||||||
| This source file is part of the Swift.org open source project | ||||||
|
|
||||||
| Copyright (c) 2025 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 | ||||||
| */ | ||||||
|
|
||||||
| #if canImport(FoundationXML) | ||||||
| // TODO: Consider other HTML rendering options as a future improvement (rdar://165755530) | ||||||
| package import FoundationXML | ||||||
| package import FoundationEssentials | ||||||
| #else | ||||||
| package import Foundation | ||||||
| #endif | ||||||
|
|
||||||
| package import Markdown | ||||||
| package import DocCCommon | ||||||
|
|
||||||
| package extension MarkdownRenderer { | ||||||
| /// Information about a task group that organizes other API into a hierarchy on this page. | ||||||
| struct TaskGroupInfo { | ||||||
| /// The title of this group of API | ||||||
| package var title: String? | ||||||
| /// Any additional free-form content that describes the group of API. | ||||||
| package var content: [any Markup] | ||||||
| /// A list of already resolved references that the renderer should display, in order, for this group. | ||||||
| package var references: [URL] | ||||||
|
|
||||||
| package init(title: String?, content: [any Markup], references: [URL]) { | ||||||
| self.title = title | ||||||
| self.content = content | ||||||
| self.references = references | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Creates a grouped section with a given name, for example "topics" or "see also" that describes and organizes groups of related API. | ||||||
| /// | ||||||
| /// If each language representation of the API has their own language-specific parameters, pass each language representation's parameter information. | ||||||
| /// | ||||||
| /// If the API has the _same_ parameters in all language representations, only pass the parameters for one language. | ||||||
| /// This produces a "parameters" section that doesn't hide any parameters for any of the languages (same as if the symbol only had one language representation) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| func groupedSection(named sectionName: String, groups taskGroups: [SourceLanguage: [TaskGroupInfo]]) -> [XMLNode] { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comments above are talking about a parameters section. Should we update the comments to describe the task group section this function returns? |
||||||
| let taskGroups = RenderHelpers.sortedLanguageSpecificValues(taskGroups) | ||||||
|
|
||||||
| let items: [XMLElement] = if taskGroups.count == 1 { | ||||||
| taskGroups.first!.value.flatMap { taskGroup in | ||||||
| _singleTaskGroupElements(for: taskGroup) | ||||||
| } | ||||||
| } else { | ||||||
| // TODO: As a future improvement we could diff the references and only mark them as language-specific if the group and reference doesn't appear in all languages. | ||||||
| taskGroups.flatMap { language, taskGroups in | ||||||
| let attribute = XMLNode.attribute(withName: "class", stringValue: "\(language.id)-only") as! XMLNode | ||||||
|
|
||||||
| let elements = taskGroups.flatMap { _singleTaskGroupElements(for: $0) } | ||||||
| for element in elements { | ||||||
| element.addAttribute(attribute) | ||||||
| } | ||||||
| return elements | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| return selfReferencingSection(named: sectionName, content: items) | ||||||
| } | ||||||
|
|
||||||
| private func _singleTaskGroupElements(for taskGroup: TaskGroupInfo) -> [XMLElement] { | ||||||
| let listItems = taskGroup.references.compactMap { reference in | ||||||
| linkProvider.element(for: reference).map { _taskGroupItem(for: $0) } | ||||||
| } | ||||||
| // Don't return a title or abstract/discussion if this group has no links to display. | ||||||
| guard !listItems.isEmpty else { return [] } | ||||||
|
|
||||||
| var items: [XMLElement] = [] | ||||||
| // Title | ||||||
| if let title = taskGroup.title { | ||||||
| items.append(selfReferencingHeading(level: 3, content: [.text(title)], plainTextTitle: title)) | ||||||
| } | ||||||
| // Abstract/Discussion | ||||||
| for markup in taskGroup.content { | ||||||
| let rendered = visit(markup) | ||||||
| if let element = rendered as? XMLElement { | ||||||
| items.append(element) | ||||||
| } else { | ||||||
| // Wrap any inline content in an element. This is not expected to happen in practice | ||||||
| items.append(.element(named: "p", children: [rendered])) | ||||||
| } | ||||||
| } | ||||||
| // Links | ||||||
| items.append(.element(named: "ul", children: listItems)) | ||||||
|
|
||||||
| return items | ||||||
| } | ||||||
|
|
||||||
| private func _taskGroupItem(for element: LinkedElement) -> XMLElement { | ||||||
| var items: [XMLNode] | ||||||
| switch element.subheadings { | ||||||
| case .single(.conceptual(let title)): | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice more nested pattern matching :) |
||||||
| items = [.element(named: "p", children: [.text(title)])] | ||||||
|
|
||||||
| case .single(.symbol(let fragments)): | ||||||
| items = switch goal { | ||||||
| case .conciseness: | ||||||
| [ .element(named: "code", children: [.text(fragments.map(\.text).joined())]) ] | ||||||
| case .richness: | ||||||
| [ _symbolSubheading(fragments, languageFilter: nil) ] | ||||||
| } | ||||||
|
|
||||||
| case .languageSpecificSymbol(let fragmentsByLanguage): | ||||||
| let fragmentsByLanguage = RenderHelpers.sortedLanguageSpecificValues(fragmentsByLanguage) | ||||||
| items = if fragmentsByLanguage.count == 1 { | ||||||
| [ _symbolSubheading(fragmentsByLanguage.first!.value, languageFilter: nil) ] | ||||||
| } else if goal == .conciseness, let fragments = fragmentsByLanguage.first?.value { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we ignoring the second (and subsequent languages) here for concise mode? Please explain why with a comment. |
||||||
| [ _symbolSubheading(fragments, languageFilter: nil) ] | ||||||
| } else { | ||||||
| fragmentsByLanguage.map { language, fragments in | ||||||
| _symbolSubheading(fragments, languageFilter: language) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Add the formatted abstract if the linked element has one. | ||||||
| if let abstract = element.abstract { | ||||||
| items.append(visit(abstract)) | ||||||
| } | ||||||
|
|
||||||
| return .element(named: "li", children: [ | ||||||
| // Wrap both the name and the abstract in an anchor so that the entire item is a link to that page. | ||||||
| .element(named: "a", children: items, attributes: ["href": path(to: element.path)]) | ||||||
| ]) | ||||||
| } | ||||||
|
|
||||||
| private func _symbolSubheading(_ fragments: [LinkedElement.SymbolNameFragment], languageFilter: SourceLanguage?) -> XMLElement { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An HTML example here or above might be nice; this is getting a bit complex. |
||||||
| switch goal { | ||||||
| case .richness: | ||||||
| .element( | ||||||
| named: "code", | ||||||
| children: fragments.map { | ||||||
| .element(named: "span", children: wordBreak(symbolName: $0.text), attributes: ["class": $0.kind.rawValue]) | ||||||
| }, | ||||||
| attributes: languageFilter.map { ["class": "\($0.id)-only"] } | ||||||
| ) | ||||||
| case .conciseness: | ||||||
| .element( | ||||||
| named: "code", | ||||||
| children: [.text(fragments.map(\.text).joined())], | ||||||
| attributes: languageFilter.map { ["class": "\($0.id)-only"] } | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -269,6 +269,165 @@ struct MarkdownRenderer_PageElementsTests { | |
| """) | ||
| } | ||
|
|
||
| @Test(arguments: RenderGoal.allCases, ["Topics", "See Also"]) | ||
| func testRenderSingleLanguageGroupedSectionsWithMultiLanguageLinks(goal: RenderGoal, expectedGroupTitle: String) { | ||
| let elements = [ | ||
| LinkedElement( | ||
| path: URL(string: "/documentation/ModuleName/SomeClass/index.html")!, | ||
| names: .languageSpecificSymbol([ | ||
| .swift: "SomeClass", | ||
| .objectiveC: "TLASomeClass", | ||
| ]), | ||
| subheadings: .languageSpecificSymbol([ | ||
| .swift: [ | ||
| .init(text: "class ", kind: .decorator), | ||
| .init(text: "SomeClass", kind: .identifier), | ||
| ], | ||
| .objectiveC: [ | ||
| .init(text: "class ", kind: .decorator), | ||
| .init(text: "TLASomeClass", kind: .identifier), | ||
| ], | ||
| ]), | ||
| abstract: parseMarkup(string: "Some _formatted_ description of this class").first as? Paragraph | ||
| ), | ||
| LinkedElement( | ||
| path: URL(string: "/documentation/ModuleName/SomeArticle/index.html")!, | ||
| names: .single(.conceptual("Some Article")), | ||
| subheadings: .single(.conceptual("Some Article")), | ||
| abstract: parseMarkup(string: "Some **formatted** description of this _article_.").first as? Paragraph | ||
| ), | ||
| LinkedElement( | ||
| path: URL(string: "/documentation/ModuleName/SomeClass/someMethod(with:and:)/index.html")!, | ||
| names: .languageSpecificSymbol([ | ||
| .swift: "someMethod(with:and:)", | ||
| .objectiveC: "someMethodWithFirst:andSecond:", | ||
| ]), | ||
| subheadings: .languageSpecificSymbol([ | ||
| .swift: [ | ||
| .init(text: "func ", kind: .decorator), | ||
| .init(text: "someMethod", kind: .identifier), | ||
| .init(text: "(", kind: .decorator), | ||
| .init(text: "with", kind: .identifier), | ||
| .init(text: ": Int, ", kind: .decorator), | ||
| .init(text: "and", kind: .identifier), | ||
| .init(text: ": String)", kind: .decorator), | ||
| ], | ||
| .objectiveC: [ | ||
| .init(text: "- ", kind: .decorator), | ||
| .init(text: "someMethodWithFirst:andSecond:", kind: .identifier), | ||
| ], | ||
| ]), | ||
| abstract: nil | ||
| ), | ||
| ] | ||
|
|
||
| let renderer = makeRenderer(goal: goal, elementsToReturn: elements) | ||
| let expectedSectionID = expectedGroupTitle.replacingOccurrences(of: " ", with: "-") | ||
| let groupedSection = renderer.groupedSection(named: expectedGroupTitle, groups: [ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since you've included the OCC representation with alternate parameters above, could we also include (or make a second set of asserts) that show the expected output when the language is ObjC? |
||
| .swift: [ | ||
| .init(title: "Group title", content: parseMarkup(string: "Some description of this group"), references: [ | ||
| URL(string: "/documentation/ModuleName/SomeClass/index.html")!, | ||
| URL(string: "/documentation/ModuleName/SomeArticle/index.html")!, | ||
| URL(string: "/documentation/ModuleName/SomeClass/someMethod(with:and:)/index.html")!, | ||
| ]) | ||
| ] | ||
| ]) | ||
|
|
||
| switch goal { | ||
| case .richness: | ||
| groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """ | ||
| <section id="\(expectedSectionID)"> | ||
| <h2> | ||
| <a href="#\(expectedSectionID)">\(expectedGroupTitle)</a> | ||
| </h2> | ||
| <h3 id="Group-title"> | ||
| <a href="#Group-title">Group title</a> | ||
| </h3> | ||
| <p>Some description of this group</p> | ||
| <ul> | ||
| <li> | ||
| <a href="../../someclass/index.html"> | ||
| <code class="swift-only"> | ||
| <span class="decorator">class </span> | ||
| <span class="identifier">Some<wbr/> | ||
| Class</span> | ||
| </code> | ||
| <code class="occ-only"> | ||
| <span class="decorator">class </span> | ||
| <span class="identifier">TLASome<wbr/> | ||
| Class</span> | ||
| </code> | ||
| <p>Some <i>formatted</i> | ||
| description of this class</p> | ||
| </a> | ||
| </li> | ||
| <li> | ||
| <a href="../../somearticle/index.html"> | ||
| <p>Some Article</p> | ||
| <p>Some <b>formatted</b> | ||
| description of this <i>article</i> | ||
| .</p> | ||
| </a> | ||
| </li> | ||
| <li> | ||
| <a href="../../someclass/somemethod(with:and:)/index.html"> | ||
| <code class="swift-only"> | ||
| <span class="decorator">func </span> | ||
| <span class="identifier">some<wbr/> | ||
| Method</span> | ||
| <span class="decorator">(</span> | ||
| <span class="identifier">with</span> | ||
| <span class="decorator">:<wbr/> | ||
| Int, </span> | ||
| <span class="identifier">and</span> | ||
| <span class="decorator">:<wbr/> | ||
| String)</span> | ||
| </code> | ||
| <code class="occ-only"> | ||
| <span class="decorator">- </span> | ||
| <span class="identifier">some<wbr/> | ||
| Method<wbr/> | ||
| With<wbr/> | ||
| First:<wbr/> | ||
| and<wbr/> | ||
| Second:</span> | ||
| </code> | ||
| </a> | ||
| </li> | ||
| </ul> | ||
| </section> | ||
| """) | ||
| case .conciseness: | ||
| groupedSection.assertMatches(prettyFormatted: true, expectedXMLString: """ | ||
| <h2>\(expectedGroupTitle)</h2> | ||
| <h3>Group title</h3> | ||
| <p>Some description of this group</p> | ||
| <ul> | ||
| <li> | ||
| <a href="../../someclass/index.html"> | ||
| <code>class SomeClass</code> | ||
| <p>Some <i>formatted</i> | ||
| description of this class</p> | ||
| </a> | ||
| </li> | ||
| <li> | ||
| <a href="../../somearticle/index.html"> | ||
| <p>Some Article</p> | ||
| <p>Some <b>formatted</b> | ||
| description of this <i>article</i> | ||
| .</p> | ||
| </a> | ||
| </li> | ||
| <li> | ||
| <a href="../../someclass/somemethod(with:and:)/index.html"> | ||
| <code>func someMethod(with: Int, and: String)</code> | ||
| </a> | ||
| </li> | ||
| </ul> | ||
| """) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - | ||
|
|
||
| private func makeRenderer( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And:
has their->has its