Skip to content
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
9 changes: 8 additions & 1 deletion Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ 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.
///
/// This is used when a built documentation is hosted in a known location.
Expand All @@ -122,6 +126,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 @@ -130,7 +135,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 @@ -140,6 +146,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,12 @@ 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
}
}
15 changes: 12 additions & 3 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,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 @@ -1839,7 +1840,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 @@ -1852,7 +1853,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 @@ -1865,7 +1866,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 @@ -1877,6 +1878,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" }
}
}
]
},
"ExternalScript": {
"description": "A script at an external URL.",
"allOf": [
{ "$ref": "#/components/schemas/ScriptFromFile" },
{
"required": ["url"],
"properties": {
"url": { "type": "string" },
"integrity": { "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 @@ -126,6 +126,18 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
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.

Copy link
Author

Choose a reason for hiding this comment

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

Hey David,

Sorry for the confusion; I hadn’t read your combined-documentation proposals yet and was operating under some admittedly hasty guesswork. Having read them now — the second, lower-level pitch in particular — I wasn't able to find more information on how combined documentation handles assets.

So I looked through the swift-docc code. But I haven't yet been able to find code for merging documentation archives, or for handling assets during that merge (the closest I could find was this, which seems related to merging assets of documentation archives given the phrasing here), or for including additional "non-symbol content for the landing page of the combined documentation" (a feature of docc merge you described in the technical pitch that could be relevant here). I'm definitely missing something obvious so apologies for the question, but where in the swift-docc codebase does documentation-archive merging happen? Essentially, I'm trying to figure out:

  • How documentation-archive merging handles the theme-settings.json files of the archives being combined, so I can do the same to custom-scripts.json.
  • How documentation-archive merging handles images, so I can do the same to the individual JS scripts in the archive's custom-scripts directory. (Assuming that doing so wouldn't break custom scripts, which I'll also look into.)

Thanks!!

Copy link
Contributor

Choose a reason for hiding this comment

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

  • How documentation-archive merging handles the theme-settings.json files of the archives being combined, so I can do the same to custom-scripts.json.

The combined archive is assumed to want a consistent visual appearance across all its modules. Therefore it doesn't matter which theme-settings.json file it uses (but it only uses one of them).

  • How documentation-archive merging handles images, so I can do the same to the individual JS scripts in the archive's custom-scripts directory. (Assuming that doing so wouldn't break custom scripts, which I'll also look into.)

In a regular archive, images are already located in a subdirectory named after the documentation's identifier. This makes merging images trivial because each subdirectory can simply be copied into the combined archives "images" directory.

Copy link
Author

Choose a reason for hiding this comment

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

Got it, thanks!

I think it makes sense to only include one of the custom-scripts.json in the combined archive:

  1. Precedence: like you explained, that's how theme-settings.json works. custom-scripts.json behaving differently would be confusing.
  2. If module A depends on module B (say, from an external package), and module B declares in its custom-scripts.json that it wants to use custom script S in its documentation website, then that by no means suggests that the developers of module A want to use S in a combined documentation website. If the developers of module A wish to use S, they can include it themselves.
  3. Trying to respect multiple archives' custom scripts at the same time would often lead to undefined (and undesirable) behavior at runtime. After all, there would be multiple scripts modifying the webpage in no obvious order and without knowledge that other such scripts will run (or have run) as well.

And if we're only including one archive's custom-scripts.json in combined documentation, then we should similarly only include that archive's custom-scripts (the archive subdirectory containing the actual scripts). After all, to continue the example in point 2 above, it would make no sense to include the scripts from B since:

  • B's custom-scripts.json requiring those scripts (and specifying how they should be run) would be absent.
  • B's scripts are unwanted (and, given point 3, are likely actively harmful).

So we should only include the custom-scripts.json and the custom-scripts of one archive, meaning that there is no need to namespace either of them with a bundle ID.

Do you agree? If so, the next and last question is: where in the swift-docc codebase is the code for the "merge" action (so I can implement the copying of custom-scripts.json and custom-scripts to the combined archive in the same way as theme-settings.json is currently handled)?

Thanks again as always!

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 @@ -151,6 +163,16 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
}
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,51 @@ 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")))
assertThat(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")))
assertThat(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")))
assertThat(DocumentationBundleFileTypes.isThemeSettingsFile, matchesFilesNamed: "theme-settings", withExtension: "json")
}

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

private func assertThat(
_ predicate: (URL) -> Bool,
matchesFilesNamed fileName: String,
withExtension extension: String,
file: StaticString = #filePath,
line: UInt = #line
) {
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), file: file, line: line)
}

for url in pathsThatShouldNotMatch {
XCTAssertFalse(predicate(url), file: file, line: line)
}
}
}
Loading