File: Identifier.swift

package info (click to toggle)
swiftlang 6.0.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,519,992 kB
  • sloc: cpp: 9,107,863; ansic: 2,040,022; asm: 1,135,751; python: 296,500; objc: 82,456; f90: 60,502; lisp: 34,951; pascal: 19,946; sh: 18,133; perl: 7,482; ml: 4,937; javascript: 4,117; makefile: 3,840; awk: 3,535; xml: 914; fortran: 619; cs: 573; ruby: 573
file content (647 lines) | stat: -rw-r--r-- 26,987 bytes parent folder | download
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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
/*
 This source file is part of the Swift.org open source project

 Copyright (c) 2021-2023 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 SymbolKit
import Markdown

/// A resolved or unresolved reference to a piece of documentation.
///
/// A reference can exist in one of three states:
///  - It has not yet been resolved.
///  - It has successfully resolved.
///  - It has failed to resolve.
///
/// References that have resolved, either successfully or not, are represented by ``TopicReferenceResolutionResult``.
///
/// ## Topics
/// ### Topic References
///
/// - ``UnresolvedTopicReference``
/// - ``ResolvedTopicReference``
/// - ``TopicReferenceResolutionResult``
/// - ``SourceLanguage``
public enum TopicReference: Hashable, CustomStringConvertible {
    /// A topic reference that hasn't been resolved to known documentation.
    case unresolved(UnresolvedTopicReference)
    
    /// A topic reference that has either been resolved to known documentation or failed to resolve to known documentation.
    case resolved(TopicReferenceResolutionResult)
    
    /// A topic reference that has successfully been resolved to known documentation.
    internal static func successfullyResolved(_ reference: ResolvedTopicReference) -> TopicReference {
        return .resolved(.success(reference))
    }
    
    public var description: String {
        switch self {
        case .unresolved(let unresolved):
            return unresolved.description
        case .resolved(let resolved):
            return resolved.description
        }
    }
}

/// A topic reference that has been resolved, either successfully or not.
public enum TopicReferenceResolutionResult: Hashable, CustomStringConvertible {
    /// A topic reference that has successfully been resolved to known documentation.
    case success(ResolvedTopicReference)
    /// A topic reference that has failed to resolve to known documentation and an error message with information about why the reference failed to resolve.
    case failure(UnresolvedTopicReference, TopicReferenceResolutionErrorInfo)
    
    public var description: String {
        switch self {
        case .success(let resolved):
            return resolved.description
        case .failure(let unresolved, _):
            return unresolved.description
        }
    }
}

/// The error causing the failure in the resolution of a ``TopicReference``.
public struct TopicReferenceResolutionErrorInfo: Hashable {
    public var message: String
    public var note: String?
    public var solutions: [Solution]
    public var rangeAdjustment: SourceRange?
    
    public init(
        _ message: String,
        note: String? = nil,
        solutions: [Solution] = [],
        rangeAdjustment: SourceRange? = nil
    ) {
        self.message = message
        self.note = note
        self.solutions = solutions
        self.rangeAdjustment = rangeAdjustment
    }
}

extension TopicReferenceResolutionErrorInfo {
    init(_ error: Error, solutions: [Solution] = []) {
        if let describedError = error as? DescribedError {
            self.message = describedError.errorDescription
            self.note = describedError.recoverySuggestion
        } else {
            self.message = error.localizedDescription
            self.note = nil
        }
        self.solutions = solutions
        self.rangeAdjustment = nil
    }
}

extension TopicReferenceResolutionErrorInfo {
    /// Extracts any `Solution`s from this error, if available.
    ///
    /// The error can provide `Solution`s if appropriate. Since the absolute location of
    /// the faulty reference is not known at the error's origin, the `Replacement`s
    /// will use `SourceLocation`s relative to the reference text. Provide range of the
    /// reference **body** to obtain correctly placed `Replacement`s.
    func solutions(referenceSourceRange: SourceRange) -> [Solution] {
        var solutions = self.solutions
        
        for i in solutions.indices {
            for j in solutions[i].replacements.indices {
                solutions[i].replacements[j].offsetWithRange(referenceSourceRange)
            }
        }
        
        return solutions
    }
}

/// A reference to a piece of documentation which has been verified to exist.
///
/// A `ResolvedTopicReference` refers to some piece of documentation, such as an article or symbol.
/// Once an `UnresolvedTopicReference` has been resolved to this type, it should be guaranteed
/// that the content backing the documentation is available
/// (i.e. there is a file on disk or data in memory ready to be
/// recalled at any time).
///
/// ## Implementation Details
///
/// `ResolvedTopicReference` is effectively a wrapper around Foundation's `URL` and,
/// because of this, it exposes an API very similar to `URL` and does not allow direct modification
/// of its properties. This immutability brings performance benefits and communicates with
/// user's of the API that doing something like adding a path component
/// is a potentially expensive operation, just as it is on `URL`.
///
/// > Important: This type has copy-on-write semantics and wraps an underlying class to store
/// > its data.
public struct ResolvedTopicReference: Hashable, Codable, Equatable, CustomStringConvertible {
    typealias ReferenceBundleIdentifier = String
    private struct ReferenceKey: Hashable {
        var path: String
        var fragment: String?
        var sourceLanguages: Set<SourceLanguage>
    }
    
    /// A synchronized reference cache to store resolved references.
    private static var sharedPool = Synchronized([ReferenceBundleIdentifier: [ReferenceKey: ResolvedTopicReference]]())
    
    /// Clears cached references belonging to the bundle with the given identifier.
    /// - Parameter bundleIdentifier: The identifier of the bundle to which the method should clear belonging references.
    static func purgePool(for bundleIdentifier: String) {
        sharedPool.sync { $0.removeValue(forKey: bundleIdentifier) }
    }
    
    /// Enables reference caching for any identifiers created with the given bundle identifier.
    static func enableReferenceCaching(for bundleIdentifier: ReferenceBundleIdentifier) {
        sharedPool.sync { sharedPool in
            if !sharedPool.keys.contains(bundleIdentifier) {
                sharedPool[bundleIdentifier] = [:]
            }
        }
    }

    /// The URL scheme for `doc://` links.
    public static let urlScheme = "doc"
    
    /// Returns `true` if the passed `URL` has a "doc" URL scheme.
    public static func urlHasResolvedTopicScheme(_ url: URL?) -> Bool {
        return url?.scheme?.lowercased() == ResolvedTopicReference.urlScheme
    }
    
    /// The storage for the resolved topic reference's state.
    let _storage: Storage
    
    /// The identifier of the bundle that owns this documentation topic.
    public var bundleIdentifier: String {
        return _storage.bundleIdentifier
    }
    
    /// The absolute path from the bundle to this topic, delimited by `/`.
    public var path: String {
        return _storage.path
    }
    
    /// A URL fragment referring to a resource in the topic.
    public var fragment: String? {
        return _storage.fragment
    }
    
    /// The source language for which this topic is relevant.
    public var sourceLanguage: SourceLanguage {
        // Return Swift by default to maintain backwards-compatibility.
        return sourceLanguages.contains(.swift) ? .swift : sourceLanguages.first!
    }
    
    /// The source languages for which this topic is relevant.
    ///
    /// > Important: The source languages associated with the reference may not be the same as the available source languages of its
    /// corresponding ``DocumentationNode``. If you need to query the source languages associated with a documentation node, use
    /// ``DocumentationContext/sourceLanguages(for:)`` instead.
    public var sourceLanguages: Set<SourceLanguage> {
        return _storage.sourceLanguages
    }
    
    /// - Note: The `path` parameter is escaped to a path readable string.
    public init(bundleIdentifier: String, path: String, fragment: String? = nil, sourceLanguage: SourceLanguage) {
        self.init(bundleIdentifier: bundleIdentifier, path: path, fragment: fragment, sourceLanguages: [sourceLanguage])
    }
    
    public init(bundleIdentifier: String, path: String, fragment: String? = nil, sourceLanguages: Set<SourceLanguage>) {
        self.init(
            bundleIdentifier: bundleIdentifier,
            urlReadablePath: urlReadablePath(path),
            urlReadableFragment: fragment.map(urlReadableFragment(_:)),
            sourceLanguages: sourceLanguages
        )
    }
    
    private init(bundleIdentifier: String, urlReadablePath: String, urlReadableFragment: String? = nil, sourceLanguages: Set<SourceLanguage>) {
        precondition(!sourceLanguages.isEmpty, "ResolvedTopicReference.sourceLanguages cannot be empty")
        // Check for a cached instance of the reference
        let key = ReferenceKey(path: urlReadablePath, fragment: urlReadableFragment, sourceLanguages: sourceLanguages)
        let cached = Self.sharedPool.sync { $0[bundleIdentifier]?[key] }
        if let resolved = cached {
            self = resolved
            return
        }
        
        _storage = Storage(
            bundleIdentifier: bundleIdentifier,
            path: urlReadablePath,
            fragment: urlReadableFragment,
            sourceLanguages: sourceLanguages
        )

        // Cache the reference
        Self.sharedPool.sync { sharedPool in
            // If we have a shared pool for this bundle identifier, cache the reference
            sharedPool[bundleIdentifier]?[key] = self
        }
    }
    
    /// The topic URL as you would write in a link.
    public var url: URL {
        return _storage.url
    }
    
    /// A list of the reference path components.
    var pathComponents: [String] {
        return _storage.pathComponents
    }
    
    /// A string representation of `url`.
    var absoluteString: String {
        return _storage.absoluteString
    }
    
    enum CodingKeys: CodingKey {
        case url, interfaceLanguage
    }
    
    public init(from decoder: Decoder) throws {
        enum TopicReferenceDeserializationError: Error {
            case unexpectedURLScheme(url: URL, scheme: String)
            case missingBundleIdentifier(url: URL)
        }
        
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        let url = try container.decode(URL.self, forKey: .url)
        guard ResolvedTopicReference.urlHasResolvedTopicScheme(url) else {
            throw TopicReferenceDeserializationError.unexpectedURLScheme(url: url, scheme: url.scheme ?? "")
        }
        
        guard let bundleIdentifier = url.host else {
            throw TopicReferenceDeserializationError.missingBundleIdentifier(url: url)
        }

        let language = try container.decode(String.self, forKey: .interfaceLanguage)
        let interfaceLanguage = SourceLanguage(id: language)

        decoder.registerReferences([url.absoluteString])
        
        self.init(bundleIdentifier: bundleIdentifier, path: url.path, fragment: url.fragment, sourceLanguage: interfaceLanguage)
    }
    
    /// Creates a new topic reference with the given fragment.
    ///
    /// Before adding the fragment to the reference, the fragment is encoded in a human readable format that avoids percent escape encoding in the URL.
    ///
    /// You use a fragment to reference an element within a page:
    /// ```
    /// doc://your.bundle.identifier/path/to/page#element-in-page
    ///                                           ╰──────┬──────╯
    ///                                               fragment
    /// ```
    /// On-page elements can then be linked to using a fragment need to conform to the ``Landmark`` protocol.
    ///
    /// - Parameter fragment: The new fragment.
    /// - Returns: The resulting topic reference.
    public func withFragment(_ fragment: String?) -> ResolvedTopicReference {
        let newReference = ResolvedTopicReference(
            bundleIdentifier: bundleIdentifier,
            path: path,
            fragment: fragment.map(urlReadableFragment),
            sourceLanguages: sourceLanguages
        )
        
        return newReference
    }
    
    /// Creates a new topic reference by appending a path to this reference.
    ///
    /// Before appending the path, it is encoded in a human readable format that avoids percent escape encoding in the URL.
    ///
    /// - Parameter path: The path to append.
    /// - Returns: The resulting topic reference.
    public func appendingPath(_ path: String) -> ResolvedTopicReference {
        let newReference = ResolvedTopicReference(
            bundleIdentifier: bundleIdentifier,
            urlReadablePath: url.appendingPathComponent(urlReadablePath(path), isDirectory: false).path,
            sourceLanguages: sourceLanguages
        )
        return newReference
    }
    
    /// Creates a new topic reference by appending the path of another topic reference to this reference.
    ///
    /// Before appending the path of the other reference, that path is encoded in a human readable format that avoids percent escape encoding in the URL.
    ///
    /// - Parameter reference: The other reference from which the path is appended to this reference.
    /// - Returns: The resulting topic reference.
    public func appendingPathOfReference(_ reference: UnresolvedTopicReference) -> ResolvedTopicReference {
        // Only append the path component if it's not empty (rdar://66580574).
        let referencePath = urlReadablePath(reference.path)
        guard !referencePath.isEmpty else {
            return self
        }
        let newPath = url.appendingPathComponent(referencePath, isDirectory: false).path
        let newReference = ResolvedTopicReference(
            bundleIdentifier: bundleIdentifier,
            urlReadablePath: newPath,
            urlReadableFragment: reference.fragment.map(urlReadableFragment),
            sourceLanguages: sourceLanguages
        )
        return newReference
    }
    
    /// Creates a new topic reference by removing the last path component from this topic reference.
    public func removingLastPathComponent() -> ResolvedTopicReference {
        let newPath = String(pathComponents.dropLast().joined(separator: "/").dropFirst())
        let newReference = ResolvedTopicReference(
            bundleIdentifier: bundleIdentifier,
            urlReadablePath: newPath,
            urlReadableFragment: fragment,
            sourceLanguages: sourceLanguages
        )
        return newReference
    }
    
    /// Returns a topic reference based on the current one that includes the given source languages.
    ///
    /// If the current topic reference already includes the given source languages, this returns
    /// the original topic reference.
    public func addingSourceLanguages(_ sourceLanguages: Set<SourceLanguage>) -> ResolvedTopicReference {
        let combinedSourceLanguages = self.sourceLanguages.union(sourceLanguages)
        
        guard combinedSourceLanguages != self.sourceLanguages else {
            return self
        }
        
        return ResolvedTopicReference(
            bundleIdentifier: bundleIdentifier,
            urlReadablePath: path,
            urlReadableFragment: fragment,
            sourceLanguages: combinedSourceLanguages
        )
    }
    
    /// Returns a topic reference based on the current one but with the given source languages.
    ///
    /// If the current topic reference's source languages equal the given source languages,
    /// this returns the original topic reference.
    public func withSourceLanguages(_ sourceLanguages: Set<SourceLanguage>) -> ResolvedTopicReference {
        guard sourceLanguages != self.sourceLanguages else {
            return self
        }
        
        return ResolvedTopicReference(
            bundleIdentifier: bundleIdentifier,
            urlReadablePath: path,
            urlReadableFragment: fragment,
            sourceLanguages: sourceLanguages
        )
    }
    
    /// The last path component of this topic reference.
    public var lastPathComponent: String {
        // There is always at least one component, so we can unwrap `last`.
        return url.lastPathComponent
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(url.absoluteString, forKey: .url)
        
        let sourceLanguageIDVariants = DocumentationDataVariants<String>(
            values: [DocumentationDataVariantsTrait: String](
                uniqueKeysWithValues: sourceLanguages.map { language in
                    (DocumentationDataVariantsTrait(interfaceLanguage: language.id), language.id)
                }
            )
        )
        
        try container.encodeVariantCollection(
            // Force-unwrapping because resolved topic references should have at least one source language.
            VariantCollection<String>(from: sourceLanguageIDVariants)!,
            forKey: .interfaceLanguage,
            encoder: encoder
        )
    }
    
    public var description: String {
        return url.absoluteString
    }
    
    // Note: The source language of a `ResolvedTopicReference` is not considered when
    // hashing and checking for equality. This is intentional as DocC uses a single
    // ResolvedTopicReference to refer to all source language variants of a topic.
    //
    // This allows clients to look up topic references without knowing ahead of time
    // which languages they are available in.
    
    public func hash(into hasher: inout Hasher) {
        hasher.combine(_storage.identifierPathAndFragment)
    }
    
    public static func == (lhs: ResolvedTopicReference, rhs: ResolvedTopicReference) -> Bool {
        return lhs._storage.identifierPathAndFragment == rhs._storage.identifierPathAndFragment
    }
    
    /// Storage for a resolved topic reference's state.
    ///
    /// This is a reference type which allows ``ResolvedTopicReference`` to have copy-on-write behavior.
    class Storage {
        let bundleIdentifier: String
        let path: String
        let fragment: String?
        let sourceLanguages: Set<SourceLanguage>
        let identifierPathAndFragment: String
        
        let url: URL
        
        let pathComponents: [String]
        
        let absoluteString: String
        
        init(
            bundleIdentifier: String,
            path: String,
            fragment: String? = nil,
            sourceLanguages: Set<SourceLanguage>
        ) {
            self.bundleIdentifier = bundleIdentifier
            self.path = path
            self.fragment = fragment
            self.sourceLanguages = sourceLanguages
            self.identifierPathAndFragment = "\(bundleIdentifier)\(path)\(fragment ?? "")"
            
            var components = URLComponents()
            components.scheme = ResolvedTopicReference.urlScheme
            components.host = bundleIdentifier
            components.path = path
            components.fragment = fragment
            self.url = components.url!
            self.pathComponents = self.url.pathComponents
            self.absoluteString = self.url.absoluteString
        }
    }
    
    // For testing the caching
    static func _numberOfCachedReferences(bundleID: ReferenceBundleIdentifier) -> Int? {
        return Self.sharedPool.sync { $0[bundleID]?.count }
    }
}

extension ResolvedTopicReference: RenderJSONDiffable {
    /// Returns the differences between this ResolvedTopicReference and the given one.
    func difference(from other: ResolvedTopicReference, at path: CodablePath) -> JSONPatchDifferences {
        var diffBuilder = DifferenceBuilder(current: self, other: other, basePath: path)

        // The only part of the URL that is encoded to RenderJSON is the absolute string.
        diffBuilder.addDifferences(atKeyPath: \.url.absoluteString, forKey: CodingKeys.url)
        
        // The only part of the source language that is encoded to RenderJSON is the id.
        diffBuilder.addDifferences(atKeyPath: \.sourceLanguage.id, forKey: CodingKeys.interfaceLanguage)
        
        return diffBuilder.differences
    }
}

/// An unresolved reference to a documentation node.
///
/// You can create unresolved references from partial information if that information can be derived from the enclosing context when the
/// reference is resolved. For example:
///
///  - The bundle identifier can be inferred from the documentation bundle that owns the document from which the unresolved reference came.
///  - The URL scheme of topic references is always "doc".
///  - The symbol precise identifier suffix can be left out when there are no known overloads or name collisions for the symbol.
public struct UnresolvedTopicReference: Hashable, CustomStringConvertible {
    /// The URL as originally spelled.
    public let topicURL: ValidatedURL
    
    /// The bundle identifier, if one was provided in the host name component of the original URL.
    public var bundleIdentifier: String? {
        return topicURL.components.host
    }
    
    /// The path of the unresolved reference.
    public var path: String {
        return topicURL.components.path
    }
    
    /// The fragment of the unresolved reference, if the original URL contained a fragment component.
    public var fragment: String? {
        return topicURL.components.fragment
    }
    
    /// An optional title.
    public var title: String? = nil
    
    /// Creates a new unresolved reference from another unresolved reference with a resolved parent reference.
    /// - Parameters:
    ///   - parent: The resolved parent reference of the unresolved reference.
    ///   - unresolvedChild: The other unresolved reference.
    public init(parent: ResolvedTopicReference, unresolvedChild: UnresolvedTopicReference) {
        var components = URLComponents(
            url: parent.url.appendingPathComponent(unresolvedChild.path, isDirectory: false),
            resolvingAgainstBaseURL: false
        )!
        components.fragment = unresolvedChild.fragment
        self.init(topicURL: ValidatedURL(components: components))
    }
    
    /// Creates a new untitled, unresolved reference with the given validated URL.
    /// - Parameter topicURL: The URL of this unresolved reference.
    public init(topicURL: ValidatedURL) {
        self.topicURL = topicURL
    }
    
    /// Creates a new unresolved reference with the given validated URL and title.
    /// - Parameters:
    ///   - topicURL: The URL of this unresolved reference.
    ///   - title: The title of this unresolved reference.
    public init(topicURL: ValidatedURL, title: String) {
        self.topicURL = topicURL
        self.title = title
    }
    
    public var description: String {
        var result = topicURL.components.string!
        // Replace that path and fragment parts of the description with the unescaped path and fragment values.
        if let rangeOfFragment = topicURL.components.rangeOfFragment, let fragment = topicURL.components.fragment {
            result.replaceSubrange(rangeOfFragment, with: fragment)
        }
        if let rangeOfPath = topicURL.components.rangeOfPath {
            result.replaceSubrange(rangeOfPath, with: topicURL.components.path)
        }
        return result
    }
}

/**
 A reference to an auxiliary resource such as an image.
 */
public struct ResourceReference: Hashable {
    /**
     The documentation bundle identifier for the bundle in which this resource resides.
     */
    public let bundleIdentifier: String

    /**
     The path of the resource local to its bundle.
     */
    public let path: String

    /// Creates a new resource reference.
    /// - Parameters:
    ///   - bundleIdentifier: The documentation bundle identifier for the bundle in which this resource resides.
    ///   - path: The path of the resource local to its bundle.
    init(bundleIdentifier: String, path: String) {
        self.bundleIdentifier = bundleIdentifier
        self.path = path.removingPercentEncoding ?? path
    }

    /// The topic reference URL of this reference.
    var url: URL {
        var components = URLComponents()
        components.scheme = ResolvedTopicReference.urlScheme
        components.host = bundleIdentifier
        components.path = "/" + path
        return components.url!
    }
}

/// Creates a more readable version of a path by replacing characters that are not allowed in the path of a URL with hyphens.
///
/// If this step is not performed, the disallowed characters are instead percent escape encoded instead which is less readable.
/// For example, a path like `"hello world/example project"` is converted to `"hello-world/example-project"`
/// instead of `"hello%20world/example%20project"`.
func urlReadablePath(_ path: some StringProtocol) -> String {
    return path.components(separatedBy: .urlPathNotAllowed).joined(separator: "-")
}

private extension CharacterSet {
    // For fragments
    static let fragmentCharactersToRemove = CharacterSet.punctuationCharacters // Remove punctuation from fragments
        .union(CharacterSet(charactersIn: "`"))       // Also consider back-ticks as punctuation. They are used as quotes around symbols or other code.
        .subtracting(CharacterSet(charactersIn: "-")) // Don't remove hyphens. They are used as a whitespace replacement.
    static let whitespaceAndDashes = CharacterSet.whitespaces
        .union(CharacterSet(charactersIn: "-–—")) // hyphen, en dash, em dash
}

/// Creates a more readable version of a fragment by replacing characters that are not allowed in the fragment of a URL with hyphens.
///
/// If this step is not performed, the disallowed characters are instead percent escape encoded, which is less readable.
/// For example, a fragment like `"#hello world"` is converted to `"#hello-world"` instead of `"#hello%20world"`.
func urlReadableFragment(_ fragment: some StringProtocol) -> String {
    var fragment = fragment
        // Trim leading/trailing whitespace
        .trimmingCharacters(in: .whitespaces)
    
        // Replace continuous whitespace and dashes
        .components(separatedBy: .whitespaceAndDashes)
        .filter({ !$0.isEmpty })
        .joined(separator: "-")
    
    // Remove invalid characters
    fragment.unicodeScalars.removeAll(where: CharacterSet.fragmentCharactersToRemove.contains)
    
    return fragment
}