Skip to content

Commit 5e237c0

Browse files
committed
Add support for custom scripts
1 parent a4b9902 commit 5e237c0

File tree

12 files changed

+205
-43
lines changed

12 files changed

+205
-43
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationBundle.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ public struct DocumentationBundle {
106106

107107
/// A custom JSON settings file used to theme renderer output.
108108
public let themeSettings: URL?
109+
110+
/// A custom JSON settings file used to add custom scripts to the renderer output.
111+
public let customScripts: URL?
112+
109113
/// A URL prefix to be appended to the relative presentation URL.
110114
///
111115
/// This is used when a built documentation is hosted in a known location.
@@ -122,6 +126,7 @@ public struct DocumentationBundle {
122126
/// - customHeader: A custom HTML file to use as the header for rendered output.
123127
/// - customFooter: A custom HTML file to use as the footer for rendered output.
124128
/// - themeSettings: A custom JSON settings file used to theme renderer output.
129+
/// - customScripts: A custom JSON settings file used to add custom scripts to the renderer output.
125130
public init(
126131
info: Info,
127132
baseURL: URL = URL(string: "/")!,
@@ -130,7 +135,8 @@ public struct DocumentationBundle {
130135
miscResourceURLs: [URL],
131136
customHeader: URL? = nil,
132137
customFooter: URL? = nil,
133-
themeSettings: URL? = nil
138+
themeSettings: URL? = nil,
139+
customScripts: URL? = nil
134140
) {
135141
self.info = info
136142
self.baseURL = baseURL
@@ -140,6 +146,7 @@ public struct DocumentationBundle {
140146
self.customHeader = customHeader
141147
self.customFooter = customFooter
142148
self.themeSettings = themeSettings
149+
self.customScripts = customScripts
143150
self.rootReference = ResolvedTopicReference(bundleID: info.id, path: "/", sourceLanguage: .swift)
144151
self.documentationRootReference = ResolvedTopicReference(bundleID: info.id, path: NodeURLGenerator.Path.documentationFolder, sourceLanguage: .swift)
145152
self.tutorialTableOfContentsContainer = ResolvedTopicReference(bundleID: info.id, path: NodeURLGenerator.Path.tutorialsFolder, sourceLanguage: .swift)

Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,12 @@ public enum DocumentationBundleFileTypes {
8484
public static func isThemeSettingsFile(_ url: URL) -> Bool {
8585
return url.lastPathComponent == themeSettingsFileName
8686
}
87+
88+
private static let customScriptsFileName = "custom-scripts.json"
89+
/// Checks if a file is `custom-scripts.json`.
90+
/// - Parameter url: The file to check.
91+
/// - Returns: Whether or not the file at `url` is `custom-scripts.json`.
92+
public static func isCustomScriptsFile(_ url: URL) -> Bool {
93+
return url.lastPathComponent == customScriptsFileName
94+
}
8795
}

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,6 +1783,7 @@ public class DocumentationContext {
17831783

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

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

@@ -1839,7 +1840,7 @@ public class DocumentationContext {
18391840
}
18401841
}
18411842

1842-
/// Returns a list of all the image assets that registered for a given `bundleIdentifier`.
1843+
/// Returns a list of all the image assets that registered for a given `bundleID`.
18431844
///
18441845
/// - Parameter bundleID: The identifier of the bundle to return image assets for.
18451846
/// - Returns: A list of all the image assets for the given bundle.
@@ -1852,7 +1853,7 @@ public class DocumentationContext {
18521853
registeredImageAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier))
18531854
}
18541855

1855-
/// Returns a list of all the video assets that registered for a given `bundleIdentifier`.
1856+
/// Returns a list of all the video assets that registered for a given `bundleID`.
18561857
///
18571858
/// - Parameter bundleID: The identifier of the bundle to return video assets for.
18581859
/// - Returns: A list of all the video assets for the given bundle.
@@ -1865,7 +1866,7 @@ public class DocumentationContext {
18651866
registeredVideoAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier))
18661867
}
18671868

1868-
/// Returns a list of all the download assets that registered for a given `bundleIdentifier`.
1869+
/// Returns a list of all the download assets that registered for a given `bundleID`.
18691870
///
18701871
/// - Parameter bundleID: The identifier of the bundle to return download assets for.
18711872
/// - Returns: A list of all the download assets for the given bundle.
@@ -1877,6 +1878,14 @@ public class DocumentationContext {
18771878
public func registeredDownloadsAssets(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] {
18781879
registeredDownloadsAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier))
18791880
}
1881+
1882+
/// Returns a list of all the custom scripts that registered for a given `bundleID`.
1883+
///
1884+
/// - Parameter bundleID: The identifier of the bundle to return custom scripts for.
1885+
/// - Returns: A list of all the custom scripts for the given bundle.
1886+
public func registeredCustomScripts(for bundleID: DocumentationBundle.Identifier) -> [DataAsset] {
1887+
return registeredAssets(withExtensions: DocumentationContext.supportedScriptExtensions, forBundleID: bundleID)
1888+
}
18801889

