Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions Sources/CLI/client/AppleDocumentationClient+DTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,56 @@ struct DocumentationVariantDTO: Decodable, Sendable {
struct DocumentationTextDTO: Decodable, Sendable {
let code: String?
let identifier: String?
let inlineContent: [DocumentationTextDTO]?
let text: String?
}

struct DocumentationBlockDTO: Decodable, Sendable {
let inlineContent: [DocumentationTextDTO]?
enum DocumentationBlockDTO: Decodable, Sendable {
case paragraph([DocumentationTextDTO])
case heading(String)
case codeListing(code: [String], syntax: String?)
case orderedList(items: [DocumentationListItemDTO], startIndex: Int)
case unorderedList([DocumentationListItemDTO])
case aside(content: [DocumentationBlockDTO], style: String, name: String?)
case unsupported

private enum CodingKeys: CodingKey {
case code, content, inlineContent, items, name, startIndex, style, syntax, text, type
}

init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "paragraph":
self = .paragraph(try container.decode([DocumentationTextDTO].self, forKey: .inlineContent))
case "heading":
self = .heading(try container.decode(String.self, forKey: .text))
case "codeListing":
self = .codeListing(
code: try container.decode([String].self, forKey: .code),
syntax: try container.decodeIfPresent(String.self, forKey: .syntax)
)
case "orderedList":
self = .orderedList(
items: try container.decode([DocumentationListItemDTO].self, forKey: .items),
startIndex: try container.decodeIfPresent(Int.self, forKey: .startIndex) ?? 1
)
case "unorderedList":
self = .unorderedList(try container.decode([DocumentationListItemDTO].self, forKey: .items))
case "aside":
self = .aside(
content: try container.decode([DocumentationBlockDTO].self, forKey: .content),
style: try container.decode(String.self, forKey: .style),
name: try container.decodeIfPresent(String.self, forKey: .name)
)
default:
self = .unsupported
}
}
}

struct DocumentationListItemDTO: Decodable, Sendable {
let content: [DocumentationBlockDTO]
}

