-
Notifications
You must be signed in to change notification settings - Fork 167
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
Changes from all commits
5bec535
4925096
2931e98
ce7a659
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,168 @@ | ||
| /* | ||
| 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 its own task groups, pass the task groups for each language representation. | ||
| /// | ||
| /// If the API has the _same_ task groups in all language representations, only pass the task groups for one language. | ||
| /// This produces a named section that doesn't hide any task groups for any of the languages (the same as if the symbol only had one language representation). | ||
| func groupedSection(named sectionName: String, groups taskGroups: [SourceLanguage: [TaskGroupInfo]]) -> [XMLNode] { | ||
| 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)): | ||
d-ronnqvist marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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.
Contributor
Author
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. I added a short comment that describes this in 2931e98 |
||
| // On the rendered page, language specific symbol names _could_ be hidden through CSS but that wouldn't help the tool that reads the raw HTML. | ||
| // So that tools don't need to filter out language specific names themselves, include only the primary language's subheading. | ||
| [ _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)]) | ||
| ]) | ||
| } | ||
|
|
||
| /// Transforms the symbol name fragments into a `<code>` HTML element that represents a symbol's subheading. | ||
| /// | ||
| /// When the renderer has a ``RenderGoal/richness`` goal, it creates one `<span>` HTML element per fragment that could be styled differently through CSS: | ||
| /// ``` | ||
| /// <code class="swift-only"> | ||
| /// <span class="decorator">class </span> | ||
| /// <span class="identifier">Some<wbr/>Class</span> | ||
| /// </code> | ||
| /// ``` | ||
| /// | ||
| /// When the renderer has a ``RenderGoal/conciseness`` goal, it joins the fragment's text into a single string: | ||
| /// ``` | ||
| /// <code>class SomeClass</code> | ||
| /// ``` | ||
| 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.
Contributor
Author
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. I added two examples of this in 2931e98 |
||
| 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 |
|---|---|---|
|
|
@@ -42,7 +42,7 @@ struct MarkdownRenderer_PageElementsTests { | |
| .init(text: "Something", kind: .identifier), | ||
| ], | ||
| .objectiveC: [ | ||
| .init(text: "class ", kind: .decorator), | ||
| .init(text: "@interface ", kind: .decorator), | ||
| .init(text: "TLASomething", kind: .identifier), | ||
| ], | ||
| ]), | ||
|
|
@@ -482,6 +482,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: "@interface ", 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?
Contributor
Author
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. What language is specified here doesn't impact the output. This is testing the same task groups in all languages but the names/subheadings of referenced symbols have difference names in each language. You can see in the "rich" test assertion that the the output contains both titles as |
||
| .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">@interface </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.
The comments above are talking about a parameters section. Should we update the comments to describe the task group section this function returns?
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.
Good catch. I updated these comments in 2931e98