Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom scripts #1056

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: "/")!,
Expand All @@ -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
Expand All @@ -144,14 +150,17 @@ 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)
self.tutorialsContainerReference = tutorialTableOfContentsContainer.appendingPath(urlReadablePath(info.displayName))
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: "/")!,
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 12 additions & 3 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,7 @@ public class DocumentationContext {

private static let supportedImageExtensions: Set<String> = ["png", "jpg", "jpeg", "svg", "gif"]
private static let supportedVideoExtensions: Set<String> = ["mov", "mp4"]
private static let supportedScriptExtensions: Set<String> = ["js"]

// TODO: Move this functionality to ``DocumentationBundleFileTypes`` (rdar://68156425).

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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<Article>]
private typealias ArticlesTuple = (articles: Articles, rootPageArticles: Articles)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -91,7 +92,8 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider {
miscResourceURLs: miscResources,
customHeader: customHeader,
customFooter: customFooter,
themeSettings: themeSettings
themeSettings: themeSettings,
customScripts: customScripts
)
}

Expand Down Expand Up @@ -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.")
Expand Down
98 changes: 98 additions & 0 deletions Sources/SwiftDocC/SwiftDocC.docc/Resources/CustomScripts.spec.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not append the bundle ID here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • So that projects with combined documentation of multiple targets (when that lands) can share scripts between the documentation bundles of each target. I suspect that authors of packages with multiple targets will want scripts to be shared: nobody wants to set up their MathJax scripts once per target.
  • In practice, even in documentation webpages for multiple targets, custom scripts loaded into the browser are global. For example, scripts that define the same global function can conflict with each other irrespective of the bundle they were loaded from. So namespacing the script files by bundle ID is moot.

(Also, if the relative path to the custom-scripts folder is always "/custom-scripts" then it’s easier to find it in the renderer code.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. That's not how combined documentation works (which is already available as an experimental feature). Non-content files aren't automatically included in the combined archive. If this was treated as an asset, a collision between archives would be treated as an error. If this file is intended to be copied over into the combined archive, it should have an archive-unique prefix and this PR should update the merge command to copy over the expected files.

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 <template> into
// the `index.html` file using its contents. This will only be done if
Expand All @@ -145,6 +157,16 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer {
}
try fileManager.copyItem(at: themeSettings, to: targetFile)
}

// Copy the `custom-scripts.json` file into the output directory if one
// is provided.
if let customScripts = bundle.customScripts {
let targetFile = targetFolder.appendingPathComponent(customScripts.lastPathComponent, isDirectory: false)
if fileManager.fileExists(atPath: targetFile.path) {
try fileManager.removeItem(at: targetFile)
}
try fileManager.copyItem(at: customScripts, to: targetFile)
}
}

func consume(linkableElementSummaries summaries: [LinkDestinationSummary]) throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ struct FileRequestHandler: RequestHandlerFactory {
TopLevelAssetFileMetadata(filePath: "/favicon.ico", mimetype: "image/x-icon"),
TopLevelAssetFileMetadata(filePath: "/theme-settings.js", mimetype: "text/javascript"),
TopLevelAssetFileMetadata(filePath: "/theme-settings.json", mimetype: "application/json"),
TopLevelAssetFileMetadata(filePath: "/custom-scripts.json", mimetype: "application/json"),
]

/// Returns a Boolean value that indicates whether the given path is located inside an asset folder.
Expand Down
Loading