18811890
typealias Articles = [DocumentationContext.SemanticResult<Article>]
18821891
private typealias ArticlesTuple = (articles: Articles, rootPageArticles: Articles)

Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ extension DocumentationContext {
2525
/// ``DocumentationBundle/symbolGraphURLs`` | ``DocumentationBundleFileTypes/isSymbolGraphFile(_:)``
2626
/// ``DocumentationBundle/info`` | ``DocumentationBundleFileTypes/isInfoPlistFile(_:)``
2727
/// ``DocumentationBundle/themeSettings`` | ``DocumentationBundleFileTypes/isThemeSettingsFile(_:)``
28+
/// ``DocumentationBundle/customScripts`` | ``DocumentationBundleFileTypes/isCustomScriptsFile(_:)``
2829
/// ``DocumentationBundle/customHeader`` | ``DocumentationBundleFileTypes/isCustomHeader(_:)``
2930
/// ``DocumentationBundle/customFooter`` | ``DocumentationBundleFileTypes/isCustomFooter(_:)``
3031
/// ``DocumentationBundle/miscResourceURLs`` | Any file not already matched above.
@@ -165,7 +166,8 @@ extension DocumentationContext.InputsProvider {
165166
miscResourceURLs: foundContents.resources,
166167
customHeader: shallowContent.first(where: FileTypes.isCustomHeader),
167168
customFooter: shallowContent.first(where: FileTypes.isCustomFooter),
168-
themeSettings: shallowContent.first(where: FileTypes.isThemeSettingsFile)
169+
themeSettings: shallowContent.first(where: FileTypes.isThemeSettingsFile),
170+
customScripts: shallowContent.first(where: FileTypes.isCustomScriptsFile)
169171
)
170172
}
171173

Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider {
8383
let customHeader = findCustomHeader(bundleChildren)?.url
8484
let customFooter = findCustomFooter(bundleChildren)?.url
8585
let themeSettings = findThemeSettings(bundleChildren)?.url
86+
let customScripts = findCustomScripts(bundleChildren)?.url
8687

8788
return DocumentationBundle(
8889
info: info,
@@ -91,7 +92,8 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider {
9192
miscResourceURLs: miscResources,
9293
customHeader: customHeader,
9394
customFooter: customFooter,
94-
themeSettings: themeSettings
95+
themeSettings: themeSettings,
96+
customScripts: customScripts
9597
)
9698
}
9799

@@ -140,6 +142,10 @@ extension LocalFileSystemDataProvider: DocumentationWorkspaceDataProvider {
140142
private func findThemeSettings(_ bundleChildren: [FSNode]) -> FSNode.File? {
141143
return bundleChildren.firstFile { DocumentationBundleFileTypes.isThemeSettingsFile($0.url) }
142144
}
145+
146+
private func findCustomScripts(_ bundleChildren: [FSNode]) -> FSNode.File? {
147+
return bundleChildren.firstFile { DocumentationBundleFileTypes.isCustomScriptsFile($0.url) }
148+
}
143149
}
144150

145151
@available(*, deprecated, message: "This deprecated API will be removed after 6.2 is released.")
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
{
2+
"openapi": "3.0.0",
3+
"info": {
4+
"title": "Custom Scripts",
5+
"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.",
6+
"version": "0.0.1"
7+
},
8+
"paths": {},
9+
"components": {
10+
"schemas": {
11+
"Scripts": {
12+
"type": "array",
13+
"description": "An array of custom scripts, which is the top-level container in a custom-scripts.json file.",
14+
"items": {
15+
"oneOf": [
16+
{ "$ref": "#/components/schemas/ExternalScript" },
17+
{ "$ref": "#/components/schemas/LocalScript" },
18+
{ "$ref": "#/components/schemas/InlineScript" }
19+
]
20+
}
21+
},
22+
"Script": {
23+
"type": "object",
24+
"description": "An abstract schema representing any script, from which all three script types inherit.",
25+
"properties": {
26+
"type": {
27+
"type": "string",
28+
"description": "The `type` attribute of the HTML script element."
29+
},
30+
"run": {
31+
"type": "string",
32+
"enum": ["on-load", "on-navigate", "on-load-and-navigate"],
33+
"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."
34+
}
35+
}
36+
},
37+
"ScriptFromFile": {
38+
"description": "An abstract schema representing a script from an external or local file; that is, not an inline script.",
39+
"allOf": [
40+
{ "$ref": "#/components/schemas/Script" },
41+
{
42+
"properties": {
43+
"async": { "type": "boolean" },
44+
"defer": { "type": "boolean" }
45+
}
46+
}
47+
]
48+
},
49+
"ExternalScript": {
50+
"description": "A script at an external URL.",
51+
"allOf": [
52+
{ "$ref": "#/components/schemas/ScriptFromFile" },
53+
{
54+
"required": ["url"],
55+
"properties": {
56+
"url": { "type": "string" },
57+
"integrity": { "type": "string" }
58+
}
59+
}
60+
]
61+
},
62+
"LocalScript": {
63+
"description": "A script from a local file.",
64+
"allOf": [
65+
{ "$ref": "#/components/schemas/ScriptFromFile" },
66+
{
67+
"required": ["name"],
68+
"properties": {
69+
"name": {
70+
"type": "string",
71+
"description": "The name of the local script file, optionally including the '.js' extension."
72+
},
73+
}
74+
}
75+
]
76+
},
77+
"InlineScript": {
78+
"description": "A script whose source code is in the custom-scripts.json file itself.",
79+
"allOf": [
80+
{ "$ref": "#/components/schemas/Script" },
81+
{
82+
"required": ["code"],
83+
"properties": {
84+
"code": {
85+
"type": "string",
86+
"description": "The source code of the inline script."
87+
}
88+
}
89+
}
90+
]
91+
}
92+
},
93+
"requestBodies": {},
94+
"securitySchemes": {},
95+
"links": {},
96+
"callbacks": {}
97+
}
98+
}

Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
126126
for downloadAsset in context.registeredDownloadsAssets(for: bundleID) {
127127
try copyAsset(downloadAsset, to: downloadsDirectory)
128128
}
129+
130+
// Create custom scripts directory if needed. Do not append the bundle identifier.
131+
let scriptsDirectory = targetFolder
132+
.appendingPathComponent("custom-scripts", isDirectory: true)
133+
if !fileManager.directoryExists(atPath: scriptsDirectory.path) {
134+
try fileManager.createDirectory(at: scriptsDirectory, withIntermediateDirectories: true, attributes: nil)
135+
}
136+
137+
// Copy all registered custom scripts to the output directory.
138+
for customScript in context.registeredCustomScripts(for: bundleID) {
139+
try copyAsset(customScript, to: scriptsDirectory)
140+
}
129141

130142
// If the bundle contains a `header.html` file, inject a <template> into
131143
// the `index.html` file using its contents. This will only be done if
@@ -151,6 +163,16 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer, ExternalNodeConsumer {
151163
}
152164
try fileManager._copyItem(at: themeSettings, to: targetFile)
153165
}
166+
167+
// Copy the `custom-scripts.json` file into the output directory if one
168+
// is provided.
169+
if let customScripts = bundle.customScripts {
170+
let targetFile = targetFolder.appendingPathComponent(customScripts.lastPathComponent, isDirectory: false)
171+
if fileManager.fileExists(atPath: targetFile.path) {
172+
try fileManager.removeItem(at: targetFile)
173+
}
174+
try fileManager._copyItem(at: customScripts, to: targetFile)
175+
}
154176
}
155177