struct DocumentationReferenceDTO: Decodable, Sendable {
Expand All @@ -48,6 +93,7 @@ struct DocumentationReferenceSectionDTO: Decodable, Sendable {
}

struct DocumentationContentSectionDTO: Decodable, Sendable {
let content: [DocumentationBlockDTO]?
let declarations: [DocumentationDeclarationDTO]?
let kind: String
}
Expand Down
65 changes: 65 additions & 0 deletions Sources/CLI/renderer/DocumentationContentRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

struct DocumentationContentRenderer: Sendable {
let references: [String: DocumentationReferenceDTO]
private let layout = DocumentationTextLayout()

func inlineText(_ content: [DocumentationTextDTO]) -> String {
content.map { item in
if let text = item.text {
return text
}
if let code = item.code {
return "`\(code)`"
}
if let identifier = item.identifier {
return references[identifier]?.title ?? identifier
}
return inlineText(item.inlineContent ?? [])
}.joined()
}

func render(_ blocks: [DocumentationBlockDTO], indent: String = " ") -> String {
blocks.map { render($0, indent: indent) }
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
}

private func render(_ block: DocumentationBlockDTO, indent: String) -> String {
switch block {
case .paragraph(let content):
return layout.paragraph(inlineText(content), indent: indent)
case .heading(let text):
return text.isEmpty ? "" : layout.heading(text)
case .codeListing(let code, let syntax):
return layout.codeBlock(code, language: syntax, indent: indent)
case .orderedList(let items, let startIndex):
return renderList(items, startIndex: startIndex, indent: indent)
case .unorderedList(let items):
return renderList(items, indent: indent)
case .aside(let content, let style, let name):
let body = render(content, indent: indent + "│ ")
guard !body.isEmpty else { return "" }
return indent + (name ?? style.capitalized) + "\n" + body
case .unsupported:
return ""
}
}

private func renderList(
_ items: [DocumentationListItemDTO],
startIndex: Int? = nil,
indent: String
) -> String {
items.enumerated().compactMap { index, item -> String? in
let marker = startIndex.map { "\($0 + index). " } ?? "• "
let continuation = indent + String(repeating: " ", count: marker.count)
let body = render(item.content, indent: continuation)
guard !body.isEmpty else { return nil }
if body.hasPrefix(continuation) {
return indent + marker + body.dropFirst(continuation.count)
}
return indent + marker + "\n" + body
}.joined(separator: "\n")
}
}
56 changes: 56 additions & 0 deletions Sources/CLI/renderer/DocumentationTextLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
struct DocumentationTextLayout: Sendable {
private let width = 80

func heading(_ title: String, prominent: Bool = false) -> String {
title + "\n" + String(repeating: prominent ? "━" : "─", count: min(title.count, width))
}

func paragraph(_ text: String, indent: String = " ", firstPrefix: String? = nil) -> String {
var lines: [String] = []
var prefix = firstPrefix ?? indent
var line = ""
for word in text.split(whereSeparator: \.isWhitespace) {
if !line.isEmpty && prefix.count + line.count + 1 + word.count > width {
lines.append(prefix + line)
prefix = indent
line = ""
}
if !line.isEmpty {
line += " "
}
line += word
}
if !line.isEmpty {
lines.append(prefix + line)
}
return lines.joined(separator: "\n")
}

func codeBlock(_ lines: [String], language: String?, indent: String = " ") -> String {
guard !lines.isEmpty else { return "" }
let label = language.map { $0 == "swift" ? "Swift" : $0 } ?? "Code"
let top = "─ \(label) "
let width = max(top.count, (lines.map(\.count).max() ?? 0) + 2)
let rows = lines.map { line in
indent + "│ " + line + String(repeating: " ", count: width - line.count - 1) + "│"
}
return
([indent + "╭" + top + String(repeating: "─", count: width - top.count) + "╮"]
+ rows + [indent + "╰" + String(repeating: "─", count: width) + "╯"])
.joined(separator: "\n")
}

func table(_ rows: [(String, String)]) -> String {
guard !rows.isEmpty else { return "" }
let keyWidth = rows.map { $0.0.count }.max() ?? 0
let valueWidth = rows.map { $0.1.count }.max() ?? 0
let keyRule = String(repeating: "─", count: keyWidth + 2)
let valueRule = String(repeating: "─", count: valueWidth + 2)
let body = rows.map { key, value in
" │ " + key + String(repeating: " ", count: keyWidth - key.count)
+ " │ " + value + String(repeating: " ", count: valueWidth - value.count) + " │"
}
return ([" ╭\(keyRule)┬\(valueRule)╮"] + body + [" ╰\(keyRule)┴\(valueRule)╯"])
.joined(separator: "\n")
}
}
130 changes: 57 additions & 73 deletions Sources/CLI/renderer/TextTypeDocumentationRenderer.swift
Original file line number Diff line number Diff line change
@@ -1,55 +1,47 @@
struct TextTypeDocumentationRenderer: Sendable {
private let layout = DocumentationTextLayout()

func render(_ page: TypeDocumentationPageDTO) -> String {
let modules = page.metadata.modules.map(\.name).joined(separator: ", ")
var sections = ["\(page.metadata.title)\n\(page.metadata.roleHeading) · \(modules)"]
let content = DocumentationContentRenderer(references: page.references)
let metadata = ([page.metadata.roleHeading] + page.metadata.modules.map(\.name))
.filter { !$0.isEmpty }.joined(separator: " · ")
var sections = [layout.heading(page.metadata.title, prominent: true) + "\n" + metadata]

let abstract = page.abstract.compactMap(\.text).joined()
let abstract = content.inlineText(page.abstract)
if !abstract.isEmpty {
sections.append(abstract)
sections.append(layout.paragraph(abstract))
}

let deprecation =
page.deprecationSummary?
.compactMap(\.inlineContent)
.map { inlineText($0, references: page.references) }
.joined(separator: "\n") ?? ""
let deprecation = content.render(page.deprecationSummary ?? [])
if !deprecation.isEmpty {
sections.append("Deprecated\n\n" + deprecation)
}

let declarations = swiftDeclarations(in: page)

if !declarations.isEmpty {
sections.append("Declaration\n\n" + declarations.joined(separator: "\n"))
sections.append(layout.heading("Deprecated") + "\n" + deprecation)
}

let availability = page.metadata.platforms.map { platform in
" \(platform.name) \(availabilityRange(for: platform))"
(platform.name, availabilityRange(for: platform))
}
if !availability.isEmpty {
sections.append("Availability\n\n" + availability.joined(separator: "\n"))
sections.append(layout.heading("Availability") + "\n" + layout.table(availability))
}

appendReferenceSections(
page.relationshipsSections ?? [],
references: page.references,
to: &sections
)
appendReferenceSections(
page.topicSections ?? [],
references: page.references,
includesAbstract: true,
to: &sections
)
appendReferenceSections(
page.seeAlsoSections ?? [],
references: page.references,
titlePrefix: "See Also: ",
to: &sections
let declarations = swiftDeclarations(in: page)
if !declarations.isEmpty {
sections.append(layout.heading("Declaration") + "\n" + declarations.joined(separator: "\n\n"))
}

let overview = content.render(
page.primaryContentSections.filter { $0.kind == "content" }.flatMap { $0.content ?? [] }
)
if !overview.isEmpty {
sections.append(overview)
}

sections += referenceSections(page.relationshipsSections ?? [], content: content)
appendGroup("Topics", page.topicSections ?? [], content: content, includesAbstract: true, to: &sections)
appendGroup("See Also", page.seeAlsoSections ?? [], content: content, to: &sections)

if let path = page.variants?.lazy.flatMap(\.paths).first {
sections.append("https://developer.apple.com\(path)")
sections.append(layout.heading("Documentation") + "\n " + documentationURL(for: path))
}

return sections.joined(separator: "\n\n")
Expand All @@ -60,41 +52,49 @@ struct TextTypeDocumentationRenderer: Sendable {
.filter { $0.kind == "declarations" }
.flatMap { $0.declarations ?? [] }
.filter { $0.languages.contains("swift") }
.map { " " + $0.tokens.map(\.text).joined() }
.map {
let lines = $0.tokens.map(\.text).joined().split(separator: "\n", omittingEmptySubsequences: false)
return layout.codeBlock(lines.map(String.init), language: "swift")
}
}

private func appendReferenceSections(
_ referenceSections: [DocumentationReferenceSectionDTO],
references: [String: DocumentationReferenceDTO],
titlePrefix: String = "",
private func appendGroup(
_ title: String,
_ groups: [DocumentationReferenceSectionDTO],
content: DocumentationContentRenderer,
includesAbstract: Bool = false,
to sections: inout [String]
) {
for referenceSection in referenceSections {
let items = referenceSection.identifiers.compactMap { identifier -> String? in
guard let reference = references[identifier], let title = reference.title else {
let rendered = referenceSections(groups, content: content, includesAbstract: includesAbstract)
if !rendered.isEmpty {
sections.append(layout.heading(title, prominent: true))
sections += rendered
}
}

private func referenceSections(
_ groups: [DocumentationReferenceSectionDTO],
content: DocumentationContentRenderer,
includesAbstract: Bool = false
) -> [String] {
groups.compactMap { group in
let items = group.identifiers.compactMap { identifier -> String? in
guard let reference = content.references[identifier], let title = reference.title else {
return nil
}

var item = " \(title)"
let abstract =
reference.abstract.map {
inlineText($0, references: references)
} ?? ""
var item = layout.paragraph(title, indent: " ", firstPrefix: " • ")
let abstract = content.inlineText(reference.abstract ?? [])
if includesAbstract && !abstract.isEmpty {
item += " — \(abstract)"
item += "\n" + layout.paragraph(abstract, indent: " ")
}
if let url = reference.url {
item += "\n \(documentationURL(for: url))"
// Keep URLs intact so terminal link detection and copy/paste still work.
item += "\n " + documentationURL(for: url)
}
return item
}

if !items.isEmpty {
sections.append(
titlePrefix + referenceSection.title + "\n\n" + items.joined(separator: "\n")
)
}
guard !items.isEmpty else { return nil }
return layout.heading(group.title) + "\n" + items.joined(separator: "\n\n")
}
}

Expand All @@ -117,20 +117,4 @@ struct TextTypeDocumentationRenderer: Sendable {
}
return "https://developer.apple.com\(path)"
}

private func inlineText(
_ content: [DocumentationTextDTO],
references: [String: DocumentationReferenceDTO]
) -> String {
content.map { item in
if let text = item.text {
return text
}
if let identifier = item.identifier {
return references[identifier]?.title ?? identifier
}
// DocC encodes inline symbol spelling such as AppIntent as codeVoice, not text.
return item.code ?? ""
}.joined()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ struct AppleDocsCommandIntegrationTests {
let output = try runAppleDocs(arguments)

// -- Assert --
#expect(output.hasPrefix("String\nStructure · Swift\n"))
#expect(output.contains("Declaration\n\n @frozen struct String"))
#expect(output.hasPrefix("String\n━━━━━━\nStructure · Swift\n"))
#expect(output.contains("Declaration\n───────────"))
#expect(output.contains("│ @frozen struct String"))
#expect(output.contains("Overview\n────────"))
}

@Test("lists MetricKit root types as JSON")
Expand Down
Loading
Loading