Skip to content

Commit 0a38abd

Browse files
Add @PageImage metadata directive (#367)
Adds a new `@PageImage` directive that can be placed inside a page’s `@Metadata` directive block to allow for customizing the images that will be used to represent the page in the navigator, hero, and elsewhere in the UI. For article-only catalogs it’s likely helpful to distinguish between articles with custom icons- in the same way Swift-DocC has different icons for different kinds of symbol pages. `@PageImage` accepts the following parameters: - purpose: The purpose of the image. One of the following: - icon: This image should be used in place of wherever a generic icon for the page would usually be rendered. - card: This image should be used whenever rendering a card representing this page. - source: A string containing the name of an image in your DocC catalog. - alt: (optional) A string containing a description of the image. Example: # Feeding a Sloth @metadata { @PageImage(purpose: icon, source: "leaf.svg", alt: "An icon representing a leaf.") } `@PageImage` is described on the Swift forums here: https://forums.swift.org/t/supporting-more-types-of-documentation-with-swift-docc/59725#pageimage-22 Dependencies: - swiftlang/swift-docc-render#417 Resolves rdar://97739580
1 parent 165309d commit 0a38abd

30 files changed

+866
-39
lines changed

Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,14 @@ extension NavigatorIndex {
617617
/// Indicates if the page title should be used instead of the navigator title.
618618
private let usePageTitle: Bool
619619

620+
621+
/// Maps the icon render references in the navigator items created by this builder
622+
/// to their image references.
623+
///
624+
/// Use the `NavigatorItem.icon` render reference to look up the full image reference
625+
/// for any custom icons used in this navigator index.
626+
var iconReferences = [String : ImageReference]()
627+
620628
/**
621629
Initialize a `Builder` with the given data provider and output URL.
622630
- Parameters:
@@ -753,11 +761,21 @@ extension NavigatorIndex {
753761
}
754762
}
755763

756-
let navigationItem = NavigatorItem(pageType: renderNode.navigatorPageType().rawValue,
757-
languageID: language.mask,
758-
title: title,
759-
platformMask: platformID,
760-
availabilityID: UInt64(availabilityID))
764+
765+
if let icon = renderNode.icon,
766+
let iconRenderReference = renderNode.references[icon.identifier] as? ImageReference
767+
{
768+
iconReferences[icon.identifier] = iconRenderReference
769+
}
770+
771+
let navigationItem = NavigatorItem(
772+
pageType: renderNode.navigatorPageType().rawValue,
773+
languageID: language.mask,
774+
title: title,
775+
platformMask: platformID,
776+
availabilityID: UInt64(availabilityID),
777+
icon: renderNode.icon
778+
)
761779
navigationItem.path = identifierPath
762780

763781
// Index the USR for the given identifier

Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
4747
/// If available, a hashed USR of this entry and its language information.
4848
internal var usrIdentifier: String? = nil
4949

50+
var icon: RenderReferenceIdentifier? = nil
51+
5052
/**
5153
Initialize a `NavigatorItem` with the given data.
5254

@@ -58,13 +60,14 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
5860
- availabilityID: The identifier of the availability information of the page.
5961
- path: The path to load the content.
6062
*/
61-
init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String) {
63+
init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil) {
6264
self.pageType = pageType
6365
self.languageID = languageID
6466
self.title = title
6567
self.platformMask = platformMask
6668
self.availabilityID = availabilityID
6769
self.path = path
70+
self.icon = icon
6871
}
6972

7073
/**
@@ -77,12 +80,13 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
7780
- platformMask: The mask indicating for which platform the page is available.
7881
- availabilityID: The identifier of the availability information of the page.
7982
*/
80-
public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64) {
83+
public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil) {
8184
self.pageType = pageType
8285
self.languageID = languageID
8386
self.title = title
8487
self.platformMask = platformMask
8588
self.availabilityID = availabilityID
89+
self.icon = icon
8690
}
8791

8892
// MARK: - Serialization and Deserialization