156178
func consume(linkableElementSummaries summaries: [LinkDestinationSummary]) throws {

Sources/SwiftDocCUtilities/PreviewServer/RequestHandler/FileRequestHandler.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ struct FileRequestHandler: RequestHandlerFactory {
107107
TopLevelAssetFileMetadata(filePath: "/favicon.ico", mimetype: "image/x-icon"),
108108
TopLevelAssetFileMetadata(filePath: "/theme-settings.js", mimetype: "text/javascript"),
109109
TopLevelAssetFileMetadata(filePath: "/theme-settings.json", mimetype: "application/json"),
110+
TopLevelAssetFileMetadata(filePath: "/custom-scripts.json", mimetype: "application/json"),
110111
]
111112

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

Tests/SwiftDocCTests/Infrastructure/DocumentationBundleFileTypesTests.swift

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,46 +13,51 @@ import XCTest
1313

1414
class DocumentationBundleFileTypesTests: XCTestCase {
1515
func testIsCustomHeader() {
16-
XCTAssertTrue(DocumentationBundleFileTypes.isCustomHeader(
17-
URL(fileURLWithPath: "header.html")))
18-
XCTAssertTrue(DocumentationBundleFileTypes.isCustomHeader(
19-
URL(fileURLWithPath: "/header.html")))
20-
XCTAssertFalse(DocumentationBundleFileTypes.isCustomHeader(
21-
URL(fileURLWithPath: "header")))
22-
XCTAssertFalse(DocumentationBundleFileTypes.isCustomHeader(
23-
URL(fileURLWithPath: "/header.html/foo")))
24-
XCTAssertFalse(DocumentationBundleFileTypes.isCustomHeader(
25-
URL(fileURLWithPath: "footer.html")))
26-
XCTAssertTrue(DocumentationBundleFileTypes.isCustomHeader(
27-
URL(fileURLWithPath: "DocC.docc/header.html")))
16+
assertThat(whether: DocumentationBundleFileTypes.isCustomHeader, matchesFilesNamed: "header", withExtension: "html")
2817
}
2918

3019
func testIsCustomFooter() {
31-
XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
32-
URL(fileURLWithPath: "footer.html")))
33-
XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
34-
URL(fileURLWithPath: "/footer.html")))
35-
XCTAssertFalse(DocumentationBundleFileTypes.isCustomFooter(
36-
URL(fileURLWithPath: "footer")))
37-
XCTAssertFalse(DocumentationBundleFileTypes.isCustomFooter(
38-
URL(fileURLWithPath: "/footer.html/foo")))
39-
XCTAssertFalse(DocumentationBundleFileTypes.isCustomFooter(
40-
URL(fileURLWithPath: "header.html")))
41-
XCTAssertTrue(DocumentationBundleFileTypes.isCustomFooter(
42-
URL(fileURLWithPath: "DocC.docc/footer.html")))
20+
assertThat(whether: DocumentationBundleFileTypes.isCustomFooter, matchesFilesNamed: "footer", withExtension: "html")
4321
}
4422

