├── .gitignore
├── Tests
└── VaporDocCTests
│ └── VaporDocCTests.swift
├── .github
└── workflows
│ ├── pr.yml
│ └── deploy.yml
├── Dockerfile
├── README.md
├── Sources
├── Run
│ └── main.swift
└── VaporDocC
│ ├── VaporDocC.docc
│ └── VaporDocC.md
│ └── VaporDocCMiddleware.swift
├── Package@swift-5.5.swift
├── Package.swift
├── .swiftpm
└── xcode
│ └── xcshareddata
│ └── xcschemes
│ ├── VaporDocC.xcscheme
│ ├── Run.xcscheme
│ └── VaporDocC-Package.xcscheme
└── Package.resolved
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/Tests/VaporDocCTests/VaporDocCTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import VaporDocC
3 |
4 | final class VaporDocCTests: XCTestCase {
5 | func testExample() {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 | // XCTAssertEqual(VaporDocC().text, "Hello, World!")
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request Checks
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | build_image:
7 | name: Build Image
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 |
12 | - name: Docker Build
13 | run: docker build . -t ghcr.io/josephduffy/vapordocc:latest
14 |
15 | tests:
16 | name: Tests
17 | runs-on: macos-latest
18 |
19 | steps:
20 | - uses: actions/checkout@v2
21 |
22 | - name: SwiftPM tests
23 | run: swift test
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM swift:5.3 as builder
2 |
3 | WORKDIR /build
4 |
5 | COPY Sources Sources
6 | COPY Tests Tests
7 | COPY Package.swift Package.swift
8 | COPY Package.resolved Package.resolved
9 |
10 | RUN swift package resolve
11 | RUN swift build --product Run --configuration release --enable-test-discovery
12 | RUN ln -s `swift build --configuration release --show-bin-path` /build/bin
13 |
14 | FROM swift:5.3
15 |
16 | RUN mkdir /app
17 | COPY --from=builder /build/bin/Run /app/Run
18 | ENV DOCS_ARCHIVE /docs
19 | EXPOSE 8080
20 | CMD /app/Run
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Trigger Deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build_image:
10 | name: Build Image
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 |
15 | - name: Docker Build
16 | run: docker build . -t ghcr.io/josephduffy/vapordocc:latest
17 |
18 | - name: Login to GitHub Container Registry
19 | uses: docker/login-action@v1
20 | with:
21 | registry: ghcr.io
22 | username: ${{ github.repository_owner }}
23 | password: ${{ secrets.GITHUB_TOKEN }}
24 |
25 | - name: Docker Push
26 | run: docker push ghcr.io/josephduffy/vapordocc:latest
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VaporDocC
2 |
3 | `VaporDocC` provides middleware for use with [Vapor](https://github.com/vapor/vapor). To initialise the middleware pass in the path to your .doccarchive, e.g.:
4 |
5 | ```swift
6 | let archiveURL = URL(fileURLWithPath: "/path/to/VaporDocC.doccarchive")
7 | app.middleware.use(VaporDocCMiddleware(archivePath: archiveURL))
8 | ```
9 |
10 | Documentation – hosted by the docker image included in this repo – is available at [vapordocc.josephduffy.co.uk](https://vapordocc.josephduffy.co.uk/).
11 |
12 | ## Docker
13 |
14 | A docker image that wraps a Vapor app using the `VaporDocCMiddleware` is provided at [ghcr.io/josephduffy/vapordocc](https://github.com/users/JosephDuffy/packages/container/package/vapordocc).
15 |
16 | To run locally mount the docs at `/docs`, e.g.:
17 |
18 | ```shell
19 | docker run -p 8080:8080 -v /path/to/VaporDocC.doccarchive:/docs ghcr.io/josephduffy/vapordocc
20 | ```
21 |
--------------------------------------------------------------------------------
/Sources/Run/main.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 | import VaporDocC
3 |
4 | var env = try Environment.detect()
5 | try LoggingSystem.bootstrap(from: &env)
6 | let app = Application(env)
7 | defer { app.shutdown() }
8 | app.http.server.configuration.hostname = "0.0.0.0"
9 |
10 | guard let archivePath = ProcessInfo.processInfo.environment["DOCS_ARCHIVE"] else {
11 | app.logger.critical("The DOCS_ARCHIVE environment variable is required")
12 | exit(1)
13 | }
14 | let archiveURL = URL(fileURLWithPath: archivePath)
15 |
16 | let redirectRoot = ProcessInfo.processInfo.environment["REDIRECT_ROOT"]
17 | let redirectMissingTrailingSlash = ProcessInfo.processInfo.environment["REDIRECT_MISSING_TRAILING_SLASH"] == "TRUE"
18 |
19 | let middleware = VaporDocCMiddleware(
20 | archivePath: archiveURL,
21 | redirectRoot: redirectRoot,
22 | redirectMissingTrailingSlash: redirectMissingTrailingSlash
23 | )
24 | app.middleware.use(middleware)
25 | try app.run()
26 |
--------------------------------------------------------------------------------
/Package@swift-5.5.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "VaporDocC",
6 | platforms: [
7 | .macOS(.v10_15),
8 | ],
9 | products: [
10 | .executable(name: "Run", targets: ["Run"]),
11 | .library(name: "VaporDocC", targets: ["VaporDocC"]),
12 | ],
13 | dependencies: [
14 | .package(name: "vapor", url: "https://github.com/vapor/Vapor.git", from: "4.0.0"),
15 | ],
16 | targets: [
17 | .executableTarget(
18 | name: "Run",
19 | dependencies: [
20 | .product(name: "Vapor", package: "vapor"),
21 | "VaporDocC",
22 | ]
23 | ),
24 | .target(
25 | name: "VaporDocC",
26 | dependencies: [
27 | .product(name: "Vapor", package: "vapor"),
28 | ]
29 | ),
30 | .testTarget(
31 | name: "VaporDocCTests",
32 | dependencies: ["VaporDocC"]
33 | ),
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "VaporDocC",
6 | platforms: [
7 | .macOS(.v10_15),
8 | ],
9 | products: [
10 | .executable(name: "Run", targets: ["Run"]),
11 | .library(name: "VaporDocC", targets: ["VaporDocC"]),
12 | ],
13 | dependencies: [
14 | .package(name: "vapor", url: "https://github.com/vapor/Vapor.git", from: "4.0.0"),
15 | ],
16 | targets: [
17 | .target(
18 | name: "Run",
19 | dependencies: [
20 | .product(name: "Vapor", package: "vapor"),
21 | "VaporDocC",
22 | ]
23 | ),
24 | .target(
25 | name: "VaporDocC",
26 | dependencies: [
27 | .product(name: "Vapor", package: "vapor"),
28 | ],
29 | exclude: ["VaporDocC.docc"]
30 | ),
31 | .testTarget(
32 | name: "VaporDocCTests",
33 | dependencies: ["VaporDocC"]
34 | ),
35 | ]
36 | )
37 |
--------------------------------------------------------------------------------
/Sources/VaporDocC/VaporDocC.docc/VaporDocC.md:
--------------------------------------------------------------------------------
1 | # ``VaporDocC``
2 |
3 | Vapor middleware that serves files from a DocC archive.
4 |
5 | ## Overview
6 |
7 | Initialise the middleware, providing the path to your DocC archive, e.g.:
8 |
9 | ```swift
10 | let archiveURL = URL(fileURLWithPath: "/path/to/VaporDocC.doccarchive")
11 | app.middleware.use(VaporDocCMiddleware(archivePath: archiveURL))
12 | ```
13 |
14 | By default ``VaporDocCMiddleware`` will map requests with hardcoded prefixes to the DocC archive, following the rules outlined in the .htaccess file from the [WWDC21 talk "Host and automate your DocC documentation"](https://developer.apple.com/wwdc21/10236):
15 |
16 | ```apacheconf
17 | # Enable custom routing.
18 | RewriteEngine On
19 |
20 | # Route documentation and tutorial pages.
21 | RewriteRule ^(documentation|tutorials)\/.*$ SlothCreator.doccarchive/index.html [L]
22 |
23 | # Route files within the documentation archive.
24 | RewriteRule ^(css|js|data|images|downloads|favicon\.ico|favicon\.svg|img|theme-settings\.json|videos)\/.*$ SlothCreator.doccarchive/$0 [L]
25 | ```
26 |
27 | The only difference is that `VaporDocCMiddleware` does not require a trailing slash when matching `favicon.ico`, `favicon.svg`, or `theme-settings.json`.
28 |
29 | ## Topics
30 |
31 | ### Middleware
32 |
33 | - ``VaporDocCMiddleware``
34 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/VaporDocC.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
44 |
50 |
51 |
57 |
58 |
59 |
60 |
62 |
63 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Run.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
69 |
75 |
76 |
77 |
78 |
84 |
86 |
92 |
93 |
94 |
95 |
97 |
98 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/VaporDocC-Package.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
61 |
67 |
68 |
69 |
70 |
71 |
81 |
82 |
88 |
89 |
90 |
91 |
97 |
98 |
104 |
105 |
106 |
107 |
109 |
110 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "async-http-client",
6 | "repositoryURL": "https://github.com/swift-server/async-http-client.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "8ccba7328d178ac05a1a9803cf3f2c6660d2f826",
10 | "version": "1.3.0"
11 | }
12 | },
13 | {
14 | "package": "async-kit",
15 | "repositoryURL": "https://github.com/vapor/async-kit.git",
16 | "state": {
17 | "branch": null,
18 | "revision": "c1de408100a2f2e4ab2ea06512e8635bc1a59144",
19 | "version": "1.3.1"
20 | }
21 | },
22 | {
23 | "package": "console-kit",
24 | "repositoryURL": "https://github.com/vapor/console-kit.git",
25 | "state": {
26 | "branch": null,
27 | "revision": "cfe8bcd58f74ffecb4f536d8237de146b634ecd3",
28 | "version": "4.2.6"
29 | }
30 | },
31 | {
32 | "package": "multipart-kit",
33 | "repositoryURL": "https://github.com/vapor/multipart-kit.git",
34 | "state": {
35 | "branch": null,
36 | "revision": "8c666b781c1a0ea1873b544da0c2b463a059b2ec",
37 | "version": "4.1.0"
38 | }
39 | },
40 | {
41 | "package": "routing-kit",
42 | "repositoryURL": "https://github.com/vapor/routing-kit.git",
43 | "state": {
44 | "branch": null,
45 | "revision": "a0801a36a6ad501d5ad6285cbcd4774de6b0a734",
46 | "version": "4.3.0"
47 | }
48 | },
49 | {
50 | "package": "swift-backtrace",
51 | "repositoryURL": "https://github.com/swift-server/swift-backtrace.git",
52 | "state": {
53 | "branch": null,
54 | "revision": "54a65d6391a1467a896d0d351ff2de6f469ee53c",
55 | "version": "1.2.3"
56 | }
57 | },
58 | {
59 | "package": "swift-crypto",
60 | "repositoryURL": "https://github.com/apple/swift-crypto.git",
61 | "state": {
62 | "branch": null,
63 | "revision": "3bea268b223651c4ab7b7b9ad62ef9b2d4143eb6",
64 | "version": "1.1.6"
65 | }
66 | },
67 | {
68 | "package": "swift-log",
69 | "repositoryURL": "https://github.com/apple/swift-log.git",
70 | "state": {
71 | "branch": null,
72 | "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
73 | "version": "1.4.2"
74 | }
75 | },
76 | {
77 | "package": "swift-metrics",
78 | "repositoryURL": "https://github.com/apple/swift-metrics.git",
79 | "state": {
80 | "branch": null,
81 | "revision": "e382458581b05839a571c578e90060fff499f101",
82 | "version": "2.1.1"
83 | }
84 | },
85 | {
86 | "package": "swift-nio",
87 | "repositoryURL": "https://github.com/apple/swift-nio.git",
88 | "state": {
89 | "branch": null,
90 | "revision": "d161bf658780b209c185994528e7e24376cf7283",
91 | "version": "2.29.0"
92 | }
93 | },
94 | {
95 | "package": "swift-nio-extras",
96 | "repositoryURL": "https://github.com/apple/swift-nio-extras.git",
97 | "state": {
98 | "branch": null,
99 | "revision": "de1c80ad1fdff1ba772bcef6b392c3ef735f39a6",
100 | "version": "1.8.0"
101 | }
102 | },
103 | {
104 | "package": "swift-nio-http2",
105 | "repositoryURL": "https://github.com/apple/swift-nio-http2.git",
106 | "state": {
107 | "branch": null,
108 | "revision": "e3e9024a632b40695ad5d3a85f9776a9b27a4bc6",
109 | "version": "1.17.0"
110 | }
111 | },
112 | {
113 | "package": "swift-nio-ssl",
114 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git",
115 | "state": {
116 | "branch": null,
117 | "revision": "6363cdf6d2fb863e82434f3c4618f4e896e37569",
118 | "version": "2.13.1"
119 | }
120 | },
121 | {
122 | "package": "swift-nio-transport-services",
123 | "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git",
124 | "state": {
125 | "branch": null,
126 | "revision": "657537c2cf1845f8d5201ecc4e48f21f21841128",
127 | "version": "1.10.0"
128 | }
129 | },
130 | {
131 | "package": "Vapor",
132 | "repositoryURL": "https://github.com/vapor/Vapor.git",
133 | "state": {
134 | "branch": null,
135 | "revision": "605d8c86cfc51cf875b9e09b3c8f55e4137b0b55",
136 | "version": "4.47.0"
137 | }
138 | },
139 | {
140 | "package": "websocket-kit",
141 | "repositoryURL": "https://github.com/vapor/websocket-kit.git",
142 | "state": {
143 | "branch": null,
144 | "revision": "a2d26b3de8b3be292f3208d1c74024f76ac503da",
145 | "version": "2.1.3"
146 | }
147 | }
148 | ]
149 | },
150 | "version": 1
151 | }
152 |
--------------------------------------------------------------------------------
/Sources/VaporDocC/VaporDocCMiddleware.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 |
3 | /// Vapor middleware that serves files from a DocC archive.
4 | public struct VaporDocCMiddleware: Middleware {
5 | /// The path to the DocC archive.
6 | public let archivePath: URL
7 |
8 | /// The path to redirect a request to the root (`/`) to. When `nil`
9 | /// no redirection will occur.
10 | public let redirectRoot: String?
11 |
12 | /// When `true` the `/documentation` and `/tutorials` endpoints will
13 | /// be redirected to `/documentation/` and `/tutorials/` respectively.
14 | public let redirectMissingTrailingSlash: Bool
15 |
16 | /// The website prefix. If DocC supports being hosted outside
17 | /// of the root directory this property will become public.
18 | private let prefix: String = "/"
19 |
20 | /// Create a new middleware that serves files from the DocC archive at ``archivePath``.
21 | ///
22 | /// When the ``redirectMissingTrailingSlash`` parameter is `true` the `/documentation` and `/tutorials`
23 | /// endpoints will be redirected to `/documentation/` and `/tutorials/` respectively.
24 | ///
25 | /// - Parameter archivePath: The path to the DocC archive.
26 | /// - Parameter redirectRoot: When non-nil the root (`/`) will be redirected to the provided path. Defaults to `nil.`
27 | /// - Parameter redirectMissingTrailingSlash: When `true` paths the require trailing slashes will be redirected to include the trailing slash. Defaults to `false`.
28 | public init(archivePath: URL, redirectRoot: String? = nil, redirectMissingTrailingSlash: Bool = false) {
29 | self.archivePath = archivePath
30 | self.redirectRoot = redirectRoot
31 | self.redirectMissingTrailingSlash = redirectMissingTrailingSlash
32 | }
33 |
34 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture {
35 | guard var path = request.url.path.removingPercentEncoding else {
36 | return request.eventLoop.makeFailedFuture(Abort(.badRequest))
37 | }
38 |
39 | guard !path.contains("../") else {
40 | return request.eventLoop.makeFailedFuture(Abort(.forbidden))
41 | }
42 |
43 | guard path.hasPrefix(self.prefix) else {
44 | return request.eventLoop.makeFailedFuture(Abort(.forbidden))
45 | }
46 |
47 | if path == self.prefix, let redirectRoot = redirectRoot {
48 | return request.eventLoop.makeSucceededFuture(
49 | request.redirect(to: redirectRoot)
50 | )
51 | }
52 |
53 | path = String(path.dropFirst(self.prefix.count))
54 |
55 | let indexPrefixes = [
56 | "documentation",
57 | "tutorials",
58 | ]
59 |
60 | for indexPrefix in indexPrefixes where path.hasPrefix(indexPrefix) {
61 | if indexPrefixes.contains(path) {
62 | // No trailing slash on request
63 | if redirectMissingTrailingSlash {
64 | return request.eventLoop.makeSucceededFuture(
65 | request.redirect(to: self.prefix + path + "/")
66 | )
67 | } else {
68 | return next.respond(to: request)
69 | }
70 | }
71 |
72 | return serveStaticFileRelativeToArchive("index.html", request: request)
73 | }
74 |
75 | if path == "data/documentation.json" {
76 | if FileManager.default.fileExists(atPath: archivePath.appendingPathComponent("data/documentation.json", isDirectory: true).path) {
77 | return serveStaticFileRelativeToArchive("data/documentation.json", request: request)
78 | }
79 |
80 | request.logger.info("\(self.prefix)data/documentation.json was not found, attempting to find product's JSON in /data/documentation/ directory")
81 |
82 | // The docs generated by Xcode 13.0 beta 1 request "/data/documentation.json" but the
83 | // generated archive puts this file under "/data/documentation/{product_name}.json".
84 | // Feedback logged under FB9156617.
85 | let documentationPath = archivePath.appendingPathComponent("data/documentation", isDirectory: true)
86 | do {
87 | let contents = try FileManager.default.contentsOfDirectory(atPath: documentationPath.path)
88 | guard let productJSON = contents.first(where: { $0.hasSuffix(".json") }) else {
89 | return next.respond(to: request)
90 | }
91 |
92 | return serveStaticFileRelativeToArchive("data/documentation/\(productJSON)", request: request)
93 | } catch {
94 | return next.respond(to: request)
95 | }
96 | }
97 |
98 | let staticFiles = [
99 | "favicon.ico",
100 | "favicon.svg",
101 | "theme-settings.json",
102 | ]
103 |
104 | for staticFile in staticFiles where path == staticFile {
105 | return serveStaticFileRelativeToArchive(staticFile, request: request)
106 | }
107 |
108 | let staticFilePrefixes = [
109 | "css/",
110 | "js/",
111 | "data/",
112 | "images/",
113 | "downloads/",
114 | "img/",
115 | "videos/",
116 | ]
117 |
118 | for staticFilePrefix in staticFilePrefixes where path.hasPrefix(staticFilePrefix) {
119 | return serveStaticFileRelativeToArchive(path, request: request)
120 | }
121 |
122 | return next.respond(to: request)
123 | }
124 |
125 | private func serveStaticFileRelativeToArchive(_ staticFilePath: String, request: Request) -> EventLoopFuture {
126 | let staticFilePath = archivePath.appendingPathComponent(staticFilePath, isDirectory: false)
127 | return request.eventLoop.makeSucceededFuture(
128 | request
129 | .fileio
130 | .streamFile(
131 | at: staticFilePath.path
132 | )
133 | )
134 | }
135 | }
136 |
--------------------------------------------------------------------------------