Sources/SwiftDocC/Indexing/IndexJSON/Index.swift renamed to Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,45 @@ import SymbolKit
2323
/// `Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderIndex.spec.json`.
2424
public struct RenderIndex: Codable, Equatable {
2525
/// The current schema version of the Index JSON spec.
26-
public static let currentSchemaVersion = SemanticVersion(major: 0, minor: 1, patch: 0)
26+
public static let currentSchemaVersion = SemanticVersion(major: 0, minor: 1, patch: 1)
2727

2828
/// The version of the RenderIndex spec that was followed when creating this index.
2929
public let schemaVersion: SemanticVersion
3030

3131
/// A mapping of interface languages to the index nodes they contain.
3232
public let interfaceLanguages: [String: [Node]]
3333

34+
/// The values of the image references used in the documentation index.
35+
public private(set) var references: [String: ImageReference]
36+
37+
enum CodingKeys: CodingKey {
38+
case schemaVersion
39+
case interfaceLanguages
40+
case references
41+
}
42+
3443
/// Creates a new render index with the given interface language to node mapping.
3544
public init(
36-
interfaceLanguages: [String: [Node]]
45+
interfaceLanguages: [String: [Node]],
46+
references: [String: ImageReference] = [:]
3747
) {
3848
self.schemaVersion = Self.currentSchemaVersion
3949
self.interfaceLanguages = interfaceLanguages
50+
self.references = references
51+
}
52+
53+
public func encode(to encoder: Encoder) throws {
54+
var container = encoder.container(keyedBy: CodingKeys.self)
55+
try container.encode(self.schemaVersion, forKey: .schemaVersion)
56+
try container.encode(self.interfaceLanguages, forKey: .interfaceLanguages)
57+
try container.encodeIfNotEmpty(self.references, forKey: .references)
58+
}
59+
60+
public init(from decoder: Decoder) throws {
61+
let container = try decoder.container(keyedBy: CodingKeys.self)
62+
self.schemaVersion = try container.decode(SemanticVersion.self, forKey: .schemaVersion)
63+
self.interfaceLanguages = try container.decode([String : [RenderIndex.Node]].self, forKey: .interfaceLanguages)
64+
self.references = try container.decodeIfPresent([String : ImageReference].self, forKey: .references) ?? [:]
4065
}
4166
}
4267

@@ -73,6 +98,9 @@ extension RenderIndex {
7398
///
7499
/// Allows renderers to use a specific design treatment for render index nodes that mark the node as in beta.
75100
public let isBeta: Bool
101+
102+
/// Reference to the image that should be used to represent this node.
103+
public let icon: RenderReferenceIdentifier?
76104

77105
enum CodingKeys: String, CodingKey {
78106
case title
@@ -82,6 +110,7 @@ extension RenderIndex {
82110
case deprecated
83111
case external
84112
case beta
113+
case icon
85114
}
86115

87116
public func encode(to encoder: Encoder) throws {
@@ -107,6 +136,8 @@ extension RenderIndex {
107136
if isBeta {
108137
try container.encode(isBeta, forKey: .beta)
109138
}
139+
140+
try container.encodeIfPresent(icon, forKey: .icon)
110141
}
111142

112143
public init(from decoder: Decoder) throws {
@@ -126,6 +157,8 @@ extension RenderIndex {
126157

127158
// `isBeta` defaults to false if it's not specified
128159
isBeta = try values.decodeIfPresent(Bool.self, forKey: .beta) ?? false
160+
161+
icon = try values.decodeIfPresent(RenderReferenceIdentifier.self, forKey: .icon)
129162
}
130163

131164
/// Creates a new node with the given title, path, type, and children.
@@ -146,7 +179,8 @@ extension RenderIndex {
146179
children: [Node]?,
147180
isDeprecated: Bool,
148181
isExternal: Bool,
149-
isBeta: Bool
182+
isBeta: Bool,
183+
icon: RenderReferenceIdentifier? = nil
150184
) {
151185
self.title = title
152186
self.path = path
@@ -155,14 +189,16 @@ extension RenderIndex {
155189
self.isDeprecated = isDeprecated
156190
self.isExternal = isExternal
157191
self.isBeta = isBeta
192+
self.icon = nil
158193
}
159194

160195
init(
161196
title: String,
162197
path: String,
163198
pageType: NavigatorIndex.PageType?,
164199
isDeprecated: Bool,
165-
children: [Node]
200+
children: [Node],
201+
icon: RenderReferenceIdentifier?
166202
) {
167203
self.title = title
168204
self.children = children.isEmpty ? nil : children
@@ -174,6 +210,7 @@ extension RenderIndex {
174210
self.isExternal = false
175211

176212
self.isBeta = false
213+
self.icon = icon
177214

178215
guard let pageType = pageType else {
179216
self.type = nil
@@ -216,7 +253,8 @@ extension RenderIndex {
216253
)
217254
},
218255
uniquingKeysWith: +
219-
)
256+
),
257+
references: builder.iconReferences
220258
)
221259
}
222260
}
@@ -242,7 +280,8 @@ extension RenderIndex.Node {
242280
isDeprecated: isDeprecated,
243281
children: node.children.map {
244282
RenderIndex.Node.fromNavigatorTreeNode($0, in: navigatorIndex, with: builder)
245-
}
283+
},
284+
icon: node.item.icon
246285
)
247286
}
248287
}

