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 1 commit 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
24 changes: 7 additions & 17 deletions Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ public struct DocumentationBundle {
/// A custom JSON settings file used to theme renderer output.
public let themeSettings: URL?

/// A custom JSON settings file used to add custom scripts to the renderer output.
public let customScripts: URL?

/**
A URL prefix to be appended to the relative presentation URL.

Expand All @@ -108,6 +111,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 @@ -116,7 +120,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 @@ -126,29 +131,14 @@ public struct DocumentationBundle {
self.customHeader = customHeader
self.customFooter = customFooter
self.themeSettings = themeSettings
self.customScripts = customScripts
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)
self.technologyTutorialsRootReference = tutorialsRootReference.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")
Copy link
Contributor

Choose a reason for hiding this comment

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

FIY: Since the name of the replacement changed with the new parameter you may need to update the renamed portion of this deprecation annotation. Even if it's not needed, it's nice to do it since it enables the fixit for any code that still uses the deprecated API.

public init(
info: Info,
baseURL: URL = URL(string: "/")!,
attributedCodeListings: [String: AttributedCodeListing] = [:],
symbolGraphURLs: [URL],
markupURLs: [URL],
miscResourceURLs: [URL],
customHeader: URL? = nil,
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.attributedCodeListings = attributedCodeListings
}

Comment on lines -136 to -151
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a source breaking change. Like the deprecation message says; we can't remove this until after 6.1 is released. See the "Introducing source breaking changes" section of the contributions guidelines

Copy link
Author

@Lucca-mito Lucca-mito Feb 20, 2025

Choose a reason for hiding this comment

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

Hey @d-ronnqvist,

Thank you for the comments! And apologies for the delayed response.

I agree that I should not remove the deprecated init in this PR: in truth, I only removed the deprecated init in this first revision to ask for help in the PR discussion for how to best avoid making this breaking change. I’m unable to build swift-docc when both versions of the init exist; this happens only with my changes, and I’m not sure why.

To explain the issue I’m facing, consider as an example the following call to DocumentationBundle.init in ConvertService+DataProvider.swift:

bundles.append(
    DocumentationBundle(
        info: info,
        symbolGraphURLs: symbolGraphURLs,
        markupURLs: markupFileURLs,
        miscResourceURLs: miscResourceURLs
    )
)

The only difference between the deprecated DocumentationBundle init and the non-deprecated init is that the deprecated init has an attributedCodeListings parameter. That parameter is optional, so in theory, the call above could be either to the non-deprecated init or to deprecated init. However, the compiler disambiguates the call by prioritizing the non-deprecated init.

But when I added customScripts as an optional parameter to the non-deprecated init — and changed the deprecated init to pass customScripts: nil to the non-deprecated init — I got an “Ambiguous use of 'init'” error at the call above.

But why? Surely if the compiler could disambiguate the init call before I added the customScripts parameter (by prioritizing the non-deprecated init) then it should be able to do the same after I added customScripts, no? Maybe the compiler’s disambiguation rules are more nuanced than I realize.

Regardless, unless there’s a better solution to this “Ambiguous use of 'init'” problem I'll just manually disambiguate the calls by explicitly specifying customScripts: nil where appropriate. I can also add a comment clarifying that the explicit customScripts: nil can be removed after Swift 6.1 (when the deprecated init is removed), like so:

bundles.append(
    DocumentationBundle(
        info: info,
        symbolGraphURLs: symbolGraphURLs,
        markupURLs: markupFileURLs,
        miscResourceURLs: miscResourceURLs,
        customScripts: nil  // Explicit `customScripts: nil` can be removed after the deprecated `DocumentationBundle.init` is removed.
    )
)

If this would not be the best way to circumvent the init ambiguity, please let me know. Thanks!

Copy link
Contributor

Choose a reason for hiding this comment

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

IIRC you can add the @_disfavoredOverload attribute to the deprecated initializer to avoid the ambiguity.

public private(set) var rootReference: ResolvedTopicReference

/// Default path to resolve symbol links.
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
9 changes: 9 additions & 0 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {

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 @@ -1729,6 +1730,14 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
public func registeredDownloadsAssets(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] {
return registeredAssets(inContexts: [DataAsset.Context.download], forBundleID: bundleIdentifier)
}

/// Returns a list of all the custom scripts that registered for a given `bundleIdentifier`.
///
/// - Parameter bundleIdentifier: The identifier of the bundle to return download assets for.
/// - Returns: A list of all the custom scripts for the given bundle.
public func registeredCustomScripts(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] {
Copy link
Contributor

Choose a reason for hiding this comment

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

I would prefer if this was package level access for now so that we don't have to make more source breaking changes to change it to not have a bundle ID parameter when #1059 lands.

Copy link
Author

Choose a reason for hiding this comment

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

Sounds good!

return registeredAssets(withExtensions: DocumentationContext.supportedScriptExtensions, forBundleID: bundleIdentifier)
}

typealias Articles = [DocumentationContext.SemanticResult<Article>]
private typealias ArticlesTuple = (articles: Articles, rootPageArticles: Articles)
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

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

FYI, the method of input discovery in this file is about to be deprecated in #1059 and won't be used by DocC anymore.

These same changes need to be implemented in DocumentationContext.InputProvider which is what DocC will use to discover its inputs after #1057 lands.

It would also be good to update DocumentationInputsProviderTests.testDiscoversSameFilesAsPreviousImplementation() to verify that both implementations discover the custom scripts file the same. (This can happen before either of those PRs are merged)

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

Expand Down Expand Up @@ -139,6 +141,10 @@ extension LocalFileSystemDataProvider {
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) }
}
}

fileprivate extension [FSNode] {
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(forBundleID: bundleIdentifier) {
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?

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(forBundleID: bundleIdentifier) {
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,49 @@ import XCTest

class DocumentationBundleFileTypesTests: XCTestCase {
func testIsCustomHeader() {
XCTAssertTrue(DocumentationBundleFileTypes.isCustomHeader(
URL(fileURLWithPath: "header.html")))
XCTAssertTrue(DocumentationBundleFileTypes.isCustomHeader(
URL(fileURLWithPath: "/header.html")))
XCTAssertFalse(DocumentationBundleFileTypes.isCustomHeader(
URL(fileURLWithPath: "header")))
XCTAssertFalse(DocumentationBundleFileTypes.isCustomHeader(
URL(fileURLWithPath: "/header.html/foo")))
XCTAssertFalse(DocumentationBundleFileTypes.isCustomHeader(
URL(fileURLWithPath: "footer.html")))
XCTAssertTrue(DocumentationBundleFileTypes.isCustomHeader(
URL(fileURLWithPath: "DocC.docc/header.html")))
test(whether: DocumentationBundleFileTypes.isCustomHeader, matchesFilesNamed: "header", withExtension: "html")
}

func testIsCustomFooter() {
XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
URL(fileURLWithPath: "footer.html")))
XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
URL(fileURLWithPath: "/footer.html")))
XCTAssertFalse(DocumentationBundleFileTypes.isCustomFooter(
URL(fileURLWithPath: "footer")))
XCTAssertFalse(DocumentationBundleFileTypes.isCustomFooter(
URL(fileURLWithPath: "/footer.html/foo")))
XCTAssertFalse(DocumentationBundleFileTypes.isCustomFooter(
URL(fileURLWithPath: "header.html")))
XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
URL(fileURLWithPath: "DocC.docc/footer.html")))
test(whether: DocumentationBundleFileTypes.isCustomFooter, matchesFilesNamed: "footer", withExtension: "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")))
test(whether: DocumentationBundleFileTypes.isThemeSettingsFile, matchesFilesNamed: "theme-settings", withExtension: "json")
}

func testIsCustomScriptsFile() {
test(whether: DocumentationBundleFileTypes.isCustomScriptsFile, matchesFilesNamed: "custom-scripts", withExtension: "json")
}

private func test(
whether predicate: (URL) -> Bool,
matchesFilesNamed fileName: String,
withExtension extension: String
) {
let fileNameWithExtension = "\(fileName).\(`extension`)"

let pathsThatShouldMatch = [
fileNameWithExtension,
"/\(fileNameWithExtension)",
"DocC/docc/\(fileNameWithExtension)",
"/a/b/\(fileNameWithExtension)"
].map { URL(fileURLWithPath: $0) }

let pathsThatShouldNotMatch = [
fileName,
"/\(fileNameWithExtension)/foo",
"/a/\(fileNameWithExtension)/bar",
"\(fileName).wrongextension",
"wrongname.\(`extension`)"
].map { URL(fileURLWithPath: $0) }

for url in pathsThatShouldMatch {
XCTAssertTrue(predicate(url))
}

for url in pathsThatShouldNotMatch {
XCTAssertFalse(predicate(url))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class ConvertActionStaticHostableTests: StaticHostingBaseTests {
_ = try await action.perform(logHandle: .none)

// Test the content of the output folder.
var expectedContent = ["data", "documentation", "tutorials", "downloads", "images", "metadata.json" ,"videos", "index.html", "index"]
var expectedContent = ["data", "documentation", "tutorials", "downloads", "images", "custom-scripts", "metadata.json", "videos", "index.html", "index"]
expectedContent += templateFolder.content.filter { $0 is Folder }.map{ $0.name }

let output = try fileManager.contentsOfDirectory(atPath: targetBundleURL.path)
Expand Down
1 change: 1 addition & 0 deletions Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3251,6 +3251,7 @@ class ConvertActionTests: XCTestCase {

XCTAssertEqual(fileSystem.dump(subHierarchyFrom: targetURL.path), """
Output.doccarchive/
├─ custom-scripts/
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: is it expected that we create this folder even if the user didn't pass any custom scripts?

Copy link
Author

Choose a reason for hiding this comment

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

Yes it is! Every subfolder of a documentation archive is created even when they are no files to add to the subfolder.

├─ data/
│ ╰─ documentation/
│ ╰─ something.json
Expand Down