diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift b/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift index b8d8716390..01c11b7958 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift @@ -110,11 +110,15 @@ public struct DocumentationBundle { /// A custom JSON settings file used to theme renderer output. public let themeSettings: URL? + /// A URL prefix to be appended to the relative presentation URL. /// /// This is used when a built documentation is hosted in a known location. public let baseURL: URL + /// A custom JSON settings file used to add custom scripts to the renderer output. + public let customScripts: URL? + /// Creates a new collection of build inputs for a unit of documentation. /// /// - Parameters: @@ -126,6 +130,7 @@ public struct DocumentationBundle { /// - customHeader: A custom HTML file to use as the header for rendered output. /// - customFooter: A custom HTML file to use as the footer for rendered output. /// - themeSettings: A custom JSON settings file used to theme renderer output. + /// - customScripts: A custom JSON settings file used to add custom scripts to the renderer output. public init( info: Info, baseURL: URL = URL(string: "/")!, @@ -134,7 +139,8 @@ public struct DocumentationBundle { miscResourceURLs: [URL], customHeader: URL? = nil, customFooter: URL? = nil, - themeSettings: URL? = nil + themeSettings: URL? = nil, + customScripts: URL? = nil ) { self.info = info self.baseURL = baseURL @@ -144,6 +150,7 @@ public struct DocumentationBundle { self.customHeader = customHeader self.customFooter = customFooter self.themeSettings = themeSettings + self.customScripts = customScripts self.rootReference = ResolvedTopicReference(bundleID: info.id, path: "/", sourceLanguage: .swift) self.documentationRootReference = ResolvedTopicReference(bundleID: info.id, path: NodeURLGenerator.Path.documentationFolder, sourceLanguage: .swift) self.tutorialTableOfContentsContainer = ResolvedTopicReference(bundleID: info.id, path: NodeURLGenerator.Path.tutorialsFolder, sourceLanguage: .swift) @@ -151,7 +158,9 @@ public struct DocumentationBundle { self.articlesDocumentationRootReference = documentationRootReference.appendingPath(urlReadablePath(info.displayName)) } - @available(*, deprecated, renamed: "init(info:baseURL:symbolGraphURLs:markupURLs:miscResourceURLs:customHeader:customFooter:themeSettings:)", message: "Use 'init(info:baseURL:symbolGraphURLs:markupURLs:miscResourceURLs:customHeader:customFooter:themeSettings:)' instead. This deprecated API will be removed after 6.1 is released") + + @_disfavoredOverload + @available(*, deprecated, renamed: "init(info:baseURL:symbolGraphURLs:markupURLs:miscResourceURLs:customHeader:customFooter:themeSettings:customScripts:)", message: "Use 'init(info:baseURL:symbolGraphURLs:markupURLs:miscResourceURLs:customHeader:customFooter:themeSettings:customScripts:)' instead. This deprecated API will be removed after 6.1 is released") public init( info: Info, baseURL: URL = URL(string: "/")!, @@ -163,7 +172,7 @@ public struct DocumentationBundle { customFooter: URL? = nil, themeSettings: URL? = nil ) { - self.init(info: info, baseURL: baseURL, symbolGraphURLs: symbolGraphURLs, markupURLs: markupURLs, miscResourceURLs: miscResourceURLs, customHeader: customHeader, customFooter: customFooter, themeSettings: themeSettings) + self.init(info: info, baseURL: baseURL, symbolGraphURLs: symbolGraphURLs, markupURLs: markupURLs, miscResourceURLs: miscResourceURLs, customHeader: customHeader, customFooter: customFooter, themeSettings: themeSettings, customScripts: nil) self.attributedCodeListings = attributedCodeListings } diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift b/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift index c7c1d3a84a..d294494e8d 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift @@ -84,6 +84,14 @@ public enum DocumentationBundleFileTypes { public static func isThemeSettingsFile(_ url: URL) -> Bool { return url.lastPathComponent == themeSettingsFileName } + + private static let customScriptsFileName = "custom-scripts.json" + /// Checks if a file is `custom-scripts.json`. + /// - Parameter url: The file to check. + /// - Returns: Whether or not the file at `url` is `custom-scripts.json`. + public static func isCustomScriptsFile(_ url: URL) -> Bool { + return url.lastPathComponent == customScriptsFileName + } } extension DocumentationBundleFileTypes { diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 304f343ab2..d1fc5a68c3 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -1784,6 +1784,7 @@ public class DocumentationContext { private static let supportedImageExtensions: Set = ["png", "jpg", "jpeg", "svg", "gif"] private static let supportedVideoExtensions: Set = ["mov", "mp4"] + private static let supportedScriptExtensions: Set = ["js"] // TODO: Move this functionality to ``DocumentationBundleFileTypes`` (rdar://68156425). @@ -1840,7 +1841,7 @@ public class DocumentationContext { } } - /// Returns a list of all the image assets that registered for a given `bundleIdentifier`. + /// Returns a list of all the image assets that registered for a given `bundleID`. /// /// - Parameter bundleID: The identifier of the bundle to return image assets for. /// - Returns: A list of all the image assets for the given bundle. @@ -1853,7 +1854,7 @@ public class DocumentationContext { registeredImageAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier)) } - /// Returns a list of all the video assets that registered for a given `bundleIdentifier`. + /// Returns a list of all the video assets that registered for a given `bundleID`. /// /// - Parameter bundleID: The identifier of the bundle to return video assets for. /// - Returns: A list of all the video assets for the given bundle. @@ -1866,7 +1867,7 @@ public class DocumentationContext { registeredVideoAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier)) } - /// Returns a list of all the download assets that registered for a given `bundleIdentifier`. + /// Returns a list of all the download assets that registered for a given `bundleID`. /// /// - Parameter bundleID: The identifier of the bundle to return download assets for. /// - Returns: A list of all the download assets for the given bundle. @@ -1878,6 +1879,14 @@ public class DocumentationContext { public func registeredDownloadsAssets(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] { registeredDownloadsAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier)) } + + /// Returns a list of all the custom scripts that registered for a given `bundleID`. + /// + /// - Parameter bundleID: The identifier of the bundle to return custom scripts for. + /// - Returns: A list of all the custom scripts for the given bundle. + public func registeredCustomScripts(for bundleID: DocumentationBundle.Identifier) -> [DataAsset] { + return registeredAssets(withExtensions: DocumentationContext.supportedScriptExtensions, forBundleID: bundleID) + } typealias Articles = [DocumentationContext.SemanticResult
] private typealias ArticlesTuple = (articles: Articles, rootPageArticles: Articles) diff --git a/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift b/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift index ae16ce51da..d0077a891d 100644 --- a/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift +++ b/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift @@ -25,6 +25,7 @@ extension DocumentationContext { /// ``DocumentationBundle/symbolGraphURLs`` | ``DocumentationBundleFileTypes/isSymbolGraphFile(_:)`` /// ``DocumentationBundle/info`` | ``DocumentationBundleFileTypes/isInfoPlistFile(_:)`` /// ``DocumentationBundle/themeSettings`` | ``DocumentationBundleFileTypes/isThemeSettingsFile(_:)`` + /// ``DocumentationBundle/customScripts`` | ``DocumentationBundleFileTypes/isCustomScriptsFile(_:)`` /// ``DocumentationBundle/customHeader`` | ``DocumentationBundleFileTypes/isCustomHeader(_:)`` /// ``DocumentationBundle/customFooter`` | ``DocumentationBundleFileTypes/isCustomFooter(_:)`` /// ``DocumentationBundle/miscResourceURLs`` | Any file not already matched above. @@ -165,7 +166,8 @@ extension DocumentationContext.InputsProvider { miscResourceURLs: foundContents.resources, customHeader: shallowContent.first(where: FileTypes.isCustomHeader), customFooter: shallowContent.first(where: FileTypes.isCustomFooter), - themeSettings: shallowContent.first(where: FileTypes.isThemeSettingsFile) + themeSettings: shallowContent.first(where: FileTypes.isThemeSettingsFile), + customScripts: shallowContent.first(where: FileTypes.isCustomScriptsFile) ) } diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift b/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift index 9101f51ec1..05ee54bf0a 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift @@ -83,6 +83,7 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider { let customHeader = findCustomHeader(bundleChildren)?.url let customFooter = findCustomFooter(bundleChildren)?.url let themeSettings = findThemeSettings(bundleChildren)?.url + let customScripts = findCustomScripts(bundleChildren)?.url return DocumentationBundle( info: info, @@ -91,7 +92,8 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider { miscResourceURLs: miscResources, customHeader: customHeader, customFooter: customFooter, - themeSettings: themeSettings + themeSettings: themeSettings, + customScripts: customScripts ) } @@ -140,6 +142,10 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider { private func findThemeSettings(_ bundleChildren: [FSNode]) -> FSNode.File? { return bundleChildren.firstFile { DocumentationBundleFileTypes.isThemeSettingsFile($0.url) } } + + private func findCustomScripts(_ bundleChildren: [FSNode]) -> FSNode.File? { + return bundleChildren.firstFile { DocumentationBundleFileTypes.isCustomScriptsFile($0.url) } + } } @available(*, deprecated, message: "This deprecated API will be removed after 6.2 is released.") diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/CustomScripts.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/CustomScripts.spec.json new file mode 100644 index 0000000000..6a8e9b98ea --- /dev/null +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/CustomScripts.spec.json @@ -0,0 +1,98 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Custom Scripts", + "description": "This spec describes the permissible contents of a custom-scripts.json file in a documentation catalog, which is used to add custom scripts to a DocC-generated website.", + "version": "0.0.1" + }, + "paths": {}, + "components": { + "schemas": { + "Scripts": { + "type": "array", + "description": "An array of custom scripts, which is the top-level container in a custom-scripts.json file.", + "items": { + "oneOf": [ + { "$ref": "#/components/schemas/ExternalScript" }, + { "$ref": "#/components/schemas/LocalScript" }, + { "$ref": "#/components/schemas/InlineScript" } + ] + } + }, + "Script": { + "type": "object", + "description": "An abstract schema representing any script, from which all three script types inherit.", + "properties": { + "type": { + "type": "string", + "description": "The `type` attribute of the HTML script element." + }, + "run": { + "type": "string", + "enum": ["on-load", "on-navigate", "on-load-and-navigate"], + "description": "Whether the custom script should be run only on the initial page load, each time the reader navigates after the initial page load, or both." + } + } + }, + "ScriptFromFile": { + "description": "An abstract schema representing a script from an external or local file; that is, not an inline script.", + "allOf": [ + { "$ref": "#/components/schemas/Script" }, + { + "properties": { + "async": { "type": "boolean" }, + "defer": { "type": "boolean" }, + "integrity": { "type": "string" }, + } + } + ] + }, + "ExternalScript": { + "description": "A script at an external URL.", + "allOf": [ + { "$ref": "#/components/schemas/ScriptFromFile" }, + { + "required": ["url"], + "properties": { + "url": { "type": "string" } + } + } + ] + }, + "LocalScript": { + "description": "A script from a local file.", + "allOf": [ + { "$ref": "#/components/schemas/ScriptFromFile" }, + { + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "The name of the local script file, optionally including the '.js' extension." + }, + } + } + ] + }, + "InlineScript": { + "description": "A script whose source code is in the custom-scripts.json file itself.", + "allOf": [ + { "$ref": "#/components/schemas/Script" }, + { + "required": ["code"], + "properties": { + "code": { + "type": "string", + "description": "The source code of the inline script." + } + } + } + ] + } + }, + "requestBodies": {}, + "securitySchemes": {}, + "links": {}, + "callbacks": {} + } +} diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift index 982170b324..df891d105c 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift @@ -120,6 +120,18 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer { for downloadAsset in context.registeredDownloadsAssets(for: bundleID) { try copyAsset(downloadAsset, to: downloadsDirectory) } + + // Create custom scripts directory if needed. Do not append the bundle identifier. + let scriptsDirectory = targetFolder + .appendingPathComponent("custom-scripts", isDirectory: true) + if !fileManager.directoryExists(atPath: scriptsDirectory.path) { + try fileManager.createDirectory(at: scriptsDirectory, withIntermediateDirectories: true, attributes: nil) + } + + // Copy all registered custom scripts to the output directory. + for customScript in context.registeredCustomScripts(for: bundleID) { + try copyAsset(customScript, to: scriptsDirectory) + } // If the bundle contains a `header.html` file, inject a