Sources/SwiftDocC/Infrastructure/Bundle Assets/DataAssetManager.swift

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,11 @@ struct DataAssetManager {
7171
try! NSRegularExpression(pattern: "(?!^)(?<=@)[1|2|3]x(?=\\.\\w*$)")
7272
}()
7373

74-
private mutating func referenceMetaInformationForDataURL(_ dataURL: URL, dataProvider: DocumentationContextDataProvider? = nil, bundle documentationBundle: DocumentationBundle? = nil) throws -> (reference: String, traits: DataTraitCollection) {
74+
private mutating func referenceMetaInformationForDataURL(_ dataURL: URL, dataProvider: DocumentationContextDataProvider? = nil, bundle documentationBundle: DocumentationBundle? = nil) throws -> (reference: String, traits: DataTraitCollection, metadata: DataAsset.Metadata) {
7575
var dataReference = dataURL.path
7676
var traitCollection = DataTraitCollection()
7777

78+
var metadata = DataAsset.Metadata()
7879
if DocumentationContext.isFileExtension(dataURL.pathExtension, supported: .video) {
7980
// In case of a video read its traits: dark/light variants.
8081

@@ -103,9 +104,13 @@ struct DataAssetManager {
103104
// Remove the display scale information from the image reference.
104105
dataReference = dataReference.replacingOccurrences(of: "@\(displayScale.rawValue)", with: "")
105106
traitCollection = .init(userInterfaceStyle: userInterfaceStyle, displayScale: displayScale)
107+
108+
if dataURL.pathExtension.lowercased() == "svg" {
109+
metadata.svgID = SVGIDExtractor.extractID(from: dataURL)
110+
}
106111
}
107112

108-
return (reference: dataReference, traits: traitCollection)
113+
return (reference: dataReference, traits: traitCollection, metadata: metadata)
109114
}
110115

111116
/**
@@ -123,7 +128,7 @@ struct DataAssetManager {
123128
// Store the image with given scale information and display scale.
124129
let name = referenceURL.lastPathComponent
125130
storage[name, default: DataAsset()]
126-
.register(dataURL, with: meta.traits)
131+
.register(dataURL, with: meta.traits, metadata: meta.metadata)
127132

128133
if name.contains(".") {
129134
let nameNoExtension = referenceURL.deletingPathExtension().lastPathComponent
@@ -156,7 +161,7 @@ struct DataAssetManager {
156161
/// ### Asset Traits
157162
/// - ``DisplayScale``
158163
/// - ``UserInterfaceStyle``
159-
public struct DataAsset: Codable {
164+
public struct DataAsset: Codable, Equatable {
160165
/// A context in which you intend clients to use a data asset.
161166
public enum Context: String, CaseIterable, Codable {
162167
/// An asset that a user intends to view alongside documentation content.
@@ -172,18 +177,32 @@ public struct DataAsset: Codable {
172177
/// depending on the system's appearance.
173178
public var variants = [DataTraitCollection: URL]()
174179

180+
/// The metadata associated with each variant.
181+
public var metadata = [URL : Metadata]()
182+
175183
/// The context in which you intend to use the data asset.
176184
public var context = Context.display
177185

178186
/// Creates an empty asset.
179187
public init() {}
180188

189+
init(
190+
variants: [DataTraitCollection : URL] = [DataTraitCollection: URL](),
191+
metadata: [URL : DataAsset.Metadata] = [URL : Metadata](),
192+
context: DataAsset.Context = Context.display
193+
) {
194+
self.variants = variants
195+
self.metadata = metadata
196+
self.context = context
197+
}
198+
181199
/// Registers a variant of the asset.
182200
/// - Parameters:
183201
/// - url: The location of the variant.
184202
/// - traitCollection: The trait collection associated with the variant.
185-
public mutating func register(_ url: URL, with traitCollection: DataTraitCollection) {
203+
public mutating func register(_ url: URL, with traitCollection: DataTraitCollection, metadata: Metadata = Metadata()) {
186204
variants[traitCollection] = url
205+
self.metadata[url] = metadata
187206
}
188207

189208
/// Returns the data that is registered to the data asset that best matches the given trait collection.
@@ -210,6 +229,21 @@ public struct DataAsset: Codable {
210229

211230
}
212231

232+
extension DataAsset {
233+
/// Metadata specific to this data asset.
234+
public struct Metadata: Codable, Equatable {
235+
/// The first ID found in the SVG asset.
236+
///
237+
/// This value is nil if the data asset is not an SVG or if it is an SVG that does not contain an ID.
238+
public var svgID: String?
239+
240+
/// Create a new data asset metadata with the given SVG ID.
241+
public init(svgID: String? = nil) {
242+
self.svgID = svgID
243+
}
244+
}
245+
}
246+
213247
/// A collection of environment traits for an asset variant.
214248
///
215249
/// Traits describe properties of a rendering environment, such as a user-interface style (light or dark mode) and display-scale
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
13+
// On non-Darwin platforms, Foundation's XML support is vended as a separate module:
14+
// https://github.com/apple/swift-corelibs-foundation/blob/main/Docs/ReleaseNotes_Swift5.md#dependency-management
15+
#if canImport(FoundationXML)
16+
import FoundationXML
17+
#endif
18+
19+
/// A basic XML parser that extracts the first `id` attribute found in the given SVG.
20+
///
21+
/// This is a single-purpose tool and should not be used for general-purpose SVG parsing.
22+
enum SVGIDExtractor {
23+
/// Extracts an SVG ID from the given data.
24+
///
25+
/// Exposed for testing. The sibling `extractID(from: URL)` method is intended to be
26+
/// used within SwiftDocC.
27+
static func _extractID(from data: Data) -> String? {
28+
let delegate = SVGIDParserDelegate()
29+
let svgParser = XMLParser(data: data)
30+
svgParser.delegate = delegate
31+
svgParser.parse()
32+
33+
return delegate.id
34+
}
35+
36+
/// Returns the first `id` attribute found in the given SVG, if any.
37+
///
38+
/// Returns nil if any errors are encountered or if an `id` attribute is
39+
/// not found in the given SVG.
40+
static func extractID(from svg: URL) -> String? {
41+
guard let data = try? Data(contentsOf: svg) else {
42+
return nil
43+
}
44+
45+
return _extractID(from: data)
46+
}
47+
}
48+
49+
private class SVGIDParserDelegate: NSObject, XMLParserDelegate {
50+
var id: String?
51+
52+
func parser(
53+
_ parser: XMLParser,
54+
didStartElement elementName: String,
55+
namespaceURI: String?,
56+
qualifiedName qName: String?,
57+
attributes attributeDict: [String : String] = [:]
58+
) {
59+
guard let id = attributeDict["id"] ?? attributeDict["ID"] ?? attributeDict["iD"] ?? attributeDict["Id"] else {
60+
return
61+
}
62+
63+
self.id = id
64+
parser.abortParsing()
65+
}
66+
}

0 commit comments

Comments
 (0)