4523
func testIsThemeSettingsFile() {
46-
XCTAssertTrue(DocumentationBundleFileTypes.isThemeSettingsFile(
47-
URL(fileURLWithPath: "theme-settings.json")))
48-
XCTAssertTrue(DocumentationBundleFileTypes.isThemeSettingsFile(
49-
URL(fileURLWithPath: "/a/b/theme-settings.json")))
50-
51-
XCTAssertFalse(DocumentationBundleFileTypes.isThemeSettingsFile(
52-
URL(fileURLWithPath: "theme-settings.txt")))
53-
XCTAssertFalse(DocumentationBundleFileTypes.isThemeSettingsFile(
54-
URL(fileURLWithPath: "not-theme-settings.json")))
55-
XCTAssertFalse(DocumentationBundleFileTypes.isThemeSettingsFile(
56-
URL(fileURLWithPath: "/a/theme-settings.json/bar")))
24+
assertThat(whether: DocumentationBundleFileTypes.isThemeSettingsFile, matchesFilesNamed: "theme-settings", withExtension: "json")
25+
}
26+
27+
func testIsCustomScriptsFile() {
28+
assertThat(whether: DocumentationBundleFileTypes.isCustomScriptsFile, matchesFilesNamed: "custom-scripts", withExtension: "json")
29+
}
30+
31+
private func assertThat(
32+
whether predicate: (URL) -> Bool,
33+
matchesFilesNamed fileName: String,
34+
withExtension extension: String,
35+
file: StaticString = #filePath,
36+
line: UInt = #line
37+
) {
38+
let fileNameWithExtension = "\(fileName).\(`extension`)"
39+
40+
let pathsThatShouldMatch = [
41+
fileNameWithExtension,
42+
"/\(fileNameWithExtension)",
43+
"DocC/docc/\(fileNameWithExtension)",
44+
"/a/b/\(fileNameWithExtension)"
45+
].map { URL(fileURLWithPath: $0) }
46+
47+
let pathsThatShouldNotMatch = [
48+
fileName,
49+
"/\(fileNameWithExtension)/foo",
50+
"/a/\(fileNameWithExtension)/bar",
51+
"\(fileName).wrongextension",
52+
"wrongname.\(`extension`)"
53+
].map { URL(fileURLWithPath: $0) }
54+
55+
for url in pathsThatShouldMatch {
56+
XCTAssertTrue(predicate(url), file: file, line: line)
57+
}
58+
59+
for url in pathsThatShouldNotMatch {
60+
XCTAssertFalse(predicate(url), file: file, line: line)
61+
}
5762
}
5863
}

0 commit comments

Comments
 (0)