├── .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 | --------------------------------------------------------------------------------