Repository: apple/swift-docc (swift)

Issue:
Include user provided theme-settings.json file in compiled output
### Feature Name

theme-settings.json support

### Description

Swift-DocC should look for a `theme-settings.json` file in documentation catalogs and include it in compiled output for Swift-DocC-Render or other renderers to utilize.

This is a feature proposed by myself in the forum post [Customizing the look and feel of Swift-DocC-Render](https://forums.swift.org/t/customizing-the-look-and-feel-of-swift-docc-render/58858).

### Motivation

With this new feature, DocC users could provide this file to customize the look and feel of their rendered documentation webpages.

[Here](https://mportiz08.github.io/swift-docc/documentation/swiftdocc/) is working prototype of the customization that could be realized with support for this file, using the Swift-DocC docs as an example:

<img width="1436" alt="Screenshot of DocC documentation presented in an alternate color scheme" src="https://user-images.githubusercontent.com/212918/179089043-f5f1e89f-3d2b-4449-9124-1f2d74ebf30c.png">


### Importance

Without this feature, there is no easy way to alter the appearance of documentation without manually modifying DocC build artifacts by hand in an ad-hoc manner.

Swift-DocC-Render already has minimal support for this file if it is present in compiled output, but more capabilities will be implemented soon [as described in the forum post](https://forums.swift.org/t/customizing-the-look-and-feel-of-swift-docc-render/58858), and this change will allow these settings to be persisted across subsequent compiler builds of a project.

### Alternatives Considered

See the [Alternatives Considered](https://forums.swift.org/t/customizing-the-look-and-feel-of-swift-docc-render/58858#alternatives-considered-2) section of [Customizing the look and feel of Swift-DocC-Render](https://forums.swift.org/t/customizing-the-look-and-feel-of-swift-docc-render/58858) for details on other possible approaches to the general concept of customization and theme support in DocC.

Pull request:
Bug/issue #, if applicable: #336 

## Summary

If a documentation catalog contains a `theme-settings.json` file, DocC will now include this file in the compiled output, alongside the JSON data and static renderer assets.

If a default `theme-settings.json` file is already provided by the renderer, the file provided by the catalog will take precedence.

This is the minimal DocC implementation work needed to support the proposal described in the [Customizing the look and feel of Swift-DocC-Render](https://forums.swift.org/t/customizing-the-look-and-feel-of-swift-docc-render/58858) forum post.

## Dependencies

There are no explicit dependencies for this PR since DocC-Render already has some (minimal) support for the presence of this file in the output, however future PRs for DocC-Render will enhance these capabilities and document the supported settings.

## Testing

Steps:
1. Download/unzip [with-settings.diff.zip](https://github.com/apple/swift-docc/files/9144977/with-settings.diff.zip)
2. Run `cd swift-docc; git apply with-settings.patch`
3. Run `env DOCC_HTML_DIR=$(dirname $(xcrun --find docc))/../share/docc/render bin/preview-docs DocC`¹
4. Open http://localhost:8000/documentation/docc and verify that some of the text on the page is now green

¹ this env var value will only work if you have a recent Xcode installed, however you can substitute it with a built copy of the renderer in other ways if needed

## Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

- [X] Added tests
- [X] Ran the `./bin/test` script and it succeeded
- [X] Updated documentation if necessary (note: documentation for the JSON file itself will be added in a later PR here or in swift-docc-render)

Patch:
diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift b/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift
index 5b7f99a014..ca497b73b0 100644
--- a/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift
+++ b/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift
@@ -87,6 +87,9 @@ public struct DocumentationBundle {
     /// Custom HTML file to use as the footer for rendered output.
     public let customFooter: URL?
 
+    /// JSON settings file used to theme renderer output.
+    public let themeSettings: URL?
+
     /// Default syntax highlighting to use for code samples in this bundle.
     @available(*, deprecated, message: "Use 'info.defaultCodeListingLanguage' instead.")
     public var defaultCodeListingLanguage: String? {
@@ -156,7 +159,8 @@ public struct DocumentationBundle {
         markupURLs: [URL],
         miscResourceURLs: [URL],
         customHeader: URL? = nil,
-        customFooter: URL? = nil
+        customFooter: URL? = nil,
+        themeSettings: URL? = nil
     ) {
         self.info = info
         self.baseURL = baseURL
@@ -166,6 +170,7 @@ public struct DocumentationBundle {
         self.miscResourceURLs = miscResourceURLs
         self.customHeader = customHeader
         self.customFooter = customFooter
+        self.themeSettings = themeSettings
         self.rootReference = ResolvedTopicReference(bundleIdentifier: info.identifier, path: "/", sourceLanguage: .swift)
         self.documentationRootReference = ResolvedTopicReference(bundleIdentifier: info.identifier, path: NodeURLGenerator.Path.documentationFolder, sourceLanguage: .swift)
         self.tutorialsRootReference = ResolvedTopicReference(bundleIdentifier: info.identifier, path: NodeURLGenerator.Path.tutorialsFolder, sourceLanguage: .swift)
diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift b/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift
index b7a259de59..0219c65f44 100644
--- a/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift
+++ b/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift
@@ -76,4 +76,12 @@ public enum DocumentationBundleFileTypes {
     public static func isCustomFooter(_ url: URL) -> Bool {
         return url.lastPathComponent == customFooterFileName
     }
+
+    private static let themeSettingsFileName = "theme-settings.json"
+    /// Checks if a file is `theme-settings.json`.
+    /// - Parameter url: The file to check.
+    /// - Returns: Whether or not the file at `url` is `theme-settings.json`.
+    public static func isThemeSettingsFile(_ url: URL) -> Bool {
+        return url.lastPathComponent == themeSettingsFileName
+    }
 }
diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift b/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift
index c202237c4e..db34fbf0d7 100644
--- a/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift
+++ b/Sources/SwiftDocC/Infrastructure/Workspace/DataProviderBundleDiscovery.swift
@@ -66,8 +66,17 @@ extension DocumentationWorkspaceDataProvider where Self: FileSystemProvider {
 
         let customHeader = findCustomHeader(bundleChildren)?.url
         let customFooter = findCustomFooter(bundleChildren)?.url
+        let themeSettings = findThemeSettings(bundleChildren)?.url
         
-        return DocumentationBundle(info: info, symbolGraphURLs: symbolGraphFiles, markupURLs: markupFiles, miscResourceURLs: miscResources, customHeader: customHeader, customFooter: customFooter)
+        return DocumentationBundle(
+            info: info,
+            symbolGraphURLs: symbolGraphFiles,
+            markupURLs: markupFiles,
+            miscResourceURLs: miscResources,
+            customHeader: customHeader,
+            customFooter: customFooter,
+            themeSettings: themeSettings
+        )
     }
     
     /// Performs a shallow search for the first Info.plist file in the given list of files and directories.
@@ -111,6 +120,10 @@ extension DocumentationWorkspaceDataProvider where Self: FileSystemProvider {
     private func findCustomFooter(_ bundleChildren: [FSNode]) -> FSNode.File? {
         return bundleChildren.firstFile { DocumentationBundleFileTypes.isCustomFooter($0.url) }
     }
+
+    private func findThemeSettings(_ bundleChildren: [FSNode]) -> FSNode.File? {
+        return bundleChildren.firstFile { DocumentationBundleFileTypes.isThemeSettingsFile($0.url) }
+    }
 }
 
 fileprivate extension Array where Element == FSNode {
diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift
index 7f121d54ac..66eae8ee9a 100644
--- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift
+++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift
@@ -124,6 +124,17 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer {
         if let customFooter = bundle.customFooter, enableCustomTemplates {
             try injectCustomTemplate(customFooter, identifiedBy: .footer)
         }
+
+        // Copy the `theme-settings.json` file into the output directory if one
+        // is provided. It will override any default `theme-settings.json` file
+        // that the renderer template may already contain.
+        if let themeSettings = bundle.themeSettings {
+            let targetFile = targetFolder.appendingPathComponent(themeSettings.lastPathComponent, isDirectory: false)
+            if fileManager.fileExists(atPath: targetFile.path) {
+                try fileManager.removeItem(at: targetFile)
+            }
+            try fileManager.copyItem(at: themeSettings, to: targetFile)
+        }
     }
     
     func consume(linkableElementSummaries summaries: [LinkDestinationSummary]) throws {


Test patch:
diff --git a/Tests/SwiftDocCTests/Infrastructure/BundleDiscoveryTests.swift b/Tests/SwiftDocCTests/Infrastructure/BundleDiscoveryTests.swift
index 3094e808e3..f9c577e7a3 100644
--- a/Tests/SwiftDocCTests/Infrastructure/BundleDiscoveryTests.swift
+++ b/Tests/SwiftDocCTests/Infrastructure/BundleDiscoveryTests.swift
@@ -294,6 +294,9 @@ class BundleDiscoveryTests: XCTestCase {
         // Ensure that `customFooter` is `nil` if no top level `footer.html`
         // file was found in the bundle
         XCTAssertNil(bundle.customFooter)
+        // Ensure that `themeSettings` is `nil` if no `theme-settings.json`
+        // file was found in the bundle
+        XCTAssertNil(bundle.themeSettings)
     }
 
     func testCustomTemplatesFound() throws {
@@ -325,4 +328,36 @@ class BundleDiscoveryTests: XCTestCase {
         // `footer.html` file if one is found in the bundle
         XCTAssertEqual(bundle.customFooter?.lastPathComponent, "footer.html")
     }
+
+    func testThemeSettingsFound() throws {
+        let workspace = Folder(name: "TestBundle.docc", content:
+            allFiles.map { CopyOfFile(original: $0) } + [
+                TextFile(name: "theme-settings.json", utf8Content: """
+                {
+                  "meta": {},
+                  "theme": {
+                    "colors": {
+                      "text": "#ff0000"
+                    }
+                  },
+                  "features": {}
+                }
+                """),
+            ]
+        )
+
+        let tempURL = try createTemporaryDirectory()
+
+        let workspaceURL = try workspace.write(inside: tempURL)
+        let dataProvider = try LocalFileSystemDataProvider(rootURL: workspaceURL)
+
+        let bundles = try dataProvider.bundles(options: BundleDiscoveryOptions())
+
+        XCTAssertEqual(bundles.count, 1)
+        guard let bundle = bundles.first else { return }
+
+        // Ensure that `themeSettings` points to the location of a
+        // `theme-settings.json` file if one is found in the bundle
+        XCTAssertEqual(bundle.themeSettings?.lastPathComponent, "theme-settings.json")
+    }
 }
diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleFileTypesTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleFileTypesTests.swift
index 387dd908f3..f274d3f82e 100644
--- a/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleFileTypesTests.swift
+++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationBundleFileTypesTests.swift
@@ -41,4 +41,18 @@ class DocumentationBundleFileTypesTests: XCTestCase {
         XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
             URL(fileURLWithPath: "DocC.docc/footer.html")))
     }
+
+    func testIsThemeSettingsFile() {
+        XCTAssertTrue(DocumentationBundleFileTypes.isThemeSettingsFile(
+            URL(fileURLWithPath: "theme-settings.json")))
+        XCTAssertTrue(DocumentationBundleFileTypes.isThemeSettingsFile(
+            URL(fileURLWithPath: "/a/b/theme-settings.json")))
+
+        XCTAssertFalse(DocumentationBundleFileTypes.isThemeSettingsFile(
+            URL(fileURLWithPath: "theme-settings.txt")))
+        XCTAssertFalse(DocumentationBundleFileTypes.isThemeSettingsFile(
+            URL(fileURLWithPath: "not-theme-settings.json")))
+        XCTAssertFalse(DocumentationBundleFileTypes.isThemeSettingsFile(
+            URL(fileURLWithPath: "/a/theme-settings.json/bar")))
+    }
 }
diff --git a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
index 55fbbe8456..d772e038f4 100644
--- a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
+++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
@@ -2498,6 +2498,64 @@ class ConvertActionTests: XCTestCase {
         let expectedOutput = Folder(name: ".docc-build", content: [expectedIndex])
         expectedOutput.assertExist(at: result.outputs[0], fileManager: FileManager.default)
     }
+
+    func testConvertWithThemeSettings() throws {
+        let info = InfoPlist(displayName: "TestConvertWithThemeSettings", identifier: "com.test.example")
+        let index = TextFile(name: "index.html", utf8Content: """
+        <!DOCTYPE html>
+        <html lang="en">
+            <head>
+                <title>Test</title>
+            </head>
+            <body data-color-scheme="auto"><p>hello</p></body>
+        </html>
+        """)
+        let themeSettings = TextFile(name: "theme-settings.json", utf8Content: """
+        {
+          "meta": {},
+          "theme": {
+            "colors": {
+              "text": "#ff0000"
+            }
+          },
+          "features": {}
+        }
+        """)
+        let template = Folder(name: "template", content: [index])
+        let bundle = Folder(name: "TestConvertWithThemeSettings.docc", content: [
+            info,
+            themeSettings,
+        ])
+
+        let tempURL = try createTemporaryDirectory()
+        let targetURL = tempURL.appendingPathComponent("target", isDirectory: true)
+
+        let bundleURL = try bundle.write(inside: tempURL)
+        let templateURL = try template.write(inside: tempURL)
+
+        let dataProvider = try LocalFileSystemDataProvider(rootURL: bundleURL)
+
+        var action = try ConvertAction(
+            documentationBundleURL: bundleURL,
+            outOfProcessResolver: nil,
+            analyze: false,
+            targetDirectory: targetURL,
+            htmlTemplateDirectory: templateURL,
+            emitDigest: false,
+            currentPlatforms: nil,
+            dataProvider: dataProvider,
+            fileManager: FileManager.default,
+            temporaryDirectory: createTemporaryDirectory(),
+            experimentalEnableCustomTemplates: true
+        )
+        let result = try action.perform(logHandle: .standardOutput)
+
+        let expectedOutput = Folder(name: ".docc-build", content: [
+            index,
+            themeSettings,
+        ])
+        expectedOutput.assertExist(at: result.outputs[0], fileManager: FileManager.default)
+    }
     
     private func uniformlyPrintDiagnosticMessages(_ problems: [Problem]) -> String {
         return problems.sorted(by: { (lhs, rhs) -> Bool in