Skip to content

Commit 531818a

Browse files
authored
Allow relative references to images across bundle identifiers (#1178)
Fixes an issue where roundtripping a `URLReference` would yield different results depending on the bundle identifier of the catalog. Paths to images and other downloadable content would always get rendered into JSON with the bundle identifier of the current bundle, discarding the original path, regardless of whether they already contained a reference to a different bundle. This causes issues when the resolved information from an out of process resolver contains a relative path, e.g. `/images/com.example.bundle/image.png`, as the path would always be rewritten to be the catalog's bundle identifier. This fix makes it such that if the path is already relative to the base URL of the reference type, the URL is left unchanged. Fixes rdar://146108301.
1 parent d365e4d commit 531818a

File tree

5 files changed

+114
-8
lines changed

5 files changed

+114
-8
lines changed

Sources/SwiftDocC/Model/Rendering/References/ImageReference.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public struct ImageReference: MediaReference, URLReference, Equatable {
7474
var result = [VariantProxy]()
7575
// sort assets by URL path for deterministic sorting of images
7676
asset.variants.sorted(by: \.value.path).forEach { (key, value) in
77-
let url = value.isAbsoluteWebURL ? value : destinationURL(for: value.lastPathComponent, prefixComponent: encoder.assetPrefixComponent)
77+
let url = renderURL(for: value, prefixComponent: encoder.assetPrefixComponent)
7878
result.append(VariantProxy(url: url, traits: key, svgID: asset.metadata[value]?.svgID))
7979
}
8080
try container.encode(result, forKey: .variants)

Sources/SwiftDocC/Model/Rendering/References/RenderReference.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ public protocol URLReference {
6565
}
6666

6767
extension URLReference {
68+
/// Transforms the given URL to ensure that it is relative to the base URL of the conforming type.
69+
///
70+
/// The converter that writes the built documentation to the file system is responsible for copying the referenced file to this destination.
71+
/// - Parameters:
72+
/// - url: The URL of the file.
73+
/// - prefixComponent: An optional path component to add before the path of the file.
74+
/// - Returns: The transformed URL for the given file path.
75+
func renderURL(for url: URL, prefixComponent: String?) -> URL {
76+
// Web URLs should be left as-is
77+
guard !url.isAbsoluteWebURL else {
78+
return url
79+
}
80+
81+
// URLs which are already relative to the base URL should be left as-is
82+
guard !url.pathComponents.starts(with: Self.baseURL.pathComponents) else {
83+
return url
84+
}
85+
86+
return destinationURL(for: url.lastPathComponent, prefixComponent: prefixComponent)
87+
}
88+
6889
/// Returns the URL for a given file path relative to the base URL of the conforming type.
6990
///
7091
/// The converter that writes the built documentation to the file system is responsible for copying the referenced file to this destination.

Sources/SwiftDocC/Model/Rendering/References/VideoReference.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public struct VideoReference: MediaReference, URLReference, Equatable {
8181
// convert the data asset to a serializable object
8282
var result = [VariantProxy]()
8383
asset.variants.sorted(by: \.value.path).forEach { (key, value) in
84-
let url = value.isAbsoluteWebURL ? value : destinationURL(for: value.lastPathComponent, prefixComponent: encoder.assetPrefixComponent)
84+
let url = renderURL(for: value, prefixComponent: encoder.assetPrefixComponent)
8585
result.append(VariantProxy(url: url, traits: key))
8686
}
8787
try container.encode(result, forKey: .variants)

Sources/SwiftDocC/Model/Rendering/Tutorial/References/DownloadReference.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,6 @@ public struct DownloadReference: RenderReference, URLReference, Equatable {
9393
}
9494
}
9595

96-
extension DownloadReference {
97-
private func renderURL(for url: URL, prefixComponent: String?) -> URL {
98-
url.isAbsoluteWebURL ? url : destinationURL(for: url.lastPathComponent, prefixComponent: prefixComponent)
99-
}
100-
}
101-
10296
// Diffable conformance
10397
extension DownloadReference: RenderJSONDiffable {
10498
/// Returns the difference between this DownloadReference and the given one.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 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 XCTest
12+
@testable import SwiftDocC
13+
14+
class URLReferenceTests: XCTestCase {
15+
struct MockReference: URLReference {
16+
static var baseURL: URL = URL(string: "/mocks/")!
17+
}
18+
func testRenderURLIgnoresAbsoluteWebURLs() throws {
19+
let testUrl = try XCTUnwrap(URL(string: "https://example.com/"))
20+
let urlReference = MockReference()
21+
XCTAssertEqual(urlReference.renderURL(for: testUrl, prefixComponent: nil), testUrl)
22+
}
23+
24+
func testRenderURLIgnoresPrefacedURLs() throws {
25+
let testUrl = try XCTUnwrap(URL(string: "/mocks/example.com/mock-name"))
26+
let urlReference = MockReference()
27+
XCTAssertEqual(urlReference.renderURL(for: testUrl, prefixComponent: nil), testUrl)
28+
}
29+
30+
func testRenderURLPreparesUnprefacedURLs() throws {
31+
let testUrl = try XCTUnwrap(URL(string: "file://full/path/to/mock-name"))
32+
let expectedUrl = try XCTUnwrap(URL(string: "/mocks/mock-name"))
33+
let urlReference = MockReference()
34+
XCTAssertEqual(urlReference.renderURL(for: testUrl, prefixComponent: nil), expectedUrl)
35+
}
36+
37+
func testImageReferenceRoundtripsAcrossBundles() throws {
38+
// Encode the reference in bundle 1
39+
var encoder = RenderJSONEncoder.makeEncoder(assetPrefixComponent: "com.example.bundle1")
40+
var asset = DataAsset()
41+
asset.register(URL(string: "image.png")!, with: .init())
42+
let reference = ImageReference(identifier: .init("image"), imageAsset: asset)
43+
44+
// Verify it was encoded correctly
45+
var jsonData = try XCTUnwrap(try encoder.encode(reference))
46+
var decodedReference = try RenderJSONDecoder.makeDecoder().decode(ImageReference.self, from: jsonData)
47+
XCTAssertEqual(Array(decodedReference.asset.metadata.keys), [URL(string: "/images/com.example.bundle1/image.png")!])
48+
49+
// Re-encode the reference from bundle 1 in bundle 2 and ensure that the URL has not changed
50+
encoder = RenderJSONEncoder.makeEncoder(assetPrefixComponent: "com.example.bundle2")
51+
jsonData = try XCTUnwrap(try encoder.encode(decodedReference))
52+
decodedReference = try RenderJSONDecoder.makeDecoder().decode(ImageReference.self, from: jsonData)
53+
XCTAssertEqual(Array(decodedReference.asset.metadata.keys), [URL(string: "/images/com.example.bundle1/image.png")!])
54+
}
55+
56+
func testDownloadReferenceRoundtripsAcrossBundles() throws {
57+
// Encode the reference in bundle 1
58+
var encoder = RenderJSONEncoder.makeEncoder(assetPrefixComponent: "com.example.bundle1")
59+
let reference = DownloadReference(identifier: .init("download"), renderURL: URL(string: "download.zip")!, checksum: nil)
60+
61+
// Verify it was encoded correctly
62+
var jsonData = try XCTUnwrap(try encoder.encode(reference))
63+
var decodedReference = try RenderJSONDecoder.makeDecoder().decode(DownloadReference.self, from: jsonData)
64+
XCTAssertEqual(decodedReference.url, URL(string: "/downloads/com.example.bundle1/download.zip")!)
65+
66+
// Re-encode the reference from bundle 1 in bundle 2 and ensure that the URL has not changed
67+
encoder = RenderJSONEncoder.makeEncoder(assetPrefixComponent: "com.example.bundle2")
68+
jsonData = try XCTUnwrap(try encoder.encode(decodedReference))
69+
decodedReference = try RenderJSONDecoder.makeDecoder().decode(DownloadReference.self, from: jsonData)
70+
XCTAssertEqual(decodedReference.url, URL(string: "/downloads/com.example.bundle1/download.zip")!)
71+
}
72+
73+
func testVideoReferenceRoundtripsAcrossBundles() throws {
74+
// Encode the reference in bundle 1
75+
var encoder = RenderJSONEncoder.makeEncoder(assetPrefixComponent: "com.example.bundle1")
76+
var asset = DataAsset()
77+
asset.register(URL(string: "video.mov")!, with: .init())
78+
let reference = VideoReference(identifier: .init("video"), videoAsset: asset, poster: nil)
79+
80+
// Verify it was encoded correctly
81+
var jsonData = try XCTUnwrap(try encoder.encode(reference))
82+
var decodedReference = try RenderJSONDecoder.makeDecoder().decode(VideoReference.self, from: jsonData)
83+
XCTAssertEqual(Array(decodedReference.asset.metadata.keys), [URL(string: "/videos/com.example.bundle1/video.mov")!])
84+
85+
// Re-encode the reference from bundle 1 in bundle 2 and ensure that the URL has not changed
86+
encoder = RenderJSONEncoder.makeEncoder(assetPrefixComponent: "com.example.bundle2")
87+
jsonData = try XCTUnwrap(try encoder.encode(decodedReference))
88+
decodedReference = try RenderJSONDecoder.makeDecoder().decode(VideoReference.self, from: jsonData)
89+
XCTAssertEqual(Array(decodedReference.asset.metadata.keys), [URL(string: "/videos/com.example.bundle1/video.mov")!])
90+
}
91+
}

0 commit comments

Comments
 (0)