1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
|
/*
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 rewriter that extracts topic links for unordered list items.
*/
struct ExtractLinks: MarkupRewriter {
enum Mode {
case linksDirective
case taskGroup
}
var links = [AnyLink]()
var problems = [Problem]()
var mode = Mode.taskGroup
/// Creates a warning with a suggestion to remove all paragraph elements but the first.
private func problemForTrailingContent(_ paragraph: Paragraph) -> Problem {
let range = paragraph.range ?? paragraph.firstChildRange()
// An unexpected non-link list item found, suggest to remove it
let trailingContent = Document(Paragraph(paragraph.inlineChildren.dropFirst()))
let replacements = trailingContent.children.range.map({ [Replacement(range: $0, replacement: "")] }) ?? []
let diagnostic: Diagnostic
switch mode {
case .taskGroup:
diagnostic = Diagnostic(
source: range?.source,
severity: .warning,
range: range,
identifier: "org.swift.docc.ExtraneousTaskGroupItemContent",
summary: "Extraneous content found after a link in task group list item"
)
case .linksDirective:
diagnostic = Diagnostic(
source: range?.source,
severity: .warning,
range: range,
identifier: "org.swift.docc.ExtraneousLinksDirectiveItemContent",
summary: "Extraneous content found after a link",
explanation: "\(Links.directiveName.singleQuoted) can only contain a bulleted list of documentation links"
)
}
return .init(diagnostic: diagnostic, possibleSolutions: [
Solution(summary: "Remove extraneous content", replacements: replacements)
])
}
private func problemForNonLinkContent(_ item: ListItem) -> Problem {
let range = item.range ?? item.firstChildRange()
let replacements = range.map({ [Replacement(range: $0, replacement: "")] }) ?? []
let diagnostic: Diagnostic
switch mode {
case .taskGroup:
diagnostic = Diagnostic(
source: range?.source,
severity: .warning,
range: range,
identifier: "org.swift.docc.UnexpectedTaskGroupItem",
summary: "Only links are allowed in task group list items"
)
case .linksDirective:
diagnostic = Diagnostic(
source: range?.source,
severity: .warning,
range: range,
identifier: "org.swift.docc.UnexpectedLinksDirectiveListItem",
summary: "Only documentation links are allowed in \(Links.directiveName.singleQuoted) list items"
)
}
return .init(diagnostic: diagnostic, possibleSolutions: [
Solution(summary: "Remove non-link item", replacements: replacements)
])
}
mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Markup? {
let remainingItems = unorderedList.children.map { $0 as! ListItem }
.filter { item -> Bool in
guard item.childCount == 1 else { return true }
guard let paragraph = item.child(at: 0) as? Paragraph,
paragraph.childCount >= 1 else { return true }
// Check for trailing invalid content.
let containsInvalidContent = paragraph.children.dropFirst().contains { child in
let isComment = child is InlineHTML
var isSpace = false
if let text = child as? Text {
isSpace = text.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
return !(isComment || isSpace)
}
switch paragraph.child(at: 0) {
case let link as Link:
// Topic link
guard let url = link.destination.flatMap(URL.init(string:)) else {
return true
}
switch mode {
case .linksDirective:
// The 'Links' directive only supports `doc:` links.
guard ResolvedTopicReference.urlHasResolvedTopicScheme(url) else {
problems.append(problemForNonLinkContent(item))
return true
}
case .taskGroup:
guard let scheme = url.scheme,
TaskGroup.allowedSchemes.contains(scheme) else { return true }
}
links.append(link)
// Warn if there is a trailing content after the link
if containsInvalidContent {
problems.append(problemForTrailingContent(paragraph))
}
return false
case let link as SymbolLink:
// Symbol link
links.append(link)
// Warn if there is a trailing content after the link
if containsInvalidContent {
problems.append(problemForTrailingContent(paragraph))
}
return false
default:
problems.append(problemForNonLinkContent(item))
return true
}
}
guard !remainingItems.isEmpty else {
return nil
}
return UnorderedList(remainingItems)
}
}
/**
A collection of curated child topics.
*/
public struct TaskGroup {
/// The schemes for links to external content supported in task groups.
static let allowedExternalSchemes = ["http", "https"]
/// The schemes for links that is supported in task groups.
static let allowedSchemes = allowedExternalSchemes + [ResolvedTopicReference.urlScheme]
/// The title heading of the group.
public var heading: Heading?
/// The group's original contents, excluding its delimiting heading.
public var originalContent: [Markup]
/// The group's remaining content after stripping topic links.
public var content: [Markup] {
var extractor = ExtractLinks()
return originalContent.compactMap {
extractor.visit($0)
}
}
/**
The curated child topic links in this group.
- Note: Links must be at the top level and have the `doc:` URL scheme.
*/
public var links: [AnyLink] {
var extractor = ExtractLinks()
for child in originalContent {
_ = extractor.visit(child)
}
return extractor.links
}
/// An optional abstract for the task group.
public var abstract: AbstractSection? {
if let firstParagraph = originalContent.mapFirst(where: { $0 as? Paragraph }) {
return AbstractSection(paragraph: firstParagraph)
}
return nil
}
/// An optional discussion section for the task group.
public var discussion: DiscussionSection? {
guard originalContent.count > 1 else {
// There must be more than 1 element to contain both a discussion and links list
return nil
}
var discussionChildren = originalContent
.prefix(while: { !($0 is UnorderedList) })
.filter({ !($0 is BlockDirective) })
// Drop the abstract
if discussionChildren.first is Paragraph {
discussionChildren.removeFirst()
}
guard !discussionChildren.isEmpty else { return nil }
return DiscussionSection(content: Array(discussionChildren))
}
/// Creates a new task group with a given heading and content.
/// - Parameters:
/// - heading: The heading for this task group.
/// - content: The content, excluding the title, for this task group.
public init(heading: Heading?, content: [Markup]) {
self.heading = heading
self.originalContent = content
}
var directives: [String: [BlockDirective]] {
.init(grouping: originalContent.compactMap { $0 as? BlockDirective }, by: \.name)
}
}
extension TaskGroup {
/// Validates the task group links markdown and return the problems, if any.
func problemsForGroupLinks() -> [Problem] {
var extractor = ExtractLinks()
for child in originalContent {
_ = extractor.visit(child)
}
return extractor.problems
}
}
|