diff --git a/.github/workflows/run-checks.yml b/.github/workflows/run-checks.yml new file mode 100644 index 00000000..b10006fd --- /dev/null +++ b/.github/workflows/run-checks.yml @@ -0,0 +1,26 @@ +name: Run checks + +on: + pull_request: + branches: + - main + +jobs: + + run-checks: + # runs-on: macOS-latest + runs-on: self-hosted + steps: + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install Dependencies + run: | + brew install mint + mint install NickLockwood/SwiftFormat@0.53.4 --no-link + + - name: run script + run: ./scripts/run-checks.sh diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..2e3aa8a1 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,56 @@ +name: Run tests + +on: + push: + branches: + - main + paths: + - '**.swift' + pull_request: + branches: + - main + +jobs: + + macOS-tests: + runs-on: self-hosted + steps: + + - name: Checkout + uses: actions/checkout@v4 + + # - name: Cache + # uses: actions/cache@v3 + # with: + # path: server/.build + # key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + # restore-keys: ${{ runner.os }}-spm- + + - name: Test + run: swift test --parallel --enable-code-coverage + + linux-tests: + runs-on: ubuntu-latest + strategy: + matrix: + image: + - 'swift:5.10' + container: + image: ${{ matrix.image }} + steps: + + - name: Checkout + uses: actions/checkout@v4 + + # - name: Cache + # uses: actions/cache@v3 + # with: + # path: server/.build + # key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + # restore-keys: ${{ runner.os }}-spm- + + - name: Swift version + run: swift --version + + - name: Test + run: swift test --parallel --enable-code-coverage diff --git a/.gitignore b/.gitignore index 1c47cc1c..34e34205 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ .DS_Store .swiftpm .build -.vscode \ No newline at end of file +.vscode +.obsidian +**/dist +**/docs +Tests/sites/benchmark/ +Examples/try-o/ diff --git a/.swift-format b/.swift-format index c8502a73..ca8777e2 100644 --- a/.swift-format +++ b/.swift-format @@ -13,7 +13,7 @@ "lineBreakBeforeEachGenericRequirement" : true, "lineLength" : 80, "maximumBlankLines" : 1, - "prioritizeKeepingFunctionOutputTogether" : false, + "prioritizeKeepingFunctionOutputTogether" : true, "respectsExistingLineBreaks" : true, "rules" : { "AllPublicDeclarationsHaveDocumentation" : true, diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9a07ee32 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2018-2022 Tibor BΓΆdecs +Copyright (c) 2022-2024 Binary Birds Ltd. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile index be8007c9..c84ecd4a 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,29 @@ +SHELL=/bin/bash + build: swift build -update: - swift package update - release: swift build -c release test: swift test --parallel +test-with-coverage: + swift test --parallel --enable-code-coverage + clean: rm -rf .build -install: release - install ./.build/release/toucan /usr/local/bin/toucan - -uninstall: - rm /usr/local/bin/toucan +check: + ./scripts/run-checks.sh format: - swift-format -i -r ./Sources && swift-format -i -r ./Tests + ./scripts/run-swift-format.sh --fix + +install: + ./scripts/install-toucan.sh + +uninstall: + ./scripts/uninstall-toucan.sh -lint: - swift-format lint -r ./Sources && swift-format lint -r ./Tests diff --git a/Package.resolved b/Package.resolved index d72fe0ec..f50fa423 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,15 @@ { + "originHash" : "c23bc687d083cd85e9ff0a65835e264f6ed6932517d9e8704585b9b489f68911", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "0ae99db85b2b9d1e79b362bd31fd1ffe492f7c47", + "version" : "1.21.2" + } + }, { "identity" : "file-manager-kit", "kind" : "remoteSourceControl", @@ -21,46 +31,46 @@ { "identity" : "hummingbird", "kind" : "remoteSourceControl", - "location" : "https://github.com/hummingbird-project/hummingbird", + "location" : "https://github.com/hummingbird-project/hummingbird.git", "state" : { - "revision" : "02ab65198bbf1b0c2c1087008f2c0f308d804dd5", - "version" : "1.12.0" + "revision" : "6c568da113a7abe712e4a00883a85ff7745d6929", + "version" : "2.0.0" } }, { - "identity" : "hummingbird-core", + "identity" : "shell-kit", "kind" : "remoteSourceControl", - "location" : "https://github.com/hummingbird-project/hummingbird-core.git", + "location" : "https://github.com/binarybirds/shell-kit", "state" : { - "revision" : "502abf07438b0d254ca8a28a50aab2ac5bca4599", - "version" : "1.6.0" + "revision" : "8507fc5a1d4c9b4aca71fa69bf6030fb94229d37", + "version" : "1.0.1" } }, { - "identity" : "ink", + "identity" : "swift-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/JohnSundell/Ink", + "location" : "https://github.com/apple/swift-algorithms", "state" : { - "revision" : "bcc9f219900a62c4210e6db726035d7f03ae757b", - "version" : "0.6.0" + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.0" } }, { - "identity" : "splash", + "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/JohnSundell/Splash", + "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8", - "version" : "0.16.0" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" } }, { - "identity" : "swift-argument-parser", + "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", - "version" : "1.3.0" + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" } }, { @@ -73,12 +83,12 @@ } }, { - "identity" : "swift-backtrace", + "identity" : "swift-cmark", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-backtrace.git", + "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "revision" : "80746bdd0ac8a7d83aad5d89dac3cbf15de652e6", - "version" : "1.3.4" + "branch" : "gfm", + "revision" : "fc07ab5da47975c5ef5f336acadfc02e8f706d30" } }, { @@ -86,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", - "version" : "1.0.6" + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" } }, { @@ -95,17 +105,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "49b7617717a09f6b781c9a11e1628e3315d8d4fe", - "version" : "1.0.1" + "revision" : "11c756c5c4d7de0eeed8595695cadd7fa107aa19", + "version" : "1.1.1" } }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types", + "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "1827dc94bdab2eb5f2fc804e9b0cb43574282566", - "version" : "1.0.2" + "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", + "version" : "1.3.0" } }, { @@ -113,8 +123,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", - "version" : "1.5.3" + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-markdown", + "state" : { + "branch" : "main", + "revision" : "d21714073e0d16ba78eebdf36724863afc36871d" } }, { @@ -122,8 +141,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1", - "version" : "2.4.1" + "revision" : "e0165b53d49b413dd987526b641e05e246782685", + "version" : "2.5.0" + } + }, + { + "identity" : "swift-mustache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/swift-mustache", + "state" : { + "revision" : "cde358e364ab26f2b72a5f70613cfb0a574de6c9", + "version" : "2.0.0-beta.3" } }, { @@ -131,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "702cd7c56d5d44eeba73fdf83918339b26dc855c", - "version" : "2.62.0" + "revision" : "4c4453b489cf76e6b3b0f300aba663eb78182fad", + "version" : "2.70.0" } }, { @@ -140,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "798c962495593a23fdea0c0c63fd55571d8dff51", - "version" : "1.20.0" + "revision" : "05c36b57453d23ea63785d58a7dbc7b70ba1745e", + "version" : "1.23.0" } }, { @@ -149,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "3bd9004b9d685ed6b629760fc84903e48efec806", - "version" : "1.29.0" + "revision" : "b5f7062b60e4add1e8c343ba4eb8da2e324b3a94", + "version" : "1.34.0" } }, { @@ -158,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", - "version" : "2.25.0" + "revision" : "a9fa5efd86e7ce2e5c1b6de113262e58035ca251", + "version" : "2.27.1" } }, { @@ -167,8 +195,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "ebf8b9c365a6ce043bf6e6326a04b15589bd285e", - "version" : "1.20.0" + "revision" : "38ac8221dd20674682148d6451367f89c2652980", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" } }, { @@ -176,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-service-context.git", "state" : { - "revision" : "ce0141c8f123132dbd02fd45fea448018762df1b", - "version" : "1.0.0" + "revision" : "0c62c5b4601d6c125050b5c3a97f20cce881d32b", + "version" : "1.1.0" } }, { @@ -185,10 +222,37 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "22363fed316cd9942b56bcd1a1df8875df79b794", - "version" : "1.0.0-alpha.11" + "revision" : "24c800fb494fbee6e42bc156dc94232dc08971af", + "version" : "2.6.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "d2ba781702a1d8285419c15ee62fd734a9437ff5", + "version" : "1.3.2" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup", + "state" : { + "revision" : "e2d11208519549c2e5798d70190472045633f22f", + "version" : "2.7.3" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", + "version" : "5.1.3" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 1d805763..a5287417 100644 --- a/Package.swift +++ b/Package.swift @@ -1,80 +1,56 @@ -// swift-tools-version:5.9 +// swift-tools-version: 5.10 import PackageDescription let package = Package( name: "toucan", platforms: [ - .macOS(.v12), + .macOS(.v14), + .iOS(.v17), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), ], products: [ - .executable(name: "toucan", targets: ["ToucanCli"]), + .executable(name: "toucan-cli", targets: ["toucan-cli"]), .library(name: "ToucanSDK", targets: ["ToucanSDK"]), ], dependencies: [ - .package( - url: "https://github.com/hummingbird-project/hummingbird", - from: "1.12.0" - ), - .package( - url: "https://github.com/apple/swift-argument-parser", - from: "1.3.0" - ), - .package( - url: "https://github.com/JohnSundell/Ink", - from: "0.6.0" - ), - .package( - url: "https://github.com/JohnSundell/Splash", - from: "0.16.0" - ), - .package( - url: "https://github.com/BinaryBirds/file-manager-kit", - from: "0.1.0" - ), - .package( - url: "https://github.com/eonil/FSEvents", - branch: "master" - ), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-markdown", branch: "main"), + .package(url: "https://github.com/binarybirds/file-manager-kit", from: "0.1.0"), + .package(url: "https://github.com/binarybirds/shell-kit", from: "1.0.0"), + .package(url: "https://github.com/hummingbird-project/swift-mustache", from: "2.0.0-beta.3"), + .package(url: "https://github.com/jpsim/Yams", from: "5.0.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird", from: "2.0.0"), + .package(url: "https://github.com/scinfu/SwiftSoup", from: "2.6.0"), + + .package(url: "https://github.com/eonil/FSEvents", branch: "master"), ], targets: [ - .target(name: "ToucanSDK", dependencies: [ - .product( - name: "FileManagerKit", - package: "file-manager-kit" - ), - .product( - name: "Ink", - package: "ink" - ), - .product( - name: "Splash", - package: "splash" - ), - ]), - - .executableTarget(name: "ToucanCli", + .executableTarget( + name: "toucan-cli", dependencies: [ - .product( - name: "ArgumentParser", - package: "swift-argument-parser" - ), - .product( - name: "Hummingbird", - package: "hummingbird" - ), - .product( - name: "HummingbirdFoundation", - package: "hummingbird" - ), - .product( - name: "EonilFSEvents", - package: "FSEvents" - ), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "EonilFSEvents", package: "FSEvents"), + .product(name: "ShellKit", package: "shell-kit"), .target(name: "ToucanSDK"), ] ), - - .testTarget(name: "ToucanSDKTests", + .target( + name: "ToucanSDK", + dependencies: [ + .product(name: "Algorithms", package: "swift-algorithms"), + .product(name: "Markdown", package: "swift-markdown"), + .product(name: "FileManagerKit", package: "file-manager-kit"), + .product(name: "Mustache", package: "swift-mustache"), + .product(name: "SwiftSoup", package: "SwiftSoup"), + .product(name: "Yams", package: "yams"), + ] + ), + .testTarget( + name: "ToucanSDKTests", dependencies: [ .target(name: "ToucanSDK"), ] diff --git a/README.md b/README.md index 817aa41d..7e7b880d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,44 @@ # Toucan -Toucan is a static site generator written in Swift. +Toucan is a markdown-based Static Site Generator (SSG) written in Swift. -## Available commands +## Install -```sh +Clone or download the repository & run: + +```shell +# clone the repository & install toucan +git clone https://github.com/toucansites/toucan.git +cd toucan make install -toucan generate ./src ./dist -toucan watch ./src ./dist -toucan serve +# verify installation +which toucan +``` + +NOTE: version 1.0.0-alpha.1 only supports macOS, Linux support is coming soon. + +## Available commands + +### generate + +This command will generate all the static files, based on a source & destination directory, it is possible to override the base url via an optional parameter. + +```shell +toucan generate ./src ./docs --base-url http://localhost:3000/ +``` + +### watch + +Watch the source folder to any changes, to automatically re-generate the site. + +```shell +toucan watch ./src ./docs --base-url http://localhost:3000/ +``` + +### serve + +Serves a given folder using an optional port number. Your site will be available under `http://localhost:3000/`, if you run like this: + +```shell +toucan serve ./docs -p 3000 ``` diff --git a/Sources/ToucanCli/Commands/GenerateCommand.swift b/Sources/ToucanCli/Commands/GenerateCommand.swift deleted file mode 100644 index 58c9f272..00000000 --- a/Sources/ToucanCli/Commands/GenerateCommand.swift +++ /dev/null @@ -1,24 +0,0 @@ -import ArgumentParser -import ToucanSDK - -struct GenerateCommand: ParsableCommand { - - static var _commandName: String = "generate" - - @Argument(help: "The input directory (default: src).") - var input: String = "./src" - - @Argument(help: "The output directory (default: docs).") - var output: String = "./docs" - - @Option(name: .shortAndLong, help: "The base url to use.") - var baseUrl: String? = nil - - func run() throws { - let toucan = Toucan( - inputPath: input, - outputPath: output - ) - try toucan.generate(baseUrl) - } -} diff --git a/Sources/ToucanCli/Commands/ServeCommand.swift b/Sources/ToucanCli/Commands/ServeCommand.swift deleted file mode 100644 index 963b4cd2..00000000 --- a/Sources/ToucanCli/Commands/ServeCommand.swift +++ /dev/null @@ -1,70 +0,0 @@ -import ArgumentParser -import Foundation -import Hummingbird -import HummingbirdFoundation -import ToucanSDK - -protocol AppArguments { - var hostname: String { get } - var port: Int { get } - var path: String { get } -} - -extension HBApplication { - - func configure(_ args: AppArguments) throws { - - let workPath: String - if args.path.hasPrefix("/") { - workPath = args.path - } - else if args.path.hasPrefix("~") { - let homePath = FileManager.default.homeDirectoryForCurrentUser.path - workPath = homePath + "/" + String(args.path.dropFirst()) - } - else { - let currentPath = FileManager.default.currentDirectoryPath - workPath = currentPath + "/" + args.path - } - - let url = URL(fileURLWithPath: workPath).standardized - - print( - "πŸ€– Site preview available at: http://\(args.hostname):\(args.port)/ -> serving from: \(url.absoluteString) " - ) - - middleware.add( - HBFileMiddleware( - url.absoluteString, - searchForIndexHtml: true, - application: self - ) - ) - } -} - -struct ServeCommand: ParsableCommand, AppArguments { - - static var _commandName: String = "serve" - - @Option(name: .shortAndLong) - var hostname: String = "127.0.0.1" - - @Option(name: .shortAndLong) - var port: Int = 8080 - - @Argument(help: "The source folder to serve (defualt: docs).") - var path: String = "./docs" - - func run() throws { - let app = HBApplication( - configuration: .init( - address: .hostname(hostname, port: port), - serverName: "Toucan" - ) - ) - try app.configure(self) - try app.start() - app.wait() - } -} diff --git a/Sources/ToucanCli/Commands/WatchCommand.swift b/Sources/ToucanCli/Commands/WatchCommand.swift deleted file mode 100644 index 00fd5508..00000000 --- a/Sources/ToucanCli/Commands/WatchCommand.swift +++ /dev/null @@ -1,50 +0,0 @@ -import ArgumentParser -import Dispatch -import EonilFSEvents -import Foundation -import ToucanSDK - -struct WatchCommand: ParsableCommand { - - static var _commandName: String = "watch" - - @Argument(help: "The input directory (default: src).") - var input: String = "./src" - - @Argument(help: "The output directory (default: docs).") - var output: String = "./docs" - - @Option(name: .shortAndLong, help: "The base url to use.") - var baseUrl: String? = nil - - func run() throws { - let toucan = Toucan( - inputPath: input, - outputPath: output - ) - - try? toucan.generate(baseUrl) - let eventStream = try EonilFSEventStream( - pathsToWatch: [toucan.inputUrl.path], - sinceWhen: .now, - latency: 0, - flags: [], - handler: { event in - guard let flag = event.flag, flag == [] else { - return - } - print("Generating site...") - try? toucan.generate(baseUrl) - print("Site re-generated.") - } - ) - - eventStream.setDispatchQueue(DispatchQueue.main) - - try eventStream.start() - print( - "πŸ‘€ Watching: `\(toucan.inputUrl.path)` -> \(toucan.outputUrl.path)." - ) - dispatchMain() - } -} diff --git a/Sources/ToucanCli/ToucanCommand.swift b/Sources/ToucanCli/ToucanCommand.swift deleted file mode 100644 index c7712f30..00000000 --- a/Sources/ToucanCli/ToucanCommand.swift +++ /dev/null @@ -1,15 +0,0 @@ -import ArgumentParser -import ToucanSDK - -@main -struct ToucanCommand: ParsableCommand { - - static var configuration = CommandConfiguration( - subcommands: [ - GenerateCommand.self, - ServeCommand.self, - WatchCommand.self, - ], - defaultSubcommand: GenerateCommand.self - ) -} diff --git a/Sources/ToucanSDK/Extensions/Array+MapConcurrency.swift b/Sources/ToucanSDK/Extensions/Array+MapConcurrency.swift new file mode 100644 index 00000000..c9f01e05 --- /dev/null +++ b/Sources/ToucanSDK/Extensions/Array+MapConcurrency.swift @@ -0,0 +1,40 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 10/06/2024. +// + +import Foundation + +extension Array where Element: Sendable { + + func mapC( + concurrency: Int = ProcessInfo.processInfo.processorCount, + _ t: @escaping @Sendable (Element) async throws -> T + ) async throws -> [T] { + try await withThrowingTaskGroup(of: T.self) { group in + var result: [T] = [] + result.reserveCapacity(count) + + var iterator = makeIterator() + var i = 0 + while let element = iterator.next() { + if i >= concurrency { + if let res = try await group.next() { + result.append(res) + } + } + group.addTask { + try await t(element) + } + i += 1 + } + + for try await res in group { + result.append(res) + } + return result + } + } +} diff --git a/Sources/ToucanSDK/Extensions/Dictionary+Extensions.swift b/Sources/ToucanSDK/Extensions/Dictionary+Extensions.swift index 0bb0485b..5d4bb6e6 100644 --- a/Sources/ToucanSDK/Extensions/Dictionary+Extensions.swift +++ b/Sources/ToucanSDK/Extensions/Dictionary+Extensions.swift @@ -1,8 +1,138 @@ import Foundation -extension Dictionary { +extension Dictionary where Key == String, Value == Any { - static func + (lhs: Self, rhs: Self) -> Self { - lhs.merging(rhs) { (_, new) in new } + func sanitized() -> [String: Any] { + var result: [String: Any] = [:] + + for (key, value) in self { + if let nestedDict = value as? [String: Any] { + result[key] = nestedDict.sanitized() + } + else if let arrayValue = value as? [Any] { + result[key] = arrayValue.sanitized() + } + else if let anyHashableValue = value as? AnyHashable { + result[key] = anyHashableValue.base + } + else { + result[key] = value + } + } + return result + } +} + +extension Array where Element == Any { + + func sanitized() -> [Any] { + map { element in + if let nestedDict = element as? [String: Any] { + return nestedDict.sanitized() + } + if let anyHashableValue = element as? AnyHashable { + return anyHashableValue.base + } + if let arrayValue = element as? [Any] { + return arrayValue.sanitized() + } + return element + } + } +} + +/// This extension allows recursive merging of dictionaries with String keys and Any values. +extension Dictionary where Key == String, Value == Any { + + /// Recursively merges another `[String: Any]` dictionary into the current dictionary and returns a new dictionary. + /// + /// - Parameter other: The dictionary to merge into the current dictionary. + /// - Returns: A new dictionary with the merged contents. + func recursivelyMerged(with other: [String: Any]) -> [String: Any] { + var result = self + for (key, value) in other { + if let existingValue = result[key] as? [String: Any], + let newValue = value as? [String: Any] + { + result[key] = existingValue.recursivelyMerged(with: newValue) + } + else { + result[key] = value + } + } + return result + } +} + +/// An extension for `Dictionary` where the keys are `String` and the values are `Any`, providing utility methods to fetch values with specific types and key paths. +extension Dictionary where Key == String, Value == Any { + + /// Retrieves the value associated with the given key path and casts it to the specified type. + /// + /// - Parameters: + /// - keyPath: The key path string, where keys are separated by dots. + /// - type: The type to cast the value to. + /// - Returns: The value cast to the specified type, or `nil` if the key path is invalid or the value cannot be cast. + func value(_ keyPath: String, as type: T.Type) -> T? { + let keys = keyPath.split(separator: ".").map { String($0) } + + guard !keys.isEmpty else { + return nil + } + var currentDict: [String: Any] = self + + for key in keys.dropLast() { + if let dict = currentDict[key] as? [String: Any] { + currentDict = dict + } + else { + return nil + } + } + return currentDict[keys.last!] as? T + } + + /// Retrieves the dictionary associated with the given key path. + /// + /// - Parameter keyPath: The key path string, where keys are separated by dots. + /// - Returns: The dictionary at the specified key path, or an empty dictionary if the key path is invalid. + func dict(_ keyPath: String) -> [String: Any] { + value(keyPath, as: [String: Any].self) ?? [:] + } + + /// Retrieves the string associated with the given key path. + /// + /// - Parameter keyPath: The key path string, where keys are separated by dots. + /// - Returns: The string at the specified key path + func string(_ keyPath: String) -> String? { + value(keyPath, as: String.self) + } + + /// Retrieves the integer associated with the given key path. + /// + /// - Parameter keyPath: The key path string, where keys are separated by dots. + /// - Returns: The integer at the specified key path, or `nil` if the key path is invalid. + func int(_ keyPath: String) -> Int? { + value(keyPath, as: Int.self) + } + + /// Retrieves the integer associated with the given key path. + /// + /// - Parameter keyPath: The key path string, where keys are separated by dots. + /// - Returns: The boolean at the specified key path, or `nil` if the key path is invalid. + func bool(_ keyPath: String) -> Bool? { + value(keyPath, as: Bool.self) + } + + func date(_ keyPath: String) -> Date? { + guard let rawDate = value(keyPath, as: String.self) else { + return nil + } + let formatter = DateFormatters.contentLoader + return formatter.date(from: rawDate) + } + + func array(_ keyPath: String, as type: T.Type) -> [T] { + value(keyPath, as: [T].self) ?? [] } } diff --git a/Sources/ToucanSDK/Extensions/FileManager+Extensions.swift b/Sources/ToucanSDK/Extensions/FileManager+Extensions.swift new file mode 100644 index 00000000..ad2af20a --- /dev/null +++ b/Sources/ToucanSDK/Extensions/FileManager+Extensions.swift @@ -0,0 +1,54 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 27/05/2024. +// + +import Foundation + +extension FileManager { + + func createParentFolderIfNeeded(for url: URL) throws { + let folderPath = + "/" + + url + .pathComponents + .dropLast() + .joined(separator: "/") + + try createDirectory( + at: .init( + fileURLWithPath: folderPath + ) + ) + } + + func getURLs( + at url: URL, + for extensions: [String] + ) -> [URL] { + var result: [URL] = [] + let dirEnum = enumerator(atPath: url.path) + while let file = dirEnum?.nextObject() as? String { + let url = url.appendingPathComponent(file) + let ext = url.pathExtension.lowercased() + guard extensions.contains(ext) else { + continue + } + result.append(url) + } + return result + } + + func recursivelyListDirectory( + at url: URL + ) -> [String] { + var result: [String] = [] + let dirEnum = enumerator(atPath: url.path) + while let file = dirEnum?.nextObject() as? String { + result.append(file) + } + return result + } +} diff --git a/Sources/ToucanSDK/Extensions/String+Extensions.swift b/Sources/ToucanSDK/Extensions/String+Extensions.swift index 7ab235c5..fee34ca6 100644 --- a/Sources/ToucanSDK/Extensions/String+Extensions.swift +++ b/Sources/ToucanSDK/Extensions/String+Extensions.swift @@ -1,8 +1,235 @@ import Foundation +extension String? { + + var nilToEmpty: String { + switch self { + case .none: + return "" + case .some(let value): + return value + } + } + + var emptyToNil: String? { + switch self { + case .none: + return nil + case .some(let value): + return value.isEmpty ? nil : self + } + } +} + extension String { - func slugified() -> String { + var minifiedCss: String { + var css = self + let patterns = [ + "\n": "", + "\\s+": " ", + "\\s*:\\s*": ":", + "\\s*\\,\\s*": ",", + "\\s*\\{\\s": "{", + "\\s*\\}\\s*": "}", + "\\s*\\;\\s*": ";", + "\\{\\s*": "{", + ] + + for pattern in patterns { + let regex = try! NSRegularExpression( + pattern: pattern.key, + options: .caseInsensitive + ) + let range = NSRange(css.startIndex..., in: css) + css = regex.stringByReplacingMatches( + in: css, + options: [], + range: range, + withTemplate: pattern.value + ) + } + return css + } + + /// Generates a safe slug for the string, optionally with a given prefix. + /// + /// This method transforms the string into a "slug" format, handling special cases and + /// optional prefixes. If the string is "home", it returns an empty string. If no prefix + /// is provided or the prefix is empty, it returns the string itself transformed into + /// a slug. If a prefix is provided, it appends the prefix to the slug. + /// + /// - Parameters: + /// - prefix: An optional prefix to prepend to the slug. If `nil` or empty, the prefix is ignored. + /// - Returns: A slug version of the string, optionally prefixed. + /// + /// - Example: + /// ```swift + /// let text1 = "home" + /// let result1 = text1.safeSlug(prefix: nil) + /// print(result1) // Output: "" + /// + /// let text2 = "about/us" + /// let result2 = text2.safeSlug(prefix: nil) + /// print(result2) // Output: "about/us" + /// + /// let text3 = "contact" + /// let result3 = text3.safeSlug(prefix: "pages") + /// print(result3) // Output: "pages/contact" + /// + /// let text4 = "contact" + /// let result4 = text4.safeSlug(prefix: nil) + /// print(result4) // Output: "contact" + /// ``` + func safeSlug( + prefix: String? + ) -> String { + /// if it is empty then simply return + guard !isEmpty else { + return self + } + /// if there's no prefix, return the safe slug + guard let prefix, !prefix.isEmpty else { + return split(separator: "/").joined(separator: "/") + } + /// if there's a prefix, append it and return the safe slug + return (prefix.split(separator: "/") + split(separator: "/")) + .joined(separator: "/") + } + + /// Removes the front matter from a string if it starts with a "---" delimiter. + /// + /// This method checks if the string starts with the "---" delimiter. If it does, it splits the string + /// at each occurrence of the "---" delimiter, removes the first part (considered as front matter), + /// and joins the remaining parts back together using the "---" delimiter. If the string does not + /// start with the "---" delimiter, the original string is returned unchanged. + /// + /// - Returns: A new string with the front matter removed if it exists; otherwise, the original string. + /// + /// - Example: + /// ```swift + /// let text = """ + /// --- + /// title: Example + /// --- + /// Content goes here. + /// """ + /// let result = text.dropFrontMatter() + /// print(result) // Output: "\nContent goes here." + /// ``` + func dropFrontMatter() -> String { + if starts(with: "---") { + return + self + .split(separator: "---") + .dropFirst() + .joined(separator: "---") + } + return self + } + + /// + /// This method searches for the first occurrence of the `from` delimiter and the `to` delimiter, + /// and returns the substring that is found between these two delimiters. If either delimiter is not found, + /// the method returns `nil`. + /// + /// - Parameters: + /// - from: The starting delimiter. The method will look for the substring after this delimiter. + /// - to: The ending delimiter. The method will look for the substring before this delimiter. + /// - Returns: An optional `String` containing the substring between the `from` and `to` delimiters, or `nil` if the delimiters are not found. + /// + /// - Example: + /// ```swift + /// let text = "Hello [world]!" + /// if let result = text.slice(from: "[", to: "]") { + /// print(result) // Output: world + /// } + /// ``` + func slice( + from: String, + to: String + ) -> String? { + guard + let fromIndex = range(of: from)?.upperBound, + let toIndex = self[fromIndex...].range(of: to)?.lowerBound + else { + return nil + } + return String(self[fromIndex.. Bool { + /// Length of "yyyy-mm-dd-" + let prefixLength = 11 + guard count >= prefixLength else { + return false + } + let datePart = prefix(prefixLength) + guard datePart.hasSuffix("-") else { + return false + } + let dateComponents = datePart.split(separator: "-") + guard dateComponents.count == 3 else { + return false + } + /// use better component variable names + let year = dateComponents[0] + let month = dateComponents[1] + let day = dateComponents[2] + + /// check all the component lenghts + guard + year.count == 4, + month.count == 2, + day.count == 2 + else { + return false + } + /// check if all the components are numbers + guard + year.allSatisfy({ $0.isNumber }), + month.allSatisfy({ $0.isNumber }), + day.allSatisfy({ $0.isNumber }) + else { + return false + } + return true + } + + /// Returns a new string with everything after the last occurrence of the specified character dropped. + /// + /// - Parameter character: The character after which everything should be dropped. + /// - Returns: A new string with the substring up to the last occurrence of the character. + /// + /// This extension method drops everything after the last occurrence of the specified character. + func droppingEverythingAfterLastOccurrence( + of character: Character + ) -> String { + guard let lastIndex = self.lastIndex(of: character) else { + // If the character is not found, return the original string + return self + } + let substring = self[.. String { let allowed = CharacterSet( charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-_." ) @@ -27,26 +254,22 @@ extension String { return result } - func replacingTemplateVariables( - _ dictionary: [String: String] + func permalink( + baseUrl: String ) -> String { - var values: [String: String] = [:] - for (key, value) in dictionary { - values["{" + key + "}"] = value + let components = split(separator: "/").map(String.init) + if components.isEmpty { + return baseUrl } - return replacingOccurrences(values) - } - - func slice( - from: String, - to: String - ) -> String? { - guard - let fromIndex = range(of: from)?.upperBound, - let toIndex = self[fromIndex...].range(of: to)?.lowerBound - else { - return nil + if components.last?.split(separator: ".").count ?? 0 > 1 { + return baseUrl + components.joined(separator: "/") } - return String(self[fromIndex.. Self + // ) -> Self { + // block(self) + // } } diff --git a/Sources/ToucanSDK/Formatters/DateFormatters.swift b/Sources/ToucanSDK/Formatters/DateFormatters.swift new file mode 100644 index 00000000..47df2bad --- /dev/null +++ b/Sources/ToucanSDK/Formatters/DateFormatters.swift @@ -0,0 +1,36 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 07/05/2024. +// + +import Foundation + +struct DateFormatters { + + static var baseFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = .init(secondsFromGMT: 0) + return formatter + } + + static var contentLoader: DateFormatter { + let formatter = baseFormatter + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + } + + static var rss: DateFormatter { + let formatter = baseFormatter + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + return formatter + } + + static var sitemap: DateFormatter { + let formatter = baseFormatter + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } +} diff --git a/Sources/ToucanSDK/Generators/Home.swift b/Sources/ToucanSDK/Generators/Home.swift deleted file mode 100644 index 923d12b7..00000000 --- a/Sources/ToucanSDK/Generators/Home.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -struct Home { - - let contentsUrl: URL - let config: Config - let posts: [Post] - let templatesUrl: URL - let outputUrl: URL - - func generate() throws { - let homeUrl = contentsUrl.appendingPathComponent("home.md") - let homeMeta = try MetadataParser().parse(at: homeUrl) - - let homePosts = - posts.sorted { lhs, rhs in - return lhs.date > rhs.date - } - .prefix(20) - - let homeContents = - try homePosts.map { post in - let homePostTemplate = HomePostTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: post.meta, - date: config.formatter.string(from: post.date), - tags: post.tags, - userDefined: post.userDefined - ) - ) - return try homePostTemplate.render() - } - .joined(separator: "\n") - - let homeTemplate = HomeTemplate( - templatesUrl: templatesUrl, - context: .init( - title: homeMeta["title"] ?? "", - description: homeMeta["description"] ?? "", - contents: homeContents - ) - ) - - let indexTemplate = IndexTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: .init( - site: config.title, - baseUrl: config.baseUrl, - slug: "", - title: homeMeta["title"] ?? "", - description: homeMeta["description"] ?? "", - image: homeMeta["image"] ?? "" - ), - contents: try homeTemplate.render() - ) - ) - - let indexOutputUrl = - outputUrl - .appendingPathComponent("index") - .appendingPathExtension("html") - - try indexTemplate.render() - .write( - to: indexOutputUrl, - atomically: true, - encoding: .utf8 - ) - } -} diff --git a/Sources/ToucanSDK/Generators/NotFound.swift b/Sources/ToucanSDK/Generators/NotFound.swift deleted file mode 100644 index 6ecce5e4..00000000 --- a/Sources/ToucanSDK/Generators/NotFound.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -struct NotFound { - - let contentsUrl: URL - let config: Config - let posts: [Post] - let templatesUrl: URL - let outputUrl: URL - - func generate() throws { - let notFoundUrl = contentsUrl.appendingPathComponent("404.md") - let notFoundMeta = try MetadataParser().parse(at: notFoundUrl) - let html = try ContentParser() - .parse( - at: notFoundUrl, - baseUrl: config.baseUrl, - slug: "404", - assets: [] - ) - - let notFoundTemplate = NotFoundTemplate( - templatesUrl: templatesUrl, - context: .init( - title: notFoundMeta["title"] ?? "", - description: notFoundMeta["description"] ?? "", - contents: html - ) - ) - - let indexTemplate = IndexTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: .init( - site: config.title, - baseUrl: config.baseUrl, - slug: "404", - title: notFoundMeta["title"] ?? "", - description: notFoundMeta["description"] ?? "", - image: notFoundMeta["image"] ?? "" - ), - contents: try notFoundTemplate.render() - ) - ) - - let indexOutputUrl = - outputUrl - .appendingPathComponent("404") - .appendingPathExtension("html") - - try indexTemplate.render() - .write( - to: indexOutputUrl, - atomically: true, - encoding: .utf8 - ) - } -} diff --git a/Sources/ToucanSDK/Generators/Page.swift b/Sources/ToucanSDK/Generators/Page.swift deleted file mode 100644 index a8a0cc0d..00000000 --- a/Sources/ToucanSDK/Generators/Page.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -struct Page { - - let meta: Meta - let slug: String - let html: String - let templatesUrl: URL - let outputUrl: URL - let modificationDate: Date - - func generate() throws { - let pageTemplate = PageTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: meta, - contents: html - ) - ) - - let indexTemplate = IndexTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: meta, - contents: try pageTemplate.render() - ) - ) - - let htmlUrl = - outputUrl - .appendingPathComponent(slug) - .appendingPathExtension("html") - - try indexTemplate.render() - .write( - to: htmlUrl, - atomically: true, - encoding: .utf8 - ) - } -} diff --git a/Sources/ToucanSDK/Generators/Post.swift b/Sources/ToucanSDK/Generators/Post.swift deleted file mode 100644 index 5ef165f2..00000000 --- a/Sources/ToucanSDK/Generators/Post.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation - -struct Post { - - let meta: Meta - let slug: String - let date: Date - let tags: [String] - let html: String - let config: Config - let templatesUrl: URL - let outputUrl: URL - let modificationDate: Date - let userDefined: [String: String] - - func generate() throws { - let postTemplate = PostTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: meta, - contents: html, - date: config.formatter.string(from: date), - tags: tags, - userDefined: userDefined - ) - ) - - let indexTemplate = IndexTemplate( - templatesUrl: templatesUrl, - context: .init( - meta: meta, - contents: try postTemplate.render() - ) - ) - - let htmlUrl = - outputUrl - .appendingPathComponent(slug) - - if !FileManager.default.directoryExists(at: htmlUrl) { - try FileManager.default.createDirectory(at: htmlUrl) - } - - let fileUrl = - htmlUrl - .appendingPathComponent("index") - .appendingPathExtension("html") - - try indexTemplate.render() - .write( - to: fileUrl, - atomically: true, - encoding: .utf8 - ) - } -} diff --git a/Sources/ToucanSDK/Generators/RSS.swift b/Sources/ToucanSDK/Generators/RSS.swift deleted file mode 100644 index ce8f4822..00000000 --- a/Sources/ToucanSDK/Generators/RSS.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -struct RSS { - - let config: Config - let posts: [Post] - let outputUrl: URL - - func generate() throws { - let rssTemplate = RSSTemplate( - items: posts.map { - .init( - title: $0.meta.title, - description: $0.meta.description, - permalink: $0.meta.permalink, - date: $0.date - ) - }, - config: config - ) - - let rssUrl = - outputUrl - .appendingPathComponent("rss") - .appendingPathExtension("xml") - - try rssTemplate.render() - .write( - to: rssUrl, - atomically: true, - encoding: .utf8 - ) - } -} diff --git a/Sources/ToucanSDK/Generators/Sitemap.swift b/Sources/ToucanSDK/Generators/Sitemap.swift deleted file mode 100644 index c4da66c7..00000000 --- a/Sources/ToucanSDK/Generators/Sitemap.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -struct Sitemap { - - let config: Config - let pages: [Page] - let posts: [Post] - let outputUrl: URL - - func generate() throws { - let sitemapTemplate = SitemapTemplate( - items: pages.map { - .init(permalink: $0.meta.permalink, date: $0.modificationDate) - } - + posts.map { - .init( - permalink: $0.meta.permalink, - date: $0.modificationDate - ) - } - ) - - let sitemapUrl = - outputUrl - .appendingPathComponent("sitemap") - .appendingPathExtension("xml") - - try sitemapTemplate.render() - .write( - to: sitemapUrl, - atomically: true, - encoding: .utf8 - ) - } -} diff --git a/Sources/ToucanSDK/Markdown/MarkdownRenderer.swift b/Sources/ToucanSDK/Markdown/MarkdownRenderer.swift new file mode 100644 index 00000000..241ccdfb --- /dev/null +++ b/Sources/ToucanSDK/Markdown/MarkdownRenderer.swift @@ -0,0 +1,107 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import Markdown + +extension MarkdownRenderer.Delegate { + + func imageOverride(_ image: Image) -> String? { + nil + } + + func linkAttributes(_ link: String?) -> [String: String] { + [:] + } +} + +/// A HTML renderer for Markdown documents. +public struct MarkdownRenderer { + + /// A delegate for the HTML renderer. + public protocol Delegate { + /// Override an image tag. + func imageOverride(_ image: Image) -> String? + /// Provide attributes for a link. + func linkAttributes(_ link: String?) -> [String: String] + } + + let delegate: Delegate? + + /// Public init. + public init( + delegate: Delegate? = nil + ) { + self.delegate = delegate + } + + // MARK: - render api + + /// Render a Markdown string. + public func renderHTML( + markdown: String + ) -> String { + let document = Document( + parsing: markdown, + options: .parseBlockDirectives + ) + var htmlVisitor = MarkupToHTMLVisitor(delegate: delegate) + return htmlVisitor.visitDocument(document) + } + + public func renderToC( + markdown: String + ) -> [ToC] { + let document = Document( + parsing: markdown + ) + var headingsVisitor = MarkupToHXVisitor() + return Self.buildToC(headingsVisitor.visitDocument(document)) + } + + // MARK: - private + + static func buildToC( + _ headings: [MarkupToHXVisitor.HX] + ) -> [ToC] { + var result: [ToC] = [] + var stack: [ToC] = [] + + for heading in headings { + let newNode = ToC( + level: heading.level, + text: heading.text, + fragment: heading.fragment + ) + + // Find the correct parent for the current node + while let last = stack.last, last.level >= heading.level { + stack.removeLast() + } + + if let parent = stack.last { + // Append new node as a child of the last node in the stack + var updatedParent = parent + updatedParent.children.append(newNode) + stack[stack.count - 1] = updatedParent + if let index = result.firstIndex(where: { + $0.fragment == parent.fragment && $0.level == parent.level + }) { + result[index] = updatedParent + } + } + else { + // Add the new node to the result if it has no parent + result.append(newNode) + } + + // Add the new node to the stack + stack.append(newNode) + } + + return result + } +} diff --git a/Sources/ToucanSDK/Markdown/MarkupToHTMLVisitor.swift b/Sources/ToucanSDK/Markdown/MarkupToHTMLVisitor.swift new file mode 100644 index 00000000..08ebbd95 --- /dev/null +++ b/Sources/ToucanSDK/Markdown/MarkupToHTMLVisitor.swift @@ -0,0 +1,478 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import Markdown + +/// NOTE: https://www.markdownguide.org/basic-syntax/ + +private extension Markup { + + var isInsideList: Bool { + self is ListItemContainer || parent?.isInsideList == true + } +} + +private enum TagType { + case short + case standard +} + +private struct Attribute { + let key: String + let value: String +} + +private enum Contents { + case value(String) + case children(MarkupChildren) +} + +private extension [DirectiveArgument] { + + func getFirstValueBy(key name: String) -> String? { + first(where: { $0.name == name })?.value + } +} + +struct MarkupToHTMLVisitor: MarkupVisitor { + + typealias Result = String + + let delegate: MarkdownRenderer.Delegate? + + init( + delegate: MarkdownRenderer.Delegate? = nil + ) { + self.delegate = delegate + } + + // MARK: - private functions + + private mutating func tag( + name: String, + type: TagType = .standard, + attributes: [Attribute] = [], + content: Contents + ) -> Result { + let attributeString = + attributes + .map { #"\#($0.key)="\#($0.value)""# } + .joined(separator: " ") + + let tag = [name, attributeString] + .filter { !$0.isEmpty } + .joined(separator: " ") + + var result = "<\(tag)>" + + switch content { + case .value(let rawValue): + result += rawValue + case .children(let children): + for child in children { + result += visit(child) + } + } + + if type == .standard { + result += "" + } + return result + } + + // MARK: - visitor functions + + mutating func defaultVisit(_ markup: any Markup) -> Result { + var result = "" + for child in markup.children { + result += visit(child) + } + return result + } + + // MARK: - elements + + // mutating func visit(_ markup: Markup) -> Result { + // fatalError() + // } + + mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { + tag(name: "blockquote", content: .children(blockQuote.children)) + } + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { + var attributes: [Attribute] = [] + if let language = codeBlock.language { + attributes.append( + .init( + key: "class", + value: "language-\(language.lowercased())" + ) + ) + } + return tag( + name: "pre", + content: .value( + tag( + name: "code", + attributes: attributes, + content: .value( + codeBlock.code.replacingOccurrences( + [ + "<": "<", + ">": ">", + ] + ) + ) + ) + ) + ) + } + + // mutating func visitCustomBlock( + // _ customBlock: CustomBlock + // ) -> Result { + // fatalError() + // } + + // mutating func visitDocument(_ document: Document) -> Result { + // fatalError() + // } + + mutating func visitHeading( + _ heading: Heading + ) -> Result { + + var attributes: [Attribute] = [] + if [2, 3].contains(heading.level) { + let fragment = heading.plainText.lowercased().slugify() + let id = Attribute(key: "id", value: "\(fragment)") + attributes.append(id) + + } + return tag( + name: "h\(heading.level)", + attributes: attributes, + content: .children(heading.children) + ) + } + + mutating func visitThematicBreak( + _ thematicBreak: ThematicBreak + ) -> Result { + tag(name: "hr", type: .short, content: .value("")) + } + + mutating func visitHTMLBlock( + _ html: HTMLBlock + ) -> Result { + html.rawHTML + } + + mutating func visitListItem( + _ listItem: ListItem + ) -> Result { + tag(name: "li", content: .children(listItem.children)) + } + + mutating func visitOrderedList( + _ orderedList: OrderedList + ) -> Result { + tag(name: "ol", content: .children(orderedList.children)) + } + + mutating func visitUnorderedList( + _ unorderedList: UnorderedList + ) -> Result { + tag(name: "ul", content: .children(unorderedList.children)) + } + + mutating func visitParagraph( + _ paragraph: Paragraph + ) -> Result { + // NOTE: this is a bad workaround, but it works for now... + /// if the parent is a link block directive + if + let block = paragraph.parent as? BlockDirective, + ["link", "question"].contains(block.name.lowercased()) + { + var result = "" + for child in paragraph.children { + result += visit(child) + } + return result + } + /// if the parent is a list element, we don't need to render the p tag + if paragraph.isInsideList { + var result = "" + for child in paragraph.children { + result += visit(child) + } + return result + } + + return tag(name: "p", content: .children(paragraph.children)) + } + + mutating func visitBlockDirective( + _ blockDirective: BlockDirective + ) -> Result { + var parseErrors = [DirectiveArgumentText.ParseError]() + var arguments: [DirectiveArgument] = [] + let blockName = blockDirective.name.lowercased() + if !blockDirective.argumentText.isEmpty { + arguments = blockDirective.argumentText.parseNameValueArguments( + parseErrors: &parseErrors + ) + } + guard parseErrors.isEmpty else { + return "" + } + + switch blockName { + case "faq": + return tag( + name: "details", + content: .children(blockDirective.children) + ) + case "question": + return tag( + name: "summary", + content: .children(blockDirective.children) + ) + case "answer": + return tag( + name: "div", + content: .children(blockDirective.children) + ) +// case "svg": +// let src = arguments.getFirstValueBy(key: "src") ?? "" +// print(src) +//// let cssClass = arguments.getFirstValueBy(key: "class") ?? "" +// return tag( +// name: "svg", +// attributes: [ +//// .init(key: "href", value: url), +//// .init(key: "class", value: cssClass), +// ], +// content: .children(blockDirective.children) +// ) + case "link": + let url = arguments.getFirstValueBy(key: "url") ?? "" + let cssClass = arguments.getFirstValueBy(key: "class") ?? "" + return tag( + name: "a", + attributes: [ + .init(key: "href", value: url), + .init(key: "class", value: cssClass), + ], + content: .children(blockDirective.children) + ) + case "button": + let cssClass = arguments.getFirstValueBy(key: "class") ?? "" + return tag( + name: "section", + attributes: [ + .init(key: "class", value: cssClass), + ], + content: .children(blockDirective.children) + ) + case "section": + let cssClass = arguments.getFirstValueBy(key: "class") ?? "" + return tag( + name: "section", + attributes: [ + .init(key: "class", value: cssClass), + ], + content: .children(blockDirective.children) + ) + case "grid": + let desktop = arguments.getFirstValueBy(key: "desktop") ?? "2" + let tablet = arguments.getFirstValueBy(key: "tablet") ?? "2" + let mobile = arguments.getFirstValueBy(key: "mobile") ?? "1" + let extraClass = arguments.getFirstValueBy(key: "class") ?? "" + + let cssClass = "grid grid-\(desktop)\(tablet)\(mobile) \(extraClass)" + return tag( + name: "div", + attributes: [ + .init(key: "class", value: cssClass), + ], + content: .children(blockDirective.children) + ) + case "column": + let extraClass = arguments.getFirstValueBy(key: "class") ?? "" + return tag( + name: "div", + attributes: [ + .init(key: "class", value: "column \(extraClass)"), + ], + content: .children(blockDirective.children) + ) + default: + return "" + } + } + + mutating func visitInlineCode(_ inlineCode: InlineCode) -> Result { + tag(name: "code", content: .value(inlineCode.code)) + } + + // mutating func visitCustomInline(_ customInline: CustomInline) -> Result { + // fatalError() + // } + + mutating func visitEmphasis(_ emphasis: Emphasis) -> Result { + tag(name: "em", content: .children(emphasis.children)) + } + + mutating func visitImage(_ image: Image) -> Result { + guard let source = image.source else { + return "" + } + if let result = delegate?.imageOverride(image) { + return result + } + var attributes: [Attribute] = [ + .init(key: "src", value: source), + .init(key: "alt", value: image.plainText), + ] + if let title = image.title { + attributes.append( + .init(key: "title", value: title) + ) + } + return tag( + name: "img", + type: .short, + attributes: attributes, + content: .value("") + ) + } + + mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> Result { + inlineHTML.rawHTML + } + + mutating func visitLineBreak(_ lineBreak: LineBreak) -> Result { + tag(name: "br", type: .short, content: .value("")) + } + + mutating func visitLink(_ link: Link) -> Result { + var attributes: [Attribute] = [] + + if let attr = delegate?.linkAttributes(link.destination) { + for (key, value) in attr { + attributes.append(.init(key: key, value: value)) + } + } + attributes.insert( + .init( + key: "href", + value: link.destination ?? "#" + ), + at: 0 + ) + return tag( + name: "a", + attributes: attributes, + content: .children(link.children) + ) + } + + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Result { + tag(name: "br", type: .short, content: .value("")) + } + + mutating func visitStrong(_ strong: Strong) -> Result { + tag(name: "strong", content: .children(strong.children)) + } + + mutating func visitText(_ text: Text) -> Result { + text.plainText + } + + mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> Result { + tag(name: "s", content: .children(strikethrough.children)) + } + + // NOTE: not supported yet... + // mutating func visitTable(_ table: Table) -> Result { + // fatalError() + // } + // + // mutating func visitTableHead(_ tableHead: Table.Head) -> Result { + // fatalError() + // } + // + // mutating func visitTableBody(_ tableBody: Table.Body) -> Result { + // fatalError() + // } + // + // mutating func visitTableRow(_ tableRow: Table.Row) -> Result { + // fatalError() + // } + // + // mutating func visitTableCell(_ tableCell: Table.Cell) -> Result { + // fatalError() + // } + // + // mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> Result { + // fatalError() + // } + // + // mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result { + // fatalError() + // } + // + // mutating func visitDoxygenDiscussion(_ doxygenDiscussion: DoxygenDiscussion) -> Result { + // fatalError() + // } + // + // mutating func visitDoxygenNote(_ doxygenNote: DoxygenNote) -> Result { + // fatalError() + // } + // + // mutating func visitDoxygenParameter(_ doxygenParam: DoxygenParameter) -> Result { + // fatalError() + // } + // + // mutating func visitDoxygenReturns(_ doxygenReturns: DoxygenReturns) -> Result { + // fatalError() + // } + +} + +// let linkModifier = Modifier(target: .links) { html, markdown in +// if !html.contains(baseUrl) { +// return html.replacingOccurrences( +// of: "\">", +// with: "\" target=\"_blank\">" +// ) +// } +// return html +// } +// +// let bqModifier = Modifier(target: .blockquotes) { html, markdown in +// if markdown.hasPrefix("> NOTE: ") { +// return html.replacingOccurrences([ +// "NOTE: ": "", +// "

": "

", +// "

": "", +// "
": "", +// ]) +// } +// if markdown.hasPrefix("> WARN: ") { +// return html.replacingOccurrences([ +// "WARN: ": "", +// "

": "

", +// "

": "", +// "
": "", +// ]) +// } +// return html +// } diff --git a/Sources/ToucanSDK/Markdown/MarkupToHXVisitor.swift b/Sources/ToucanSDK/Markdown/MarkupToHXVisitor.swift new file mode 100644 index 00000000..abf22755 --- /dev/null +++ b/Sources/ToucanSDK/Markdown/MarkupToHXVisitor.swift @@ -0,0 +1,65 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import Markdown + +struct MarkupToHXVisitor: MarkupVisitor { + + struct HX { + let level: Int + let text: String + let fragment: String + + init( + level: Int, + text: String, + fragment: String + ) { + self.level = level + self.text = text + self.fragment = fragment + } + } + + typealias Result = [HX] + + let levels: [Int] + + init(levels: [Int] = [2, 3]) { + self.levels = levels + } + + // MARK: - visitor functions + + mutating func defaultVisit( + _ markup: any Markup + ) -> Result { + var result: [HX] = [] + for child in markup.children { + result += visit(child) + } + return result + } + + // MARK: - elements + + mutating func visitHeading( + _ heading: Heading + ) -> Result { + guard levels.contains(heading.level) else { + return [] + } + let fragment = heading.plainText.lowercased().slugify() + return [ + .init( + level: heading.level, + text: heading.plainText, + fragment: fragment + ) + ] + } +} diff --git a/Sources/ToucanSDK/Models/Config.swift b/Sources/ToucanSDK/Models/Config.swift deleted file mode 100644 index 6dd81070..00000000 --- a/Sources/ToucanSDK/Models/Config.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct Config { - let baseUrl: String - let title: String - let description: String - let language: String - - // @TODO: return only one instance based on config - var formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy/MM/dd" - return formatter - }() -} diff --git a/Sources/ToucanSDK/Models/Meta.swift b/Sources/ToucanSDK/Models/Meta.swift deleted file mode 100644 index d117bdec..00000000 --- a/Sources/ToucanSDK/Models/Meta.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -struct Meta { - - let site: String - let baseUrl: String - let slug: String - let title: String - let description: String - let image: String - - var templateVariables: [String: String] { - [ - "site": site, - "baseUrl": baseUrl, - "slug": slug, - "permalink": permalink, - "title": title, - "description": description, - "image": image, - ] - } - - var permalink: String { - var permalink = baseUrl + slug - if !permalink.hasSuffix("/") { - permalink += "/" - } - return permalink - } -} diff --git a/Sources/ToucanSDK/Mustache/MustacheToHTMLRenderer.swift b/Sources/ToucanSDK/Mustache/MustacheToHTMLRenderer.swift new file mode 100644 index 00000000..2f520f37 --- /dev/null +++ b/Sources/ToucanSDK/Mustache/MustacheToHTMLRenderer.swift @@ -0,0 +1,111 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 23/05/2024. +// + +import Foundation +import Mustache + +extension String { + + func minifyHTML() -> Self { + self + } +} + +struct MustacheToHTMLRenderer { + + enum Error: Swift.Error { + case missingTemplate(String) + } + + private let library: MustacheLibrary + private let ids: [String] + + init( + templatesUrl: URL, + overridesUrl: URL + ) throws { + var templates: [String: MustacheTemplate] = [:] + for (id, template) in try Self.loadTemplates(at: templatesUrl) { + templates[id] = template + } + for (id, template) in try Self.loadTemplates(at: overridesUrl) { + templates[id] = template + } + + self.library = MustacheLibrary(templates: templates) + self.ids = Array(templates.keys) + + // print("---") + // print(templatesUrl.path()) + // print(overridesUrl.path()) + // print(self.ids) + // print("---") + } + + // MARK: - + + static func loadTemplates( + at templatesUrl: URL + ) throws -> [String: MustacheTemplate] { + let ext = "mustache" + var templates: [String: MustacheTemplate] = [:] + if let dirContents = FileManager.default.enumerator( + at: templatesUrl, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) { + for case let url as URL in dirContents + where url.pathExtension == ext { + var relativePathComponents = url.pathComponents.dropFirst( + templatesUrl.pathComponents.count + ) + let name = String( + relativePathComponents.removeLast() + .dropLast(".\(ext)".count) + ) + relativePathComponents.append(name) + let id = relativePathComponents.joined(separator: ".") + templates[id] = try MustacheTemplate( + string: .init(contentsOf: url) + ) + } + } + return templates + } + + // MARK: - + + func render( + template: String, + with object: Any + ) throws -> String? { + guard self.ids.contains(template) else { + throw Error.missingTemplate(template) + } + return library.render(object, withTemplate: template) + } + + func render( + template: String, + with object: Any, + to destination: URL + ) throws { + guard ids.contains(template) else { + throw Error.missingTemplate(template) + } + try library.render( + object, + withTemplate: template + )? + .minifyHTML() + .write( + to: destination, + atomically: true, + encoding: .utf8 + ) + } +} diff --git a/Sources/ToucanSDK/Parser/ContentParser.swift.swift b/Sources/ToucanSDK/Parser/ContentParser.swift.swift deleted file mode 100644 index 762764fe..00000000 --- a/Sources/ToucanSDK/Parser/ContentParser.swift.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import Ink -import Splash - -struct ContentParser { - - func parse( - at url: URL, - baseUrl: String, - slug: String, - assets: [String] - ) throws -> String { - let parser = createParser( - baseUrl: baseUrl, - slug: slug, - assets: assets - ) - let rawMarkdown = try String(contentsOf: url) - return parser.parse(rawMarkdown).html - } - - func createParser( - baseUrl: String, - slug: String, - assets: [String] - ) -> MarkdownParser { - let linkModifier = Modifier(target: .links) { html, markdown in - if !html.contains(baseUrl) { - return html.replacingOccurrences( - of: "\">", - with: "\" target=\"_blank\">" - ) - } - return html - } - - let bqModifier = Modifier(target: .blockquotes) { html, markdown in - if markdown.hasPrefix("> NOTE: ") { - return html.replacingOccurrences([ - "NOTE: ": "", - "

": "

", - "

": "", - "
": "", - ]) - } - if markdown.hasPrefix("> WARN: ") { - return html.replacingOccurrences([ - "WARN: ": "", - "

": "

", - "

": "", - "
": "", - ]) - } - return html - } - - let highlighter = SyntaxHighlighter(format: HTMLOutputFormat()) - let splashModifier = Modifier(target: .codeBlocks) { html, markdown in - var input = String(markdown) - guard input.hasPrefix("```swift") else { - return html - } - input = String(input.dropFirst(8).dropLast(3)) - let code = highlighter.highlight(input) - .trimmingCharacters( - in: .whitespacesAndNewlines - ) - return #"
\#(code)
"# - } - - let imageModifier = Modifier(target: .images) { html, markdown in - let input = String(markdown) - guard - let alt = input.slice(from: "![", to: "]"), - let file = input.slice(from: "](", to: ")"), - let name = file.split(separator: ".").first, - let ext = file.split(separator: ".").last, - assets.contains(file) - else { - print("[WARNING] Image link issues `\(input)` in `\(slug)`.") - return html - } - - let darkFile = String(name) + "~dark." + String(ext) - let src = baseUrl + "images/assets/" + slug + "/images/" + file - let darkSrc = - baseUrl + "images/assets/" + slug + "/images/" + darkFile - - var dark = "" - if assets.contains(darkFile) { - dark = - #"\#n\#t\#t"# - } - return #""" -
-
- - \#(dark)\#(alt) - -
-
- """# - } - - var parser = MarkdownParser() - let modifiers = [ - linkModifier, - splashModifier, - imageModifier, - bqModifier, - ] - for modifier in modifiers { - parser.addModifier(modifier) - } - return parser - } -} diff --git a/Sources/ToucanSDK/Parser/MetadataParser.swift b/Sources/ToucanSDK/Parser/MetadataParser.swift deleted file mode 100644 index 083c043c..00000000 --- a/Sources/ToucanSDK/Parser/MetadataParser.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation -import Ink -import Splash - -struct MetadataParser { - - var parser: MarkdownParser - - init() { - let parser = MarkdownParser() - self.parser = parser - } - - func parse(at url: URL) throws -> [String: String] { - let rawMarkdown = try String(contentsOf: url) - let markdown = parser.parse(rawMarkdown) - return markdown.metadata - } -} diff --git a/Sources/ToucanSDK/Parsers/FrontMatterParser.swift b/Sources/ToucanSDK/Parsers/FrontMatterParser.swift new file mode 100644 index 00000000..29633ba9 --- /dev/null +++ b/Sources/ToucanSDK/Parsers/FrontMatterParser.swift @@ -0,0 +1,30 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import Yams + +struct FrontMatterParser { + + func parse( + markdown: String + ) throws -> [String: Any] { + guard markdown.starts(with: "---") else { + return [:] + } + + let parts = markdown.split( + separator: "---", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + guard let rawMetadata = parts.first else { + return [:] + } + return try Yaml.parse(yaml: String(rawMetadata)) + } +} diff --git a/Sources/ToucanSDK/Site/HTMLRendererDelegate.swift b/Sources/ToucanSDK/Site/HTMLRendererDelegate.swift new file mode 100644 index 00000000..d2fddc56 --- /dev/null +++ b/Sources/ToucanSDK/Site/HTMLRendererDelegate.swift @@ -0,0 +1,54 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 23/05/2024. +// + +import Markdown +import Foundation + +struct HTMLRendererDelegate: MarkdownRenderer.Delegate { + + let config: Config + let pageBundle: PageBundle + + func linkAttributes(_ link: String?) -> [String: String] { + var attributes: [String: String] = [:] + guard let link, !link.isEmpty else { + return attributes + } + if !link.hasPrefix("."), + !link.hasPrefix("/"), + !link.hasPrefix(config.site.baseUrl) + { + attributes["target"] = "_blank" + } + return attributes + } + + func imageOverride(_ image: Image) -> String? { + let prefix = "./\(pageBundle.assets.path)/" + guard + let source = image.source, + source.hasPrefix(prefix) + else { + return nil + } + + let src = String(source.dropFirst(prefix.count)) + + // TODO: better asset management for index page bundle + let assetsDir = pageBundle.context.slug.isEmpty ? "" : "/assets/" + let url = assetsDir + pageBundle.context.slug + "/" + src + + var title = "" + if let ttl = image.title { + title = #" title="\#(ttl)""# + } + + return """ + \(image.plainText) + """ + } +} diff --git a/Sources/ToucanSDK/Site/SiteRenderer.swift b/Sources/ToucanSDK/Site/SiteRenderer.swift new file mode 100644 index 00000000..bc47a534 --- /dev/null +++ b/Sources/ToucanSDK/Site/SiteRenderer.swift @@ -0,0 +1,856 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 21/06/2024. +// + +import Foundation +import Logging +import Dispatch +import ShellKit +import Algorithms +import SwiftSoup + +// TODO: use actor & modern concurrency +final class Cache { + + let q = DispatchQueue( + label: "com.binarybirds.toucan.cache", + attributes: .concurrent + ) + + var storage: [String: Any] + + init() { + self.storage = [:] + } + + func set(key: String, value: Any) { + q.async(flags: .barrier) { + self.storage[key] = value + } + + } + + func get(key: String) -> Any? { + q.sync { + self.storage[key] + } + } +} + +/// Responsible to build renderable files using the site context & templates. +struct SiteRenderer { + + public enum Files { + static let index = "index.html" + static let notFound = "404.html" + static let rss = "rss.xml" + static let sitemap = "sitemap.xml" + } + + let source: Source + + let currentYear: Int + let dateFormatter: DateFormatter + let rssDateFormatter: DateFormatter + let sitemapDateFormatter: DateFormatter + + let templatesUrl: URL + let overridesUrl: URL + let destinationUrl: URL + + let fileManager: FileManager = .default + let logger: Logger + + let templateRenderer: MustacheToHTMLRenderer + + var cache: Cache + + init( + source: Source, + templatesUrl: URL, + overridesUrl: URL, + destinationUrl: URL + ) throws { + self.source = source + self.templatesUrl = templatesUrl + self.overridesUrl = overridesUrl + self.destinationUrl = destinationUrl + + let calendar = Calendar(identifier: .gregorian) + self.currentYear = calendar.component(.year, from: .init()) + + self.dateFormatter = DateFormatters.baseFormatter + self.dateFormatter.dateFormat = source.config.site.dateFormat + self.rssDateFormatter = DateFormatters.rss + self.sitemapDateFormatter = DateFormatters.sitemap + + self.logger = { + var logger = Logger(label: "SiteRenderer") + logger.logLevel = .debug + return logger + }() + + self.templateRenderer = try MustacheToHTMLRenderer( + templatesUrl: templatesUrl, + overridesUrl: overridesUrl + ) + + self.cache = .init() + } + + // MARK: - context related + + func readingTime(_ value: String) -> Int { + max(value.split(separator: " ").count / 238, 1) + } + + func relations( + for pageBundle: PageBundle + ) -> [String: [PageBundle]] { + let contentType = source.contentType(for: pageBundle) + var result: [String: [PageBundle]] = [:] + for (key, value) in contentType.relations ?? [:] { + let refIds = pageBundle.referenceIdentifiers( + for: key, + join: value.join + ) + + let refs = + source + .pageBundles(by: value.references) + /// filter down based on the condition + .filter { item in + refIds.contains(item.contextAwareIdentifier) + } + .sorted(key: value.sort, order: value.order) + .limited(value.limit) + + result[key] = refs + } + return result + } + + func globalContext() -> [String: [PageBundle]] { + var result: [String: [PageBundle]] = [:] + for contentType in source.contentTypes { + for (key, value) in contentType.context?.site ?? [:] { + let pageBundles = source.pageBundles(by: contentType.id) + .sorted(key: value.sort, order: value.order) + result[key] = + pageBundles + .filtered(value.filter) + // TODO: proper pagination + .limited(value.limit) + } + } + return result + } + + // TODO: optimize & merge with data? + func paginationContext( + for pageBundle: PageBundle + ) -> [String: [Context.Pagination.Link]] { + var result: [String: [Context.Pagination.Link]] = [:] + for contentType in source.contentTypes { + guard let pagination = contentType.pagination else { continue } + let paginationBundle = source.pageBundles.first { pageBundle in + guard pageBundle.type == ContentType.pagination.id else { + return false + } + guard pageBundle.id == pagination.bundle else { return false } + guard pageBundle.context.slug.contains("{{number}}") else { + return false + } + return true + } + guard let paginationBundle else { + continue + } + + let pageBundles = source.pageBundles(by: contentType.id) + .sorted(key: pagination.sort, order: pagination.order) + + let limit = pagination.limit + let pages = pageBundles.chunks(ofCount: limit) + let total = pages.count + + var ctx: [Context.Pagination.Link] = [] + for (index, _) in pages.enumerated() { + let number = index + 1 + let slug = paginationBundle.context.slug.replacingOccurrences([ + "{{number}}": String(number), + "{{total}}": String(total), + ]) + let permalink = slug.permalink( + baseUrl: source.config.site.baseUrl + ) + let isCurrent = pageBundle.context.slug == slug + ctx.append( + .init( + number: number, + total: total, + slug: slug, + permalink: permalink, + isCurrent: isCurrent + ) + ) + } + result[contentType.id] = ctx + } + return result + } + + func localContext( + for pageBundle: PageBundle + ) -> [String: [PageBundle]] { + let id = pageBundle.contextAwareIdentifier + var localContext: [String: [PageBundle]] = [:] + let contentType = source.contentType(for: pageBundle) + + for (key, value) in contentType.context?.local ?? [:] { + if value.foreignKey.hasPrefix("$") { + var command = String(value.foreignKey.dropFirst()) + var arguments: [String] = [] + if command.contains(".") { + let all = command.split(separator: ".") + command = String(all[0]) + arguments = all.dropFirst().map(String.init) + } + + let refs = + source + .pageBundles(by: value.references) + .sorted(key: value.sort, order: value.order) + + guard + let idx = refs.firstIndex(where: { + $0.context.slug == pageBundle.context.slug + }) + else { + continue + } + + switch command { + case "prev": + guard idx > 0 else { + continue + } + localContext[key] = [refs[idx - 1]] + case "next": + guard idx < refs.count - 1 else { + continue + } + localContext[key] = [refs[idx + 1]] + case "same": + guard let arg = arguments.first else { + continue + } + let ids = Set(pageBundle.referenceIdentifiers(for: arg)) + localContext[key] = + refs.filter { pb in + if pb.context.slug == pageBundle.context.slug { + return false + } + let pbIds = Set(pb.referenceIdentifiers(for: arg)) + return !ids.intersection(pbIds).isEmpty + } + .limited(value.limit) + default: + continue + } + } + else { + localContext[key] = + source + .pageBundles(by: value.references) + .filter { + $0.referenceIdentifiers( + for: value.foreignKey + ) + .contains(id) + } + .sorted(key: value.sort, order: value.order) + .limited(value.limit) + } + } + return localContext + } + + func contentContext( + for pageBundle: PageBundle + ) -> [String: Any] { + let renderer = MarkdownRenderer( + delegate: HTMLRendererDelegate( + config: source.config, + pageBundle: pageBundle + ) + ) + + // TODO: check if transformer exists + let transformersUrl = source.url.appendingPathComponent("transformers") + let availableTransformers = + fileManager + .listDirectory(at: transformersUrl) + .filter { !$0.hasPrefix(".") } + .sorted() + + let contentType = source.contentType(for: pageBundle) + let run = contentType.transformers?.run ?? [] + let renderFallback = contentType.transformers?.render ?? true + + // let transformers = pageBundle.frontMatter.dict("transformers") + // let renderFallback = transformers.bool("render") + // let run = transformers.array("run", as: [String: Any].self) + + let markdown = pageBundle.markdown.dropFrontMatter() + var toc: [ToC]? = nil + var time: Int? = nil + var contents = "" + + // TODO: better transformers settings merge with page bundle + if !run.isEmpty { + let shell = Shell(env: ProcessInfo.processInfo.environment) + + // Create a temporary directory URL + let tempDirectoryURL = FileManager.default.temporaryDirectory + let fileName = UUID().uuidString + let fileURL = tempDirectoryURL.appendingPathComponent(fileName) + try! markdown.write(to: fileURL, atomically: true, encoding: .utf8) + + for r in run { + guard availableTransformers.contains(r.name) else { + continue + } + var rawOptions = r.options ?? [:] + rawOptions["file"] = fileURL.path + // TODO: this is not necessary the right way... + rawOptions["id"] = pageBundle.contextAwareIdentifier + rawOptions["slug"] = pageBundle.context.slug + + let bin = transformersUrl.appendingPathComponent(r.name).path + let options = + rawOptions + .map { #"--\#($0) "\#($1)""# } + .joined(separator: " ") + + do { + let cmd = #"\#(bin) \#(options)"# + // print(cmd) + let log = try shell.run(cmd) + if !log.isEmpty { + print(log) + } + } + catch { + print("\(error)") + } + } + contents = try! String(contentsOf: fileURL, encoding: .utf8) + try? fileManager.delete(at: fileURL) + + time = readingTime(contents) + + do { + let doc: Document = try SwiftSoup.parse(contents) + + var tocList: [MarkupToHXVisitor.HX] = [] + let headings = try doc.select("h2, h3") + for h in headings { + let n = h.nodeName() + let attr = try h.attr("id") + guard !attr.isEmpty else { continue } + let val = try h.text() + + let level = n.hasSuffix("2") ? 2 : 3 + + tocList.append( + .init( + level: level, + text: val, + fragment: attr + ) + ) + } + + toc = MarkdownRenderer.buildToC(tocList) + + } + catch Exception.Error(_, let message) { + print(message) + } + catch { + print("error") + } + } + + if renderFallback { + contents = renderer.renderHTML(markdown: markdown) + } + + var context: [String: Any] = [:] + context["readingTime"] = time ?? readingTime(markdown) + context["toc"] = toc ?? renderer.renderToC(markdown: markdown) + context["contents"] = contents + + return context + } + + func getContext( + pageBundle: PageBundle + ) -> [String: Any] { + + if let res = cache.get(key: pageBundle.context.slug) as? [String: Any] { + return res + } + + logger.trace("slug: \(pageBundle.context.slug)") + logger.trace("type: \(pageBundle.type)") + + let contentType = source.contentType(for: pageBundle) + + var properties: [String: Any] = [:] + for (key, _) in contentType.properties ?? [:] { + let value = pageBundle.frontMatter[key] + properties[key] = value + } + + let relations = relations(for: pageBundle) + + logger.trace("relations:") + for (key, values) in relations { + logger.trace("\t\(key):") + for item in values { + logger.trace("\t - \(item.context.slug)") + } + } + + let localContext = localContext(for: pageBundle) + logger.trace("local context:") + for (key, values) in localContext { + logger.trace("\t\(key):") + for item in values { + logger.trace("\t - \(item.context.slug)") + } + } + + let res = pageBundle.context.dict + .recursivelyMerged( + with: properties + ) + .recursivelyMerged( + with: + relations + // TODO: fix this, it can lead to a recursive call!!! +// .mapValues { $0.map { getContext(pageBundle: $0) } } + .mapValues { $0.map(\.context.dict) } + ) + .recursivelyMerged( + with: localContext.mapValues { + $0.map(\.context.dict) + } + ) + .recursivelyMerged(with: contentContext(for: pageBundle)) + + cache.set(key: pageBundle.context.slug, value: res) + + return res + } + + // MARK: - page bundle rendering + + func renderHTML( + pageBundle: PageBundle, + globalContext: [String: [PageBundle]], + paginationContext: [String: [Context.Pagination.Link]], + paginationData: [String: [PageBundle]] + ) throws { + + var fileUrl = + destinationUrl + .appendingPathComponent(pageBundle.context.slug) + .appendingPathComponent(Files.index) + + if pageBundle.context.slug == "404" { + fileUrl = + destinationUrl + .appendingPathComponent(Files.notFound) + } + + if let output = pageBundle.output { + fileUrl = + destinationUrl + .appendingPathComponent(output) + } + + try fileManager.createParentFolderIfNeeded( + for: fileUrl + ) + + try templateRenderer.render( + template: pageBundle.template, + with: HTML( + site: .init( + baseUrl: source.config.site.baseUrl, + title: source.config.site.title, + description: source.config.site.description, + language: source.config.site.language, + context: globalContext.mapValues { + $0.map { getContext(pageBundle: $0) } + } + ), + page: getContext(pageBundle: pageBundle), + userDefined: pageBundle.userDefined + .recursivelyMerged(with: source.config.site.userDefined) + .sanitized(), + data: pageBundle.data, + pagination: .init( + links: paginationContext, + data: paginationData.mapValues { + $0.map { getContext(pageBundle: $0) } + } + ), + year: currentYear + ), + to: fileUrl + ) + + } + + // MARK: - render related methods + + func render() throws { + let globalContext = globalContext() + + logger.trace("global context:") + for (key, values) in globalContext { + logger.trace("\t\(key):") + for item in values { + logger.trace("\t - \(item.context.slug)") + } + } + + for pageBundle in source.pageBundles { + guard pageBundle.type != ContentType.pagination.id else { + continue + } + // if pageBundle.context.slug == "" { + // print(pageBundle.context) + // } + try renderHTML( + pageBundle: pageBundle, + globalContext: globalContext, + paginationContext: paginationContext(for: pageBundle), + paginationData: [:] + ) + } + + for contentType in source.contentTypes { + guard let pagination = contentType.pagination else { continue } + + for pageBundle in source.pageBundles { + guard pageBundle.type == ContentType.pagination.id else { + continue + } + guard pageBundle.id == pagination.bundle else { continue } + guard pageBundle.context.slug.contains("{{number}}") else { + continue + } + + let pageBundles = source.pageBundles(by: contentType.id) + .sorted(key: pagination.sort, order: pagination.order) + + let limit = pagination.limit + let pages = pageBundles.chunks(ofCount: limit) + let total = pages.count + + func replace( + in value: String, + number: Int, + total: Int + ) -> String { + value.replacingOccurrences([ + "{{number}}": String(number), + "{{total}}": String(total), + ]) + } + + if let home = pageBundle.frontMatter["home"] as? String, + !home.isEmpty + { + // print("---------------------") + // print(home) + } + + for (index, current) in pages.enumerated() { + let number = index + 1 + let finalSlug = replace( + in: pageBundle.context.slug, + number: number, + total: total + ) + let finalPermalink = finalSlug.permalink( + baseUrl: source.config.site.baseUrl + ) + let finalTitle = replace( + in: pageBundle.context.title, + number: number, + total: total + ) + let finalDescription = replace( + in: pageBundle.context.description, + number: number, + total: total + ) + let finalMarkdown = replace( + in: pageBundle.markdown, + number: number, + total: total + ) + + let finalBundle = PageBundle( + id: pageBundle.id, + url: pageBundle.url, + frontMatter: pageBundle.frontMatter, + markdown: finalMarkdown, + type: pageBundle.type, + lastModification: pageBundle.lastModification, + publication: pageBundle.publication, + expiration: pageBundle.expiration, + template: pageBundle.template, + output: pageBundle.output, + assets: pageBundle.assets, + redirects: pageBundle.redirects, + userDefined: pageBundle.userDefined, + data: pageBundle.data, + context: .init( + slug: finalSlug, + permalink: finalPermalink, + title: finalTitle, + description: finalDescription, + imageUrl: pageBundle.context.imageUrl, + lastModification: pageBundle.context + .lastModification, + publication: pageBundle.context.publication, + expiration: pageBundle.context.expiration, + noindex: pageBundle.context.noindex, + canonical: pageBundle.context.canonical, + hreflang: pageBundle.context.hreflang, + css: pageBundle.context.css, + js: pageBundle.context.js + ) + ) + + try renderHTML( + pageBundle: finalBundle, + globalContext: globalContext, + paginationContext: paginationContext(for: finalBundle), + paginationData: [contentType.id: Array(current)] + ) + } + } + } + + try? renderRSS() + try? renderSitemap() + try? renderRedirects() + } + + func renderRSS() throws { + let items: [RSS.Item] = source.rssPageBundles() + .map { item in + .init( + permalink: item.context.permalink, + title: item.context.title, + description: item.context.description, + publicationDate: rssDateFormatter.string( + from: item.publication + ) + ) + } + + let publicationDate = + items.first?.publicationDate + ?? rssDateFormatter.string(from: .init()) + + let context = RSS( + title: source.config.site.title, + description: source.config.site.description, + baseUrl: source.config.site.baseUrl, + language: source.config.site.language, + lastBuildDate: rssDateFormatter.string(from: .init()), + publicationDate: publicationDate, + items: items + ) + + try templateRenderer.render( + template: "rss", + with: context, + to: destinationUrl.appendingPathComponent(Files.rss) + ) + } + + func renderSitemap() throws { + let context = Sitemap( + urls: source.sitemapPageBundles() + .map { + .init( + location: $0.context.permalink, + lastModification: sitemapDateFormatter.string( + from: $0.lastModification + ) + ) + } + ) + try templateRenderer.render( + template: "sitemap", + with: context, + to: destinationUrl.appendingPathComponent(Files.sitemap) + ) + } + + func renderRedirects() throws { + for pageBundle in source.pageBundles { + for redirect in pageBundle.redirects { + + let fileUrl = + destinationUrl + .appendingPathComponent(redirect.from) + .appendingPathComponent(Files.index) + + try fileManager.createParentFolderIfNeeded( + for: fileUrl + ) + + try templateRenderer.render( + template: "redirect", + with: Redirect( + url: pageBundle.context.permalink, + code: redirect.code.rawValue + ), + to: fileUrl + ) + } + } + } +} + +// NOTE: this is a complete hack for now... +extension [PageBundle] { + + func sorted( + key: String?, + order: ContentType.Order? + ) -> [PageBundle] { + guard let key, let order else { + return self + } + switch key { + case "publication": + return sorted { lhs, rhs in + switch order { + case .asc: + return lhs.publication < rhs.publication + case .desc: + return lhs.publication > rhs.publication + } + } + default: + return sorted { lhs, rhs in + guard + let l = lhs.frontMatter[key] as? String, + let r = rhs.frontMatter[key] as? String + else { + guard + let l = lhs.frontMatter[key] as? Int, + let r = rhs.frontMatter[key] as? Int + else { + return false + } + switch order { + case .asc: + return l < r + case .desc: + return l > r + } + } + // TODO: proper case insensitive compare + switch order { + case .asc: + // switch l.caseInsensitiveCompare(r) { + // case .orderedAscending: + // return true + // case .orderedDescending: + // return false + // case .orderedSame: + // return false + // } + return l.lowercased() < r.lowercased() + case .desc: + return l.lowercased() > r.lowercased() + } + } + } + } + + func limited(_ value: Int?) -> [PageBundle] { + Array(prefix(value ?? Int.max)) + } + + func filtered(_ filter: ContentType.Filter?) -> [PageBundle] { + guard let filter else { + return self + } + return self.filter { pageBundle in + guard let field = pageBundle.frontMatter[filter.field] else { + return false + } + switch filter.method { + case .equals: + // this is horrible... 😱 + return String(describing: field) == filter.value + } + } + } +} + +// TODO: this is tricky, next / prev over refs, using a generic approach... +//func prev(_ guide: Guide) -> Guide? { +// let guides = guides(category: guide.category) +// guard +// let index = guideIndex(for: guide, in: guides), +// index > 0 +// else { +// if +// let categoryIndex = categoryIndex(for: guide.category), +// categoryIndex > 0 +// { +// let nextIndex = categoryIndex - 1 +// let category = categories[nextIndex] +// return self.guides(category: category).last +// } +// return nil +// } +// return guides[index - 1] +// } +// +// func next(_ guide: Guide) -> Guide? { +// let guides = guides(category: guide.category) +// guard +// let index = guideIndex(for: guide, in: guides), +// index < guides.count - 1 +// else { +// if +// let categoryIndex = categoryIndex(for: guide.category), +// categoryIndex < categories.count - 1 +// { +// let nextIndex = categoryIndex + 1 +// let category = categories[nextIndex] +// return self.guides(category: category).first +// } +// return nil +// } +// return guides[index + 1] +// } diff --git a/Sources/ToucanSDK/Site/ToC.swift b/Sources/ToucanSDK/Site/ToC.swift new file mode 100644 index 00000000..2aae849f --- /dev/null +++ b/Sources/ToucanSDK/Site/ToC.swift @@ -0,0 +1,27 @@ +// +// File.swift +// toucan +// +// Created by Tibor Bodecs on 22/07/2024. +// + +import Foundation + +public struct ToC { + public let level: Int + public let text: String + public let fragment: String + public var children: [ToC] + + public init( + level: Int, + text: String, + fragment: String, + children: [ToC] = [] + ) { + self.level = level + self.text = text + self.fragment = fragment + self.children = children + } +} diff --git a/Sources/ToucanSDK/Site/Types/Output+HTML.swift b/Sources/ToucanSDK/Site/Types/Output+HTML.swift new file mode 100644 index 00000000..4b5ce0fd --- /dev/null +++ b/Sources/ToucanSDK/Site/Types/Output+HTML.swift @@ -0,0 +1,47 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 21/05/2024. +// + +struct Context { + + struct Site { + let baseUrl: String + let title: String + let description: String + let language: String? + let context: [String: Any]? + } + + struct Pagination { + struct Link { + let number: Int + let total: Int + + let slug: String + let permalink: String + let isCurrent: Bool + } + + // let home: Link? + // let first: Link? + // let prev: Link? + // let next: Link? + // let last: Link? + + let links: [String: [Link]] + let data: [String: Any] + } +} + +struct HTML: Output { + + let site: Context.Site + let page: [String: Any] + let userDefined: [String: Any] + let data: [[String: Any]] + let pagination: Context.Pagination + let year: Int +} diff --git a/Sources/ToucanSDK/Site/Types/Output+RSS.swift b/Sources/ToucanSDK/Site/Types/Output+RSS.swift new file mode 100644 index 00000000..24753879 --- /dev/null +++ b/Sources/ToucanSDK/Site/Types/Output+RSS.swift @@ -0,0 +1,24 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 14/05/2024. +// + +struct RSS: Output { + + struct Item { + let permalink: String + let title: String + let description: String + let publicationDate: String + } + + let title: String + let description: String + let baseUrl: String + let language: String? + let lastBuildDate: String + let publicationDate: String + let items: [Item] +} diff --git a/Sources/ToucanSDK/Site/Types/Output+Redirect.swift b/Sources/ToucanSDK/Site/Types/Output+Redirect.swift new file mode 100644 index 00000000..b0105705 --- /dev/null +++ b/Sources/ToucanSDK/Site/Types/Output+Redirect.swift @@ -0,0 +1,11 @@ +// +// Context+Redirect.swift +// +// +// Created by Tibor Bodecs on 01/07/2024. +// + +struct Redirect: Output { + let url: String + let code: Int +} diff --git a/Sources/ToucanSDK/Site/Types/Output+Sitemap.swift b/Sources/ToucanSDK/Site/Types/Output+Sitemap.swift new file mode 100644 index 00000000..28e7de5b --- /dev/null +++ b/Sources/ToucanSDK/Site/Types/Output+Sitemap.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 14/05/2024. +// + +struct Sitemap: Output { + + struct URL { + let location: String + let lastModification: String + } + + let urls: [URL] +} diff --git a/Sources/ToucanSDK/Site/Types/Output.swift b/Sources/ToucanSDK/Site/Types/Output.swift new file mode 100644 index 00000000..914a9758 --- /dev/null +++ b/Sources/ToucanSDK/Site/Types/Output.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 20/06/2024. +// + +protocol Output {} diff --git a/Sources/ToucanSDK/Source/Config.swift b/Sources/ToucanSDK/Source/Config.swift new file mode 100644 index 00000000..a50bde50 --- /dev/null +++ b/Sources/ToucanSDK/Source/Config.swift @@ -0,0 +1,52 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 27/06/2024. +// + +struct Config { + + struct Location { + let folder: String + } + + struct Site { + + struct Hreflang: Codable { + let lang: String + let url: String + } + + var baseUrl: String + let title: String + let description: String + let language: String? + let dateFormat: String? + let noindex: Bool? + let hreflang: [Hreflang]? + let userDefined: [String: Any] + } + + struct Themes { + let use: String + let folder: String + let templates: Location + let assets: Location + let overrides: Location + } + + struct Content { + let folder: String + let assets: Location + } + + struct Types { + let folder: String + } + + var site: Site + let themes: Themes + let types: Types + let content: Content +} diff --git a/Sources/ToucanSDK/Source/ConfigLoader.swift b/Sources/ToucanSDK/Source/ConfigLoader.swift new file mode 100644 index 00000000..85142111 --- /dev/null +++ b/Sources/ToucanSDK/Source/ConfigLoader.swift @@ -0,0 +1,248 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 27/06/2024. +// + +import Foundation +import FileManagerKit +import Yams + +private extension Config { + + enum Keys { + static let site = "site" + static let themes = "themes" + static let content = "content" + static let types = "types" + } + +} + +private extension Config.Location { + + enum Keys { + static let folder = "folder" + } +} + +private extension Config.Site { + + enum Keys: String, CaseIterable { + case baseUrl + case title + case description + case language + case dateFormat + case noindex + case hreflang + } + + enum Defaults { + static let baseUrl = "http://localhost:3000/" + static let title = "" + static let description = "" + static let dateFormat = "MMMM dd, yyyy" + static let noindex = false + } +} + +private extension Config.Themes { + + enum Keys { + static let use = "use" + static let templates = "templates" + static let assets = "assets" + static let overrides = "overrides" + } + + enum Defaults { + static let use = "default" + static let folder = "themes" + static let templatesFolder = "templates" + static let assetsFolder = "assets" + static let overridesFolder = "template_overrides" + } +} + +private extension Config.Types { + + enum Defaults { + static let typesFolder = "types" + } +} + +private extension Config.Content { + + enum Keys { + static let assets = "assets" + } + + enum Defaults { + static let contentFolder = "content" + static let assetsFolder = "assets" + } +} + +struct ConfigLoader { + + /// An enumeration representing possible errors that can occur while loading the configuration. + enum Error: Swift.Error { + case missing + /// Indicates an error related to file operations. + case file(Swift.Error) + /// Indicates an error related to parsing YAML. + case yaml(YamlError) + } + + /// The URL of the source files. + let sourceUrl: URL + /// The file manager used for file operations. + let fileManager: FileManager + + /// Loads the configuration. + /// + /// - Returns: A `Config` object. + /// - Throws: An error if the configuration fails to load. + func load() throws -> Config { + let configUrl = sourceUrl.appendingPathComponent("config") + + let yamlConfigUrls = [ + configUrl.appendingPathExtension("yaml"), + configUrl.appendingPathExtension("yml"), + ] + for yamlConfigUrl in yamlConfigUrls { + guard fileManager.fileExists(at: yamlConfigUrl) else { + continue + } + do { + let rawYaml = try String( + contentsOf: yamlConfigUrl, + encoding: .utf8 + ) + let dict = try Yams.load(yaml: rawYaml) as? [String: Any] ?? [:] + let config = try dictToConfig(dict) + return config + } + catch let error as YamlError { + throw Error.yaml(error) + } + catch { + throw Error.file(error) + } + } + throw Error.missing + } + + func dictToConfig( + _ yaml: [String: Any] + ) throws -> Config { + // MARK: - site + let site = yaml.dict(Config.Keys.site) + + let baseUrl = + site.string(Config.Site.Keys.baseUrl.rawValue) + ?? Config.Site.Defaults.baseUrl + + let title = + site.string(Config.Site.Keys.title.rawValue) + ?? Config.Site.Defaults.title + + let description = + site.string(Config.Site.Keys.description.rawValue) + ?? Config.Site.Defaults.description + + let language = site.string(Config.Site.Keys.language.rawValue) + + let dateFormat = + site.string(Config.Site.Keys.dateFormat.rawValue) + ?? Config.Site.Defaults.dateFormat + + let noindex = + site.bool(Config.Site.Keys.noindex.rawValue) + ?? Config.Site.Defaults.noindex + + let hreflang = site.array( + Config.Site.Keys.hreflang.rawValue, + as: Config.Site.Hreflang.self + ) + + let userDefined = site.filter { + !Config.Site.Keys.allCases.map(\.rawValue).contains($0.key) + } + + // MARK: - themes + + let themes = yaml.dict(Config.Keys.themes) + + let use = + themes.string(Config.Themes.Keys.use) ?? Config.Themes.Defaults.use + + let folder = + themes.string(Config.Location.Keys.folder) + ?? Config.Themes.Defaults.folder + + let templates = themes.dict(Config.Themes.Keys.templates) + let templatesFolder = + templates.string(Config.Location.Keys.folder) + ?? Config.Themes.Defaults.templatesFolder + + let assets = themes.dict(Config.Themes.Keys.assets) + let assetsFolder = + assets.string(Config.Location.Keys.folder) + ?? Config.Themes.Defaults.assetsFolder + + let overrides = themes.dict(Config.Themes.Keys.overrides) + let overridesFolder = + overrides.string(Config.Location.Keys.folder) + ?? Config.Themes.Defaults.overridesFolder + + // MARK: - types + + let types = yaml.dict(Config.Keys.types) + let typesFolder = + types.string(Config.Location.Keys.folder) + ?? Config.Types.Defaults.typesFolder + + // MARK: - content + + let content = yaml.dict(Config.Keys.content) + let contentFolder = + content.string(Config.Location.Keys.folder) + ?? Config.Content.Defaults.contentFolder + + let contentAssets = content.dict(Config.Content.Keys.assets) + let contentAssetsFolder = + contentAssets + .string(Config.Location.Keys.folder) + ?? Config.Content.Defaults.assetsFolder + + // MARK: - config + + return .init( + site: .init( + baseUrl: baseUrl, + title: title, + description: description, + language: language, + dateFormat: dateFormat, + noindex: noindex, + hreflang: hreflang, + userDefined: userDefined + ), + themes: .init( + use: use, + folder: folder, + templates: .init(folder: templatesFolder), + assets: .init(folder: assetsFolder), + overrides: .init(folder: overridesFolder) + ), + types: .init(folder: typesFolder), + content: .init( + folder: contentFolder, + assets: .init(folder: contentAssetsFolder) + ) + ) + } +} diff --git a/Sources/ToucanSDK/Source/ContentType.swift b/Sources/ToucanSDK/Source/ContentType.swift new file mode 100644 index 00000000..f018cb57 --- /dev/null +++ b/Sources/ToucanSDK/Source/ContentType.swift @@ -0,0 +1,190 @@ +// +// File.swift +// toucan +// +// Created by Tibor Bodecs on 18/07/2024. +// + +import Foundation + +struct ContentType: Codable { + + enum Order: String, Codable { + case asc + case desc + } + + enum Join: String, Codable { + case one + case many + } + + struct Pagination: Codable { + let bundle: String + let limit: Int + let sort: String? + let order: Order? + } + + struct Property: Codable { + enum DataType: String, Codable, CaseIterable { + case string + case int + case double + case bool + case date + case array + case object + } + + let type: DataType + let required: Bool + } + + struct Relation: Codable { + let references: String + let join: Join + let sort: String? + let order: Order? + let limit: Int? + } + + struct Filter: Codable { + + enum Method: String, Codable { + case equals + } + + let field: String + let method: Method + let value: String + } + + struct Context: Codable { + + struct Site: Codable { + let sort: String? + let order: Order? + let limit: Int? + let filter: Filter? + } + + struct Local: Codable { + let references: String + let foreignKey: String + let sort: String? + let order: Order? + let limit: Int? + } + + let site: [String: Site]? + let local: [String: Local]? + + } + + struct Transformers: Codable { + + struct Transformer: Codable { + let name: String + let options: [String: String]? + } + + let run: [Transformer]? + let render: Bool? + } + + let id: String + let rss: Bool? + let location: String? + let template: String? + let pagination: Pagination? + let properties: [String: Property]? + let relations: [String: Relation]? + let context: Context? + let transformers: Transformers? +} + +extension ContentType { + + static let `default` = ContentType( + id: "page", + rss: nil, + location: nil, + template: "pages.single.page", + pagination: nil, + properties: [ + : + // "type": .init( + // type: .string, + // required: false + // ), + // "slug": .init( + // type: .string, + // required: false + // ), + // "title": .init( + // type: .string, + // required: false + // ), + // "description": .init( + // type: .string, + // required: false + // ), + // "image": .init( + // type: .string, + // required: false + // ), + // "draft": .init( + // type: .bool, + // required: false + // ), + // "publication": .init( + // type: .date, + // required: false + // ), + // "expiration": .init( + // type: .date, + // required: false + // ), + //case template + //case output + //case assets + //case redirects + // + //case noindex + //case canonical + //case hreflang + //case css + //case js + + ], + relations: nil, + context: .init( + site: [ + "pages": .init( + sort: "title", + order: .asc, + limit: nil, + filter: nil + ) + ], + local: nil + ), + transformers: nil + ) + + static let pagination = ContentType( + id: "pagination", + rss: nil, + location: nil, + template: "pages.single.page", + pagination: nil, + properties: nil, + relations: nil, + context: .init( + site: [:], + local: nil + ), + transformers: nil + ) +} diff --git a/Sources/ToucanSDK/Source/ContentTypeLoader.swift b/Sources/ToucanSDK/Source/ContentTypeLoader.swift new file mode 100644 index 00000000..581d77c6 --- /dev/null +++ b/Sources/ToucanSDK/Source/ContentTypeLoader.swift @@ -0,0 +1,65 @@ +// +// File.swift +// toucan +// +// Created by Tibor Bodecs on 19/07/2024. +// + +import Foundation +import FileManagerKit +import Yams + +/// A struct responsible for loading and managing content types. +struct ContentTypeLoader { + + /// An enumeration representing possible errors that can occur while loading the configuration. + enum Error: Swift.Error { + case missing + /// Indicates an error related to file operations. + case file(Swift.Error) + /// Indicates an error related to parsing YAML. + case yaml(YamlError) + } + + /// The URL of the source files. + let sourceUrl: URL + + /// The configuration object that holds settings for the site. + let config: Config + + /// The file manager used for file operations. + let fileManager: FileManager + + /// Loads and returns an array of content types. + /// + /// - Throws: An error if the content types could not be loaded. + /// - Returns: An array of `ContentType` objects. + func load() throws -> [ContentType] { + // TODO: use yaml loader + let typesUrl = sourceUrl.appendingPathComponent(config.types.folder) + let list = fileManager.listDirectory(at: typesUrl) + .filter { $0.hasSuffix(".yml") || $0.hasSuffix(".yaml") } + + var types: [ContentType] = [] + var useDefaultContentType = true + for file in list { + let decoder = YAMLDecoder() + let data = try Data( + contentsOf: typesUrl.appendingPathComponent(file) + ) + let type = try decoder.decode(ContentType.self, from: data) + types.append(type) + if type.id == ContentType.default.id { + useDefaultContentType = false + } + } + if useDefaultContentType { + types.append(.default) + } + // TODO: pagination type is not allowed + types = types.filter { $0.id != ContentType.pagination.id } + types.append(.pagination) + + return types + } +} diff --git a/Sources/ToucanSDK/Source/PageBundle.swift b/Sources/ToucanSDK/Source/PageBundle.swift new file mode 100644 index 00000000..4510931f --- /dev/null +++ b/Sources/ToucanSDK/Source/PageBundle.swift @@ -0,0 +1,171 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 27/06/2024. +// + +import Foundation + +struct PageBundle { + + struct Redirect { + + enum Code: Int, CaseIterable { + case movedPermanently = 301 + case seeOther = 303 + case permanentRedirect = 308 + } + + let from: String + let code: Code + } + + struct Assets { + let path: String + } + + struct Context { + + struct Hreflang { + let lang: String + let url: String + } + + struct DateValue { + let html: String + let rss: String + let sitemap: String + } + + let slug: String + let permalink: String + let title: String + let description: String + let imageUrl: String? + + let lastModification: DateValue + let publication: DateValue + let expiration: DateValue? + + // head + let noindex: Bool + let canonical: String? + let hreflang: [Hreflang] + let css: [String] + let js: [String] + + var dict: [String: Any] { + var result: [String: Any] = [:] + + result["slug"] = slug + result["permalink"] = permalink + result["title"] = title + result["description"] = description + result["imageUrl"] = imageUrl ?? false + + result["lastModification"] = lastModification + result["publication"] = publication + result["expiration"] = expiration ?? false + + result["noindex"] = noindex + result["canonical"] = canonical ?? permalink + result["hreflang"] = hreflang + result["css"] = css + result["js"] = js + + return result + } + } + + /// The url of the page bundle. + + /// the location of the page bundle + let id: String + let url: URL + let frontMatter: [String: Any] + let markdown: String + + let type: String + let lastModification: Date + let publication: Date + let expiration: Date? + let template: String + let output: String? + let assets: Assets + let redirects: [Redirect] + + let userDefined: [String: Any] + let data: [[String: Any]] + + let context: Context + +} + +extension PageBundle { + + // var context: [String: Any] { + // var result: [String: Any] = [:] + //// var result: [String: Any] = frontMatter + // result["slug"] = slug + // result["permalink"] = permalink + // result["title"] = title + // result["description"] = description + // result["imageUrl"] = image // imageUrl() vs frontMatter["image"] ? + // if image == nil { + // result["imageUrl"] = false + // } + // // TODO: date format + // result["publication"] = publication + // result["expiration"] = expiration + // result["lastModification"] = lastModification + // result["css"] = cssUrls() + // result["js"] = jsUrls() + // result["noindex"] = noindex + // result["canonical"] = canonical + // result["hreflang"] = hreflang + // // TODO: better user defaults + // return + // result + // .recursivelyMerged(with: userDefined) + // } + + /// Returns the context aware identifier, the last component of the slug + /// + /// Can be used when referencing contents, e.g. + /// slug: docs/installation + /// type: category + /// contextAwareIdentifier: installation + /// This way content can be identified, when knowing the type & id + var contextAwareIdentifier: String { + .init(context.slug.split(separator: "/").last ?? "") + } + + func referenceIdentifiers( + for key: String + ) -> [String] { + var refIds: [String] = [] + if let ref = frontMatter[key] as? String { + refIds.append(ref) + } + refIds += frontMatter[key] as? [String] ?? [] + return refIds + } + + func referenceIdentifiers( + for key: String, + join: ContentType.Join + ) -> [String] { + var refIds: [String] = [] + switch join { + case .one: + if let ref = frontMatter[key] as? String { + refIds.append(ref) + } + case .many: + refIds = frontMatter[key] as? [String] ?? [] + } + return refIds + } + +} diff --git a/Sources/ToucanSDK/Source/PageBundleLoader.swift b/Sources/ToucanSDK/Source/PageBundleLoader.swift new file mode 100644 index 00000000..6b7f947b --- /dev/null +++ b/Sources/ToucanSDK/Source/PageBundleLoader.swift @@ -0,0 +1,491 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 27/06/2024. +// + +import Foundation +import FileManagerKit +import Yams + +struct PageBundleLocation { + let slug: String + let path: String +} + +struct PageBundleLoader { + + public enum Keys: String, CaseIterable { + case draft + case publication + case expiration + + case slug + case type + + case title + case description + case image + + case template + case output + case assets + case redirects + + case noindex + case canonical + case hreflang + case css + case js + } + + /// An enumeration representing possible errors that can occur while loading the content. + enum Error: Swift.Error { + case indexFileNotExists + /// Indicates an error related to a content. + case pageBundle(Swift.Error) + } + + let sourceUrl: URL + /// The configuration for loading contents. + let config: Config + + let contentTypes: [ContentType] + + /// The file manager used for file operations. + let fileManager: FileManager + /// The front matter parser used for parsing markdown files. + let frontMatterParser: FrontMatterParser + + /// The current date. + let now: Date = .init() + + let indexName = "index" + let noindexName = "noindex" + let mdExtensions = ["md", "markdown"] + let yamlExtensions = ["yaml", "yml"] + + var extensions: [String] { + mdExtensions + yamlExtensions + } + + /// helper + private var contentUrl: URL { + sourceUrl.appendingPathComponent(config.content.folder) + } + + public func load() throws -> [PageBundle] { + try loadBundleLocations() + .sorted { $0.path < $1.path } + .compactMap { + return try? loadPageBundle(at: $0) + } + .sorted { $0.context.slug < $1.context.slug } + } + + // MARK: - load helpers + + func loadBundleLocations( + slug: [String] = [], + path: [String] = [] + ) throws -> [PageBundleLocation] { + var result: [PageBundleLocation] = [] + + let p = path.joined(separator: "/") + let url = contentUrl.appendingPathComponent(p) + + if containsIndexFile(name: indexName, at: url) { + result.append( + .init( + slug: slug.joined(separator: "/"), + path: p + ) + ) + } + + let list = fileManager.listDirectory(at: url) + for item in list { + var newSlug = slug + let childUrl = url.appendingPathComponent(item) + if !containsIndexFile(name: noindexName, at: childUrl) { + newSlug += [item] + } + let newPath = path + [item] + result += try loadBundleLocations(slug: newSlug, path: newPath) + } + + return result + } + + func containsIndexFile( + name: String, + at url: URL + ) -> Bool { + for ext in extensions { + let fileUrl = url.appendingPathComponent("\(name).\(ext)") + if fileManager.fileExists(at: fileUrl) { + return true + } + } + return false + } + + func loadLastModificationDate( + at url: URL + ) throws -> Date { + var date: Date? + for ext in extensions { + let fileUrl = url.appendingPathComponent("\(indexName).\(ext)") + guard fileManager.fileExists(at: fileUrl) else { + continue + } + let fileDate = try fileManager.modificationDate(at: fileUrl) + if date == nil || date! < fileDate { + date = fileDate + } + } + precondition(date != nil, "Last modification date is nil.") + return date! + } + + func loadRawMarkdown( + at url: URL + ) throws -> String { + for ext in mdExtensions { + let fileUrl = url.appendingPathComponent("\(indexName).\(ext)") + if fileManager.fileExists(at: fileUrl) { + return try String(contentsOf: fileUrl, encoding: .utf8) + } + } + return "" + } + + func loadFrontMatter( + id: String, + dirUrl: URL, + rawMarkdown: String + ) throws -> [String: Any] { + /// use front matter from the markdown file + let frontMatter = try frontMatterParser.parse(markdown: rawMarkdown) + + /// load additional yaml files for meta data overrides + let overrides: [String: Any] = try Yaml.load( + at: dirUrl, + name: id, + fileManager: fileManager + ) + return frontMatter.recursivelyMerged(with: overrides) + } + + func loadData( + id: String, + dirUrl: URL + ) throws -> [[String: Any]] { + /// load additional data files for data definitions + try Yaml.load( + at: dirUrl, + name: "\(id).data", + fileManager: fileManager + ) + } + + func convert( + date: Date + ) -> PageBundle.Context.DateValue { + let html = DateFormatters.baseFormatter + html.dateFormat = config.site.dateFormat + let rss = DateFormatters.rss + let sitemap = DateFormatters.sitemap + + return .init( + html: html.string(from: date), + rss: rss.string(from: date), + sitemap: sitemap.string(from: date) + ) + } + + // MARK: - fields + + func draft(frontMatter: [String: Any]) -> Bool { + frontMatter.bool(Keys.draft.rawValue) ?? false + } + + func publication(frontMatter: [String: Any]) -> Date { + guard let date = frontMatter.date(Keys.publication.rawValue) else { + return now + } + return date + } + + func expiration(frontMatter: [String: Any]) -> Date? { + frontMatter.date(Keys.expiration.rawValue) + } + + func slug(frontMatter: [String: Any], fallback: String) -> String { + (frontMatter.string(Keys.slug.rawValue).emptyToNil ?? fallback) + .safeSlug(prefix: nil) + } + + func contentType(frontMatter: [String: Any]) -> String? { + frontMatter.string(Keys.type.rawValue).emptyToNil + } + + func title(frontMatter: [String: Any]) -> String { + frontMatter.string(Keys.title.rawValue).nilToEmpty + } + + func description(frontMatter: [String: Any]) -> String { + frontMatter.string(Keys.description.rawValue).nilToEmpty + } + + func image(frontMatter: [String: Any]) -> String? { + frontMatter.string(Keys.image.rawValue).emptyToNil + } + + func template( + frontMatter: [String: Any], + contentType: ContentType + ) -> String { + frontMatter.string(Keys.template.rawValue).emptyToNil ?? contentType + .template ?? ContentType.default.template ?? "pages.single.page" + } + + func output(frontMatter: [String: Any]) -> String? { + frontMatter.string(Keys.output.rawValue).emptyToNil + } + + func assets(frontMatter: [String: Any]) -> String { + frontMatter.string(Keys.assets.rawValue + ".path").emptyToNil + ?? "assets" + } + + func noindex(frontMatter: [String: Any]) -> Bool { + frontMatter.bool(Keys.noindex.rawValue) ?? false + } + + func canonical(frontMatter: [String: Any]) -> String? { + frontMatter.string(Keys.canonical.rawValue).emptyToNil + } + + func hreflang(frontMatter: [String: Any]) -> [PageBundle.Context.Hreflang] { + frontMatter + .array(Keys.hreflang.rawValue, as: [String: String].self) + .compactMap { dict in + guard + let lang = dict["lang"].emptyToNil, + let url = dict["url"].emptyToNil + else { + return nil + } + return .init(lang: lang, url: url) + } + } + + func redirects(frontMatter: [String: Any]) -> [PageBundle.Redirect] { + frontMatter + .array(Keys.redirects.rawValue, as: [String: String].self) + .compactMap { dict -> PageBundle.Redirect? in + guard let from = dict["from"].emptyToNil else { + return nil + } + let code = + dict["code"] + .flatMap { Int($0) } + .flatMap { PageBundle.Redirect.Code(rawValue: $0) } + ?? .movedPermanently + return .init(from: from, code: code) + } + } + + func css(frontMatter: [String: Any]) -> [String] { + frontMatter.array(Keys.css.rawValue, as: String.self) + } + + func js(frontMatter: [String: Any]) -> [String] { + frontMatter.array(Keys.js.rawValue, as: String.self) + } + + // MARK: - loading + + func loadPageBundle( + at location: PageBundleLocation + ) throws -> PageBundle? { + let dirUrl = contentUrl.appendingPathComponent(location.path) + guard fileManager.directoryExists(at: dirUrl) else { + return nil + } + do { + let lastModification = try loadLastModificationDate(at: dirUrl) + let rawMarkdown = try loadRawMarkdown(at: dirUrl) + let markdown = rawMarkdown.dropFrontMatter() + + let frontMatter = try loadFrontMatter( + id: indexName, + dirUrl: dirUrl, + rawMarkdown: rawMarkdown + ) + + let data = try loadData( + id: indexName, + dirUrl: dirUrl + ) + + /// filter out drafts + if draft(frontMatter: frontMatter) { + return nil + } + /// filter out unpublished + let publication = publication(frontMatter: frontMatter) + if publication > now { + return nil + } + /// filter out expired + let expiration = expiration(frontMatter: frontMatter) + if let expiration, expiration < now { + return nil + } + + let slug = slug(frontMatter: frontMatter, fallback: location.slug) + + var assumedType: String? + for contentType in contentTypes { + guard + let locPrefix = contentType.location, !locPrefix.isEmpty + else { + continue + } + if location.path.hasPrefix(locPrefix) { + assumedType = contentType.id + } + } + + if let explicitType = contentType(frontMatter: frontMatter) { + assumedType = explicitType + } + + let type = assumedType ?? ContentType.default.id + + let contentType = contentTypes.first { $0.id == type } + guard let contentType else { + // TODO: fatal or log invalid content type + print("invalid content type") + return nil + } + + let title = title(frontMatter: frontMatter) + let description = description(frontMatter: frontMatter) + let image = image(frontMatter: frontMatter) + + let template = template( + frontMatter: frontMatter, + contentType: contentType + ) + let output = output(frontMatter: frontMatter) + + let assetsPath = assets(frontMatter: frontMatter) + let assetsUrl = dirUrl.appendingPathComponent(assetsPath) + let assets = fileManager.recursivelyListDirectory(at: assetsUrl) + + let noindex = noindex(frontMatter: frontMatter) + let canonical = canonical(frontMatter: frontMatter) + let hreflang = hreflang(frontMatter: frontMatter) + let redirects = redirects(frontMatter: frontMatter) + + // print("-------------------") + // print(assetsUrl.path()) + // print(assets.joined(separator: "\n")) + + /// resolve imageUrl for the page bundle + let assetsPrefix = "./\(assetsPath)/" + var imageUrl: String? = nil + if let image, + image.hasPrefix(assetsPrefix), + assets.contains(String(image.dropFirst(assetsPrefix.count))) + { + imageUrl = image.finalAssetUrl(in: assetsPath, slug: slug) + } + else { + imageUrl = image + } + + /// inject style.css if exists, resolve js paths for css assets + var css = css(frontMatter: frontMatter) + if assets.contains("style.css") { + css.append("./\(assetsPath)/style.css") + } + css = css.map { $0.finalAssetUrl(in: assetsPath, slug: slug) } + + /// inject main.js if exists, resolve js paths for js assets + var js = js(frontMatter: frontMatter) + if assets.contains("main.js") { + js.append("./\(assetsPath)/main.js") + } + js = js.map { $0.finalAssetUrl(in: assetsPath, slug: slug) } + + let propertyKeys = contentType.properties?.keys.sorted() ?? [] + let relationKeys = contentType.relations?.keys.sorted() ?? [] + let userDefined = frontMatter.filter { element in + !Keys.allCases.map(\.rawValue).contains(element.key) + && !propertyKeys.contains(element.key) + && !relationKeys.contains(element.key) + } + + let context = PageBundle.Context( + slug: slug, + permalink: slug.permalink(baseUrl: config.site.baseUrl), + title: title, + description: description, + imageUrl: imageUrl, + lastModification: convert(date: lastModification), + publication: convert(date: publication), + expiration: expiration.map { convert(date: $0) }, + noindex: noindex, + canonical: canonical, + hreflang: hreflang, + css: css, + js: js + ) + + return .init( + id: location.path, + url: dirUrl, + frontMatter: frontMatter, + markdown: markdown, + type: type, + lastModification: lastModification, + publication: publication, + expiration: expiration, + template: template, + output: output, + assets: .init(path: assetsPath), + redirects: redirects, + userDefined: userDefined, + data: data, + context: context + ) + } + catch { + throw Error.pageBundle(error) + } + } +} + +extension String { + + func finalAssetUrl( + in path: String, + slug: String + ) -> String { + let prefix = "./\(path)/" + guard hasPrefix(prefix) else { + return self + } + let path = String(dropFirst(prefix.count)) + // TODO: not sure if this is the correct way of handling index assets + if slug.isEmpty { + return "/" + path + } + return "/assets/" + slug + "/" + path + } +} diff --git a/Sources/ToucanSDK/Source/Source.swift b/Sources/ToucanSDK/Source/Source.swift new file mode 100644 index 00000000..b97415be --- /dev/null +++ b/Sources/ToucanSDK/Source/Source.swift @@ -0,0 +1,64 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 13/06/2024. +// + +import Foundation + +struct Source { + + let url: URL + let config: Config + let contentTypes: [ContentType] + let pageBundles: [PageBundle] + + func validateSlugs() throws { + let slugs = pageBundles.map(\.context.slug) + let uniqueSlugs = Set(slugs) + guard slugs.count == uniqueSlugs.count else { + var seenSlugs = Set() + var duplicateSlugs = Set() + + for element in slugs { + if seenSlugs.contains(element) { + duplicateSlugs.insert(element) + } + else { + seenSlugs.insert(element) + } + } + + for element in duplicateSlugs { + fatalError("Duplicate slug: \(element)") + } + fatalError("Invalid slugs") + } + } + + func contentType(for pageBundle: PageBundle) -> ContentType { + contentTypes.first { $0.id == pageBundle.type } ?? ContentType.default + } + + func pageBundles(by contentType: String) -> [PageBundle] { + pageBundles.filter { $0.type == contentType } + } + + func rssPageBundles() -> [PageBundle] { + contentTypes + .filter { $0.id != ContentType.pagination.id } + .filter { $0.rss == true } + .flatMap { + pageBundles(by: $0.id) + } + .sorted { $0.publication > $1.publication } + } + + func sitemapPageBundles() -> [PageBundle] { + pageBundles + .filter { $0.type != ContentType.pagination.id } + .filter { $0.id != "404" } + .sorted { $0.publication > $1.publication } + } +} diff --git a/Sources/ToucanSDK/Source/SourceLoader.swift b/Sources/ToucanSDK/Source/SourceLoader.swift new file mode 100644 index 00000000..aa5070b1 --- /dev/null +++ b/Sources/ToucanSDK/Source/SourceLoader.swift @@ -0,0 +1,51 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 27/06/2024. +// + +import Foundation + +struct SourceLoader { + let baseUrl: String? + let sourceUrl: URL + let fileManager: FileManager + let frontMatterParser: FrontMatterParser + + /// load the configuration & the contents of the site source + func load() throws -> Source { + + let configLoader = ConfigLoader( + sourceUrl: sourceUrl, + fileManager: fileManager + ) + var config = try configLoader.load() + if let baseUrl { + config.site.baseUrl = baseUrl + } + + let contentTypeLoader = ContentTypeLoader( + sourceUrl: sourceUrl, + config: config, + fileManager: fileManager + ) + let contentTypes = try contentTypeLoader.load() + + let pageBundleLoader = PageBundleLoader( + sourceUrl: sourceUrl, + config: config, + contentTypes: contentTypes, + fileManager: fileManager, + frontMatterParser: frontMatterParser + ) + let pageBundles = try pageBundleLoader.load() + + return .init( + url: sourceUrl, + config: config, + contentTypes: contentTypes, + pageBundles: pageBundles + ) + } +} diff --git a/Sources/ToucanSDK/Templates/HomePostTemplate.swift b/Sources/ToucanSDK/Templates/HomePostTemplate.swift deleted file mode 100644 index b044465b..00000000 --- a/Sources/ToucanSDK/Templates/HomePostTemplate.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -struct HomePostTemplate { - - struct Context { - let meta: Meta - let date: String - let tags: [String] - let userDefined: [String: String] - - var templateVariables: [String: String] { - userDefined + meta.templateVariables + [ - "date": date, - "tags": tags.map { #"\#($0)"# } - .joined( - separator: "\n" - ), - ] - } - } - - var file = "home-post.html" - var templatesUrl: URL - var context: Context - - init( - templatesUrl: URL, - context: Context - ) { - self.templatesUrl = templatesUrl - self.context = context - } - - func render() throws -> String { - let templateUrl = templatesUrl.appendingPathComponent(file) - let template = try String(contentsOf: templateUrl) - return template.replacingTemplateVariables(context.templateVariables) - } -} diff --git a/Sources/ToucanSDK/Templates/HomeTemplate.swift b/Sources/ToucanSDK/Templates/HomeTemplate.swift deleted file mode 100644 index c9fb2e51..00000000 --- a/Sources/ToucanSDK/Templates/HomeTemplate.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -struct HomeTemplate { - - struct Context { - let title: String - let description: String - let contents: String - - var templateVariables: [String: String] { - [ - "home.title": title, - "home.description": description, - "contents": contents, - ] - } - } - - var file = "home.html" - var templatesUrl: URL - var context: Context - - init( - templatesUrl: URL, - context: Context - ) { - self.templatesUrl = templatesUrl - self.context = context - } - - func render() throws -> String { - let templateUrl = templatesUrl.appendingPathComponent(file) - let template = try String(contentsOf: templateUrl) - return template.replacingTemplateVariables(context.templateVariables) - } -} diff --git a/Sources/ToucanSDK/Templates/IndexTemplate.swift b/Sources/ToucanSDK/Templates/IndexTemplate.swift deleted file mode 100644 index f8dcb503..00000000 --- a/Sources/ToucanSDK/Templates/IndexTemplate.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -struct IndexTemplate { - - struct Context { - let meta: Meta - let contents: String - - var templateVariables: [String: String] { - meta.templateVariables + [ - "contents": contents - ] - } - } - - var file = "index.html" - var templatesUrl: URL - var context: Context - - init( - templatesUrl: URL, - context: Context - ) { - self.templatesUrl = templatesUrl - self.context = context - } - - func render() throws -> String { - let templateUrl = templatesUrl.appendingPathComponent(file) - let template = try String(contentsOf: templateUrl) - return template.replacingTemplateVariables(context.templateVariables) - } -} diff --git a/Sources/ToucanSDK/Templates/NotFoundTemplate.swift b/Sources/ToucanSDK/Templates/NotFoundTemplate.swift deleted file mode 100644 index bf60726a..00000000 --- a/Sources/ToucanSDK/Templates/NotFoundTemplate.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -struct NotFoundTemplate { - - struct Context { - let title: String - let description: String - let contents: String - - var templateVariables: [String: String] { - [ - "404.title": title, - "404.description": description, - "contents": contents, - ] - } - } - - var file = "404.html" - var templatesUrl: URL - var context: Context - - init( - templatesUrl: URL, - context: Context - ) { - self.templatesUrl = templatesUrl - self.context = context - } - - func render() throws -> String { - let templateUrl = templatesUrl.appendingPathComponent(file) - let template = try String(contentsOf: templateUrl) - return template.replacingTemplateVariables(context.templateVariables) - } -} diff --git a/Sources/ToucanSDK/Templates/PageTemplate.swift b/Sources/ToucanSDK/Templates/PageTemplate.swift deleted file mode 100644 index 8dcaf3da..00000000 --- a/Sources/ToucanSDK/Templates/PageTemplate.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -struct PageTemplate { - - struct Context { - let meta: Meta - let contents: String - - var templateVariables: [String: String] { - meta.templateVariables + [ - "contents": contents - ] - } - } - - var file = "page.html" - var templatesUrl: URL - var context: Context - - init( - templatesUrl: URL, - context: Context - ) { - self.templatesUrl = templatesUrl - self.context = context - } - - func render() throws -> String { - let templateUrl = templatesUrl.appendingPathComponent(file) - let template = try String(contentsOf: templateUrl) - return template.replacingTemplateVariables(context.templateVariables) - } -} diff --git a/Sources/ToucanSDK/Templates/PostTemplate.swift b/Sources/ToucanSDK/Templates/PostTemplate.swift deleted file mode 100644 index f5f99a87..00000000 --- a/Sources/ToucanSDK/Templates/PostTemplate.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -struct PostTemplate { - - struct Context { - let meta: Meta - let contents: String - let date: String - let tags: [String] - let userDefined: [String: String] - - var templateVariables: [String: String] { - userDefined + meta.templateVariables + [ - "contents": contents, - "date": date, - "tags": tags.map { #"\#($0)"# } - .joined( - separator: "\n" - ), - ] - } - } - - var file = "post.html" - var templatesUrl: URL - var context: Context - - init( - templatesUrl: URL, - context: Context - ) { - self.templatesUrl = templatesUrl - self.context = context - } - - func render() throws -> String { - let templateUrl = templatesUrl.appendingPathComponent(file) - let template = try String(contentsOf: templateUrl) - return template.replacingTemplateVariables(context.templateVariables) - } -} diff --git a/Sources/ToucanSDK/Templates/RSSTemplate.swift b/Sources/ToucanSDK/Templates/RSSTemplate.swift deleted file mode 100644 index d7501c70..00000000 --- a/Sources/ToucanSDK/Templates/RSSTemplate.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation - -struct RSSTemplate { - - struct Item { - let title: String - let description: String - let permalink: String - let date: Date - } - - let formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = .init(identifier: "en_US_POSIX") - formatter.timeZone = .init(secondsFromGMT: 0) - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" - return formatter - }() - - let items: [Item] - let config: Config - - init(items: [Item], config: Config) { - self.items = items - self.config = config - } - - func render() throws -> String { - let date = Date() - let now = formatter.string(from: date) - - let sorteditems = items.sorted { $0.date > $1.date } - - let contents = - sorteditems.map { item in - """ - - \(item.permalink) - <![CDATA[ \(item.title) ]]> - - \(item.permalink) - \(formatter.string(from: item.date)) - - """ - } - .joined(separator: "\n") - - let pubDate = sorteditems.first?.date ?? .init() - - return """ - - - \(config.title) - \(config.description) - \(config.baseUrl) - \(config.language) - \(now) - \(formatter.string(from: pubDate)) - 250 - \n - \(contents) - - - """ - } -} diff --git a/Sources/ToucanSDK/Templates/SitemapTemplate.swift b/Sources/ToucanSDK/Templates/SitemapTemplate.swift deleted file mode 100644 index 640c1ae9..00000000 --- a/Sources/ToucanSDK/Templates/SitemapTemplate.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -struct SitemapTemplate { - - struct Item { - let permalink: String - let date: Date - } - - var items: [Item] - - let formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - - init(items: [Item]) { - self.items = items - } - - func render() throws -> String { - let contents = items.sorted { $0.date > $1.date } - .map { item in - """ - - \(item.permalink) - \(formatter.string(from: item.date)) - - """ - } - .joined(separator: "\n") - - return """ - - \(contents) - - """ - } -} diff --git a/Sources/ToucanSDK/Toucan.swift b/Sources/ToucanSDK/Toucan.swift index 57820993..eba0be2e 100644 --- a/Sources/ToucanSDK/Toucan.swift +++ b/Sources/ToucanSDK/Toucan.swift @@ -1,14 +1,59 @@ -import FileManagerKit +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + import Foundation +import FileManagerKit + +extension FileManager { + + func copyRecursively( + from inputURL: URL, + to outputURL: URL + ) throws { + guard directoryExists(at: inputURL) else { + return + } + if !directoryExists(at: outputURL) { + try createDirectory(at: outputURL) + } + + for item in listDirectory(at: inputURL) { + let itemSourceUrl = inputURL.appendingPathComponent(item) + let itemDestinationUrl = outputURL.appendingPathComponent(item) + if fileExists(at: itemSourceUrl) { + if fileExists(at: itemDestinationUrl) { + try delete(at: itemDestinationUrl) + } + try copy(from: itemSourceUrl, to: itemDestinationUrl) + } + else { + try copyRecursively(from: itemSourceUrl, to: itemDestinationUrl) + } + } + } +} +/// A static site generator. public struct Toucan { - public let inputUrl: URL - public let outputUrl: URL + // MARK: - + + let inputUrl: URL + let outputUrl: URL + let baseUrl: String? + /// Initialize a new instance. + /// - Parameters: + /// - input: The input url as a path string. + /// - output: The output url as a path string. public init( - inputPath: String, - outputPath: String + input: String, + output: String, + baseUrl: String? ) { let home = FileManager.default.homeDirectoryForCurrentUser.path func getSafeUrl(_ path: String, home: String) -> URL { @@ -17,321 +62,118 @@ public struct Toucan { ) .standardized } - self.inputUrl = getSafeUrl(inputPath, home: home) - self.outputUrl = getSafeUrl(outputPath, home: home) + self.inputUrl = getSafeUrl(input, home: home) + self.outputUrl = getSafeUrl(output, home: home) + self.baseUrl = baseUrl } - public func generate(_ baseUrl: String?) throws { - - let fileManager = FileManager.default + // MARK: - file management - // input - let publicUrl = inputUrl.appendingPathComponent("public") - let contentsUrl = inputUrl.appendingPathComponent("contents") - let templatesUrl = inputUrl.appendingPathComponent("templates") - let postsUrl = contentsUrl.appendingPathComponent("posts") - let pagesUrl = contentsUrl.appendingPathComponent("pages") + let fileManager = FileManager.default - // output - let assetsUrl = - outputUrl - .appendingPathComponent("images") - .appendingPathComponent("assets") + // MARK: - directory management - if !fileManager.directoryExists(at: outputUrl) { - try fileManager.createDirectory(at: outputUrl) + func resetOutputDirectory() throws { + if fileManager.exists(at: outputUrl) { + try fileManager.delete(at: outputUrl) } + try fileManager.createDirectory(at: outputUrl) + } - // wipe output directory if it's probably dist or docs folder - if outputUrl.path.contains("dist") || outputUrl.path.contains("docs") { - let list = fileManager.listDirectory( - at: outputUrl, - includingHiddenItems: true - ) - for path in list { - try fileManager.delete( - at: outputUrl.appendingPathComponent(path) - ) - } - } + /// generates the static site + public func generate() throws { + let loader = SourceLoader( + baseUrl: baseUrl, + sourceUrl: inputUrl, + fileManager: fileManager, + frontMatterParser: .init() + ) + let source = try loader.load() + try source.validateSlugs() - guard fileManager.listDirectory(at: outputUrl).isEmpty else { - fatalError("Output directory should be empty.") - } + // TODO: output url is completely wiped, check if it's safe to delete everything + try resetOutputDirectory() - // copy public files - for path in fileManager.listDirectory(at: publicUrl) { - try fileManager.copy( - from: publicUrl.appendingPathComponent(path), - to: outputUrl.appendingPathComponent(path) - ) + let themeUrl: URL + if source.config.themes.folder.hasPrefix("/") { + themeUrl = URL(fileURLWithPath: source.config.themes.folder) + .appendingPathComponent(source.config.themes.use) } - - // create assets directory - if !fileManager.directoryExists(at: assetsUrl) { - try fileManager.createDirectory(at: assetsUrl) + else { + themeUrl = + inputUrl + .appendingPathComponent(source.config.themes.folder) + .appendingPathComponent(source.config.themes.use) } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy/MM/dd" - let metadataParser = MetadataParser() - let contentParser = ContentParser() - var slugs = Set() - - let indexUrl = contentsUrl.appendingPathComponent("index.md") - let indexMeta = try metadataParser.parse(at: indexUrl) + let themeAssetsUrl = + themeUrl + .appendingPathComponent(source.config.themes.assets.folder) - let config = Config( - baseUrl: baseUrl ?? indexMeta["baseUrl"] ?? "./", - title: indexMeta["title"] ?? "Untitled", - description: indexMeta["description"] ?? "", - language: indexMeta["language"] ?? "en-US" - ) - - // process posts - let postURLs = getContentURLsToProcess(at: postsUrl, using: fileManager) - var posts: [Post] = [] - for url in postURLs { - let contentsUrl = url.appendingPathComponent("contents.md") - let modificationDate = try fileManager.modificationDate( - at: contentsUrl - ) - let metadata = try metadataParser.parse(at: contentsUrl) - guard - let slug = metadata["slug"], - !slug.isEmpty, - !slugs.contains(slug) - else { - fatalError( - "Invalid or missing slug \(metadata["slug"] ?? "n/a"), \(url.path)" - ) - } - slugs.insert(slug) + let themeTemplatesUrl = + themeUrl + .appendingPathComponent(source.config.themes.templates.folder) - let availableAssets = try processContentAssets( - at: url, - slug: slug, - assetsUrl: assetsUrl, - fileManager: fileManager - ) + let themeOverrideUrl = + inputUrl + .appendingPathComponent(source.config.themes.overrides.folder) + .appendingPathComponent(source.config.themes.use) - let html = try contentParser.parse( - at: contentsUrl, - baseUrl: config.baseUrl, - slug: slug, - assets: availableAssets - ) + let themeOverrideAssetsUrl = + themeOverrideUrl + .appendingPathComponent(source.config.themes.assets.folder) - let meta = getContentMeta( - slug: slug, - config: config, - metadata: metadata - ) + let themeOverrideTemplatesUrl = + themeOverrideUrl + .appendingPathComponent(source.config.themes.templates.folder) - let tags = (metadata["tags"] ?? "").split(separator: ",") - .map { - $0.trimmingCharacters(in: .whitespacesAndNewlines) - } + // theme assets + try fileManager.copyRecursively( + from: themeAssetsUrl, + to: outputUrl + ) + // theme override assets + try fileManager.copyRecursively( + from: themeOverrideAssetsUrl, + to: outputUrl + ) - var postDate = Date() - if let rawDate = metadata["date"], - let date = formatter.date(from: rawDate) - { - postDate = date - } - else { - print("[WARNING] Date issues in `\(slug)`.") - } + // MARK: copy assets - let post = Post( - meta: meta, - slug: slug, - date: postDate, - tags: tags, - html: html, - config: config, - templatesUrl: templatesUrl, - outputUrl: outputUrl, - modificationDate: modificationDate, - userDefined: metadata - ) - try post.generate() - posts.append(post) - } + for pageBundle in source.pageBundles { + let assetsUrl = pageBundle.url + .appendingPathComponent(pageBundle.assets.path) - var pages: [Page] = [] - // process pages - let pageURLs = getContentURLsToProcess(at: pagesUrl, using: fileManager) - for url in pageURLs { - let contentsUrl = url.appendingPathComponent("contents.md") - let modificationDate = try fileManager.modificationDate( - at: contentsUrl - ) - let metadata = try metadataParser.parse(at: contentsUrl) guard - let slug = metadata["slug"], - !slug.isEmpty, - !slugs.contains(slug) + fileManager.directoryExists(at: assetsUrl), + !fileManager.listDirectory(at: assetsUrl).isEmpty else { - fatalError( - "Invalid or missing slug \(metadata["slug"] ?? "n/a"), \(url.path)" - ) + continue } - slugs.insert(slug) - - let availableAssets = try processContentAssets( - at: url, - slug: slug, - assetsUrl: assetsUrl, - fileManager: fileManager - ) - - let html = try contentParser.parse( - at: contentsUrl, - baseUrl: "./", - slug: slug, - assets: availableAssets - ) - - let meta = getContentMeta( - slug: slug, - config: config, - metadata: metadata - ) - let page = Page( - meta: meta, - slug: slug, - html: html, - templatesUrl: templatesUrl, - outputUrl: outputUrl, - modificationDate: modificationDate + let outputUrl = + outputUrl + .appendingPathComponent( + pageBundle.context.slug.isEmpty ? "" : "assets" + ) + .appendingPathComponent(pageBundle.context.slug) + + // print("-------------") + // print(assetsUrl.path) + // print(outputUrl.path) + try fileManager.copyRecursively( + from: assetsUrl, + to: outputUrl ) - - try page.generate() - pages.append(page) } - let home = Home( - contentsUrl: contentsUrl, - config: config, - posts: posts, - templatesUrl: templatesUrl, - outputUrl: outputUrl - ) - try home.generate() - - let notFound = NotFound( - contentsUrl: contentsUrl, - config: config, - posts: posts, - templatesUrl: templatesUrl, - outputUrl: outputUrl - ) - try notFound.generate() - - let rss = RSS( - config: config, - posts: posts, - outputUrl: outputUrl - ) - try rss.generate() - - let sitemap = Sitemap( - config: config, - pages: pages, - posts: posts, - outputUrl: outputUrl - ) - try sitemap.generate() - } - -} - -extension Toucan { - - fileprivate func getContentMeta( - slug: String, - config: Config, - metadata: [String: String] - ) -> Meta { - .init( - site: config.title, - baseUrl: config.baseUrl, - slug: slug, - title: metadata["title"] ?? "Untitled", - description: metadata["description"] ?? "", - image: "images/assets/" + slug + "/cover.jpg" + let renderer = try SiteRenderer( + source: source, + templatesUrl: themeTemplatesUrl, + overridesUrl: themeOverrideTemplatesUrl, + destinationUrl: outputUrl ) - } - - fileprivate func getContentURLsToProcess( - at url: URL, - using fileManager: FileManager = .default - ) -> [URL] { - var toProcess: [URL] = [] - let dirEnum = fileManager.enumerator(atPath: url.path) - while let file = dirEnum?.nextObject() as? String { - let url = url.appendingPathComponent(file) - guard url.lastPathComponent.lowercased() == "contents.md" else { - continue - } - toProcess.append(url.deletingLastPathComponent()) - } - return toProcess - } - - fileprivate func processContentAssets( - at url: URL, - slug: String, - assetsUrl: URL, - fileManager: FileManager - ) throws -> [String] { - var assets: [String] = [] - // create assets dir - let assetsDir = assetsUrl.appendingPathComponent(slug) - try fileManager.createDirectory(at: assetsDir) - - // check for image assets - let imagesUrl = url.appendingPathComponent("images") - var imageList: [String] = [] - if fileManager.directoryExists(at: imagesUrl) { - imageList = fileManager.listDirectory(at: imagesUrl) - } - - // copy image assets - if !imageList.isEmpty { - let assetImagesDir = assetsDir.appendingPathComponent("images") - try fileManager.createDirectory(at: assetImagesDir) - - for image in imageList { - let sourceUrl = imagesUrl.appendingPathComponent(image) - let assetPath = assetImagesDir.appendingPathComponent(image) - try fileManager.copy(from: sourceUrl, to: assetPath) - assets.append(image) - } - } - // copy cover + dark version - let coverUrl = url.appendingPathComponent("cover.jpg") - let coverAssetUrl = assetsDir.appendingPathComponent("cover.jpg") - if fileManager.fileExists(at: coverUrl) { - try fileManager.copy(from: coverUrl, to: coverAssetUrl) - assets.append("cover.jpg") - } - else { - print("[WARNING] Cover image issues in `\(slug)`.") - } - - // copy dark cover image if exists - let darkCoverUrl = url.appendingPathComponent("cover~dark.jpg") - let darkCoverAssetUrl = assetsDir.appendingPathComponent( - "cover~dark.jpg" - ) - if fileManager.fileExists(at: darkCoverUrl) { - try fileManager.copy(from: darkCoverUrl, to: darkCoverAssetUrl) - assets.append("cover~dark.jpg") - } - return assets + try renderer.render() } } diff --git a/Sources/ToucanSDK/Yaml/Yaml.swift b/Sources/ToucanSDK/Yaml/Yaml.swift new file mode 100644 index 00000000..780b0b0d --- /dev/null +++ b/Sources/ToucanSDK/Yaml/Yaml.swift @@ -0,0 +1,72 @@ +// +// File.swift +// toucan +// +// Created by Tibor Bodecs on 23/07/2024. +// + +import Yams +import FileManagerKit +import Foundation + +public enum Yaml { + + static func parse( + yaml: String + ) throws -> [String: Any] { + try parse(yaml: yaml, as: [String: Any].self) ?? [:] + } + + static func parse( + yaml: String, + as: T.Type + ) throws -> T? { + try Yams.load( + yaml: yaml, + Resolver.default.removing(.timestamp) + ) as? T + } + + static func load( + at dirUrl: URL, + name: String, + extensions: [String] = ["yaml", "yml"], + fileManager: FileManager = .default + ) throws -> [String: Any] { + let names = extensions.map { "\(name).\($0)" } + var result: [String: Any] = [:] + for name in names { + let url = dirUrl.appendingPathComponent(name) + guard fileManager.fileExists(at: url) else { + continue + } + let yaml = try String(contentsOf: url, encoding: .utf8) + if let data = try? parse(yaml: yaml) { + result = result.recursivelyMerged(with: data) + } + } + return result + } + + static func load( + at dirUrl: URL, + name: String, + extensions: [String] = ["yaml", "yml"], + fileManager: FileManager = .default + ) throws -> [[String: Any]] { + let names = extensions.map { "\(name).\($0)" } + var result: [[String: Any]] = [] + for name in names { + let url = dirUrl.appendingPathComponent(name) + guard fileManager.fileExists(at: url) else { + continue + } + let yaml = try String(contentsOf: url, encoding: .utf8) + if let data = try? parse(yaml: yaml, as: [[String: Any]].self) { + result += data + } + } + return result + } + +} diff --git a/Sources/toucan-cli/Commands/Generate.swift b/Sources/toucan-cli/Commands/Generate.swift new file mode 100644 index 00000000..5f403364 --- /dev/null +++ b/Sources/toucan-cli/Commands/Generate.swift @@ -0,0 +1,27 @@ +import Foundation +import ArgumentParser +import ToucanSDK + +extension Entrypoint { + + struct Generate: AsyncParsableCommand { + + @Argument(help: "The input directory (default: src).") + var input: String = "./src" + + @Argument(help: "The output directory (default: docs).") + var output: String = "./docs" + + @Option(name: .shortAndLong, help: "The base url to use.") + var baseUrl: String? = nil + + func run() async throws { + let generator = Toucan( + input: input, + output: output, + baseUrl: baseUrl + ) + try generator.generate() + } + } +} diff --git a/Sources/toucan-cli/Commands/Serve.swift b/Sources/toucan-cli/Commands/Serve.swift new file mode 100644 index 00000000..eb745317 --- /dev/null +++ b/Sources/toucan-cli/Commands/Serve.swift @@ -0,0 +1,53 @@ +import Foundation +import ArgumentParser +import ToucanSDK +import Hummingbird +import Logging + +extension Entrypoint { + + struct Serve: AsyncParsableCommand { + + static var _commandName: String = "serve" + + @Argument(help: "The root directory (default: docs).") + var root: String = "./docs" + + @Option(name: .shortAndLong) + var hostname: String = "127.0.0.1" + + @Option(name: .shortAndLong) + var port: Int = 3000 + + func run() async throws { + + let home = FileManager.default.homeDirectoryForCurrentUser.path + var rootPath = root.replacingOccurrences(of: "~", with: home) + if rootPath.hasPrefix(".") { + rootPath = + FileManager.default.currentDirectoryPath + "/" + rootPath + } + + let router = Router() + var logger = Logger(label: "Toucan") + logger.logLevel = .warning + + router.add( + middleware: FileMiddleware( + rootPath, + searchForIndexHtml: true, + logger: logger + ) + ) + let app = Application( + router: router, + configuration: .init( + address: .hostname(hostname, port: port), + serverName: "Toucan server" + ), + logger: logger + ) + try await app.runService() + } + } +} diff --git a/Sources/toucan-cli/Commands/Watch.swift b/Sources/toucan-cli/Commands/Watch.swift new file mode 100644 index 00000000..12217433 --- /dev/null +++ b/Sources/toucan-cli/Commands/Watch.swift @@ -0,0 +1,75 @@ +import Foundation +import ArgumentParser +import Dispatch +import EonilFSEvents +import ToucanSDK + +// TODO: use async sequence for file watcher + Linux support +let semaphore = DispatchSemaphore(value: 0) +private var lastGenerationTime: Date? + +func waitForever() { + semaphore.wait() +} + +extension Entrypoint { + + struct Watch: AsyncParsableCommand { + + static var _commandName: String = "watch" + + @Argument(help: "The input directory (default: src).") + var input: String = "./src" + + @Argument(help: "The output directory (default: docs).") + var output: String = "./docs" + + @Option(name: .shortAndLong, help: "The base url to use.") + var baseUrl: String? = nil + + mutating func run() async throws { + let toucan = Toucan( + input: input, + output: output, + baseUrl: baseUrl + ) + try toucan.generate() + + let eventStream = try EonilFSEventStream( + pathsToWatch: [input], + sinceWhen: .now, + latency: 0, + flags: [], + handler: { event in + guard let flag = event.flag, flag == [] else { + return + } + let now = Date() + let last = lastGenerationTime ?? now + let diff = abs(last.timeIntervalSince(now)) + // 3 sec delay + guard (diff == 0) || (diff > 3) else { + return + } + + print("Generating site...") + do { + try toucan.generate() + lastGenerationTime = now + } + catch { + print("\(error)") + } + print("Site re-generated.") + } + ) + + eventStream.setDispatchQueue(DispatchQueue.main) + + try eventStream.start() + print("πŸ‘€ Watching: `\(input)` -> \(output).") + + waitForever() + } + } +} diff --git a/Sources/toucan-cli/Entrypoint.swift b/Sources/toucan-cli/Entrypoint.swift new file mode 100644 index 00000000..0cbb60ee --- /dev/null +++ b/Sources/toucan-cli/Entrypoint.swift @@ -0,0 +1,24 @@ +import ArgumentParser +import ToucanSDK + +/// The main entry point for the command-line tool. +@main +struct Entrypoint: AsyncParsableCommand { + + /// Configuration for the command-line tool. + static var configuration = CommandConfiguration( + commandName: "toucan", + abstract: """ + Toucan + """, + discussion: """ + A markdown-based Static Site Generator (SSG) written in Swift. + """, + version: "0.1.0", + subcommands: [ + Generate.self, + Serve.self, + Watch.self, + ] + ) +} diff --git a/Tests/ToucanSDKTests/AppTests.swift b/Tests/ToucanSDKTests/AppTests.swift deleted file mode 100644 index 856a63c2..00000000 --- a/Tests/ToucanSDKTests/AppTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import XCTest - -final class AppTests: XCTestCase { - - func testExample() async throws { - XCTAssertTrue(true) - } -} diff --git a/Tests/ToucanSDKTests/SourceTestSuite.swift b/Tests/ToucanSDKTests/SourceTestSuite.swift new file mode 100644 index 00000000..db49f6e6 --- /dev/null +++ b/Tests/ToucanSDKTests/SourceTestSuite.swift @@ -0,0 +1,106 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import XCTest +@testable import ToucanSDK + +final class SourceTestSuite: XCTestCase { + + var sitesPath: String { + "/" + + #file + .split(separator: "/") + .dropLast(3) + .joined(separator: "/") + + "/sites/" + } + + func loadConfig( + _ site: String + ) async throws { + let baseUrl = URL(fileURLWithPath: sitesPath) + let siteUrl = baseUrl.appendingPathComponent(site) + let srcUrl = siteUrl.appendingPathComponent("src") + let configLoader = SourceConfigLoader( + sourceUrl: srcUrl, + fileManager: .default, + frontMatterParser: .init() + ) + _ = try configLoader.load() + } + + func testLoadConfig() async throws { + for argument in [ + "demo" + // "theswiftdev.com", + // "binarybirds.com", + // "swiftonserver.com", + ] { + try await loadConfig(argument) + } + + } + + func loadContents( + _ site: String + ) async throws { + let baseUrl = URL(fileURLWithPath: sitesPath) + let siteUrl = baseUrl.appendingPathComponent(site) + let srcUrl = siteUrl.appendingPathComponent("src") + let configLoader = SourceConfigLoader( + sourceUrl: srcUrl, + fileManager: .default, + frontMatterParser: .init() + ) + let config = try configLoader.load() + + let contentsLoader = SourceMaterialLoader( + config: config, + fileManager: .default, + frontMatterParser: .init() + ) + + let contents = try contentsLoader.load() + try contents.validateSlugs() + } + + func testLoadContents() async throws { + for argument in [ + "demo" + // "theswiftdev.com", + // "binarybirds.com", + // "swiftonserver.com", + ] { + try await loadContents(argument) + } + + } + + // func testUserDefined() async throws { + // + // let path = + // "/" + // + #file + // .split(separator: "/") + // .dropLast(3) + // .joined(separator: "/") + // + "/sites/demo/" + // + // let baseUrl = URL(fileURLWithPath: path) + // let srcUrl = baseUrl.appendingPathComponent("src") + // let contentsUrl = srcUrl.appendingPathComponent("contents") + // let loader = ContentLoader( + // contentsUrl: contentsUrl, + // fileManager: .default, + // frontMatterParser: .init() + // ) + // let content = try await loader.load() + // + // _ = content.blog.author.contents.first?.userDefined + // + // } +} diff --git a/Tests/ToucanSDKTests/ToucanTestSuite.swift b/Tests/ToucanSDKTests/ToucanTestSuite.swift new file mode 100644 index 00000000..b9c7fb3c --- /dev/null +++ b/Tests/ToucanSDKTests/ToucanTestSuite.swift @@ -0,0 +1,49 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import XCTest +@testable import ToucanSDK + +final class ToucanTestSuite: XCTestCase { + + var sitesPath: String { + "/" + + #file + .split(separator: "/") + .dropLast(3) + .joined(separator: "/") + + "/sites/" + } + + func generate( + _ site: String + ) async throws { + let baseUrl = URL(fileURLWithPath: sitesPath) + let siteUrl = baseUrl.appendingPathComponent(site) + let srcUrl = siteUrl.appendingPathComponent("src") + let destUrl = siteUrl.appendingPathComponent("dist") + + let toucan = Toucan( + input: srcUrl.path, + output: destUrl.path, + baseUrl: nil + ) + try toucan.generate() + } + + func testGenerate() async throws { + for argument in [ + // "minimal", + "demo" + // "theswiftdev.com", + // "binarybirds.com", + // "swiftonserver.com", + ] { + try await generate(argument) + } + } +} diff --git a/Tests/ToucanSDKTests/Utilities/FrontMatterParserTests.swift b/Tests/ToucanSDKTests/Utilities/FrontMatterParserTests.swift new file mode 100644 index 00000000..b14e2d14 --- /dev/null +++ b/Tests/ToucanSDKTests/Utilities/FrontMatterParserTests.swift @@ -0,0 +1,37 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import XCTest +@testable import ToucanSDK + +final class FrontMatterParserTests: XCTestCase { + + func testBasics() throws { + + let input = #""" + --- + slug: lorem-ipsum + title: Lorem ipsum + tags: foo, bar, baz + --- + + Lorem ipsum dolor sit amet. + """# + + let parser = FrontMatterParser() + let metadata = try parser.parse(markdown: input) as? [String: String] + + let expectation: [String: String] = [ + "slug": "lorem-ipsum", + "title": "Lorem ipsum", + "tags": "foo, bar, baz", + ] + + XCTAssert(metadata == expectation) + } + +} diff --git a/Tests/ToucanSDKTests/Utilities/MarkdownToHTMLRendererTestSuite.swift b/Tests/ToucanSDKTests/Utilities/MarkdownToHTMLRendererTestSuite.swift new file mode 100644 index 00000000..b2aa15c1 --- /dev/null +++ b/Tests/ToucanSDKTests/Utilities/MarkdownToHTMLRendererTestSuite.swift @@ -0,0 +1,417 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 03/05/2024. +// + +import XCTest +@testable import ToucanSDK + +final class MarkdownToHTMLRendererTestSuite: XCTestCase { + + // MARK: - standard elements + + func testParagraphElement() throws { + + let input = #""" + Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testLineBreakElement() throws { + + let input = #""" + This is the first line. + And this is the second line. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

This is the first line.
And this is the second line.

+ """# + + XCTAssert(output == expectation) + } + + func testHorizontalRuleElement() throws { + + let input = #""" + Lorem ipsum + *** + dolor + --- + sit + _________________ + amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum


dolor

sit


amet.

+ """# + + XCTAssert(output == expectation) + } + + func testStrongElement() throws { + + let input = #""" + Lorem **ipsum** dolor __sit__ amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testBlockquoteElement() throws { + + let input = #""" + > Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testNestedBlockquoteElement() throws { + + let input = #""" + > Lorem ipsum + > + >> dolor __sit__ amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum

dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testEmphasisElement() throws { + + let input = #""" + Lorem *ipsum* dolor _sit_ amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + // MARK: - headings + + func testH1Element() throws { + + let input = #""" + # Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testH2Element() throws { + + let input = #""" + ## Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testH3Element() throws { + + let input = #""" + ### Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testH4Element() throws { + + let input = #""" + #### Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testH5Element() throws { + + let input = #""" + ##### Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +
Lorem ipsum dolor sit amet.
+ """# + + XCTAssert(output == expectation) + } + + func testH6Element() throws { + + let input = #""" + ###### Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +
Lorem ipsum dolor sit amet.
+ """# + + XCTAssert(output == expectation) + } + + func testInvalidHeadingElement() throws { + + /// NOTE: this should be treated as a paragraph + let input = #""" + ####### Lorem ipsum dolor sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

####### Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + // MARK: - lists + + func testUnorderedList() throws { + + let input = #""" + - foo + - bar + - baz + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +
  • foo
  • bar
  • baz
+ """# + + XCTAssert(output == expectation) + } + + func testOrderedList() throws { + + let input = #""" + 1. foo + 2. bar + 3. baz + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +
  1. foo
  2. bar
  3. baz
+ """# + + XCTAssert(output == expectation) + } + + func testListWithCode() throws { + + let input = #""" + - foo `aaa` + - bar + - baz + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +
  • foo aaa
  • bar
  • baz
+ """# + + XCTAssert(output == expectation) + } + + // MARK: - other elements + + func testInlineCode() throws { + + let input = #""" + Lorem `ipsum dolor` sit amet. + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem ipsum dolor sit amet.

+ """# + + XCTAssert(output == expectation) + } + + func testCodeBlockElement() throws { + + let input = #""" + ```js + Lorem + ipsum + dolor + sit + amet + ``` + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +
Lorem
+            ipsum
+            dolor
+            sit
+            amet
+            
+ """# + + XCTAssert(output == expectation) + } + + func testImageElement() throws { + + let input = #""" + ![Lorem](lorem.jpg) + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Lorem

+ """# + + XCTAssert(output == expectation) + } + + func testLinkElement() throws { + + let input = #""" + [Swift](https://swift.org/) + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

Swift

+ """# + + XCTAssert(output == expectation) + } + + func testInlineHTML() throws { + + let input = #""" + https://swift.org + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

https://swift.org

+ """# + + XCTAssert(output == expectation) + } + + func testLineBreak() throws { + + let input = #""" + a\ + b + """# + + let renderer = MarkdownRenderer() + let output = renderer.renderHTML(markdown: input) + + let expectation = #""" +

a
b

+ """# + + XCTAssert(output == expectation) + } + +} diff --git a/Tests/ToucanSDKTests/Utilities/MinifyHTMLTests.swift b/Tests/ToucanSDKTests/Utilities/MinifyHTMLTests.swift new file mode 100644 index 00000000..f7f0e28d --- /dev/null +++ b/Tests/ToucanSDKTests/Utilities/MinifyHTMLTests.swift @@ -0,0 +1,23 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 13/05/2024. +// + +import XCTest +@testable import ToucanSDK + +final class MinifyHTMLTests: XCTestCase { + + func testMinify() throws { + + let html = + "

Hello, world!

" + let minifiedHTML = html.minifyHTML() + // print(minifiedHTML) + + XCTAssert(!minifiedHTML.isEmpty) + } + +} diff --git a/Tests/ToucanSDKTests/Utilities/StringExtensionTestSuite.swift b/Tests/ToucanSDKTests/Utilities/StringExtensionTestSuite.swift new file mode 100644 index 00000000..8af69c46 --- /dev/null +++ b/Tests/ToucanSDKTests/Utilities/StringExtensionTestSuite.swift @@ -0,0 +1,52 @@ +// +// File.swift +// +// +// Created by Tibor Bodecs on 13/06/2024. +// + +import XCTest +@testable import ToucanSDK + +final class StringExtensionTestSuite: XCTestCase { + + func testValidDatePrefix() { + let validString = "2023-06-13-example" + XCTAssert(validString.hasDatePrefix()) + } + + func testInvalidDatePrefixWrongFormat() { + let invalidString = "13-06-2023-example" + XCTAssert(invalidString.hasDatePrefix() == false) + } + + func testInvalidDatePrefixNonNumeric() { + let invalidString = "2023-06-aa-example" + XCTAssert(invalidString.hasDatePrefix() == false) + } + + func testInvalidDatePrefixShortString() { + let shortString = "2023-06-1" + XCTAssert(shortString.hasDatePrefix() == false) + } + + func testEmptyString() { + let emptyString = "" + XCTAssert(emptyString.hasDatePrefix() == false) + } + + func testNoHyphenSuffix() { + let noHyphenString = "2023-06-13example" + XCTAssert(noHyphenString.hasDatePrefix() == false) + } + + func testValidDatePrefixWithDifferentContent() { + let validString = "2023-06-13-12345" + XCTAssert(validString.hasDatePrefix()) + } + + func testValidDatePrefixAtStartOfString() { + let validString = "2023-06-13-" + XCTAssert(validString.hasDatePrefix()) + } +} diff --git a/scripts/check-broken-symlinks.sh b/scripts/check-broken-symlinks.sh new file mode 100755 index 00000000..3d7e919a --- /dev/null +++ b/scripts/check-broken-symlinks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" + +log "Checking for broken symlinks..." +NUM_BROKEN_SYMLINKS=0 +while read -r -d '' file; do + if ! test -e "${REPO_ROOT}/${file}"; then + error "Broken symlink: ${file}" + ((NUM_BROKEN_SYMLINKS++)) + fi +done < <(git -C "${REPO_ROOT}" ls-files -z) + +if [ "${NUM_BROKEN_SYMLINKS}" -gt 0 ]; then + fatal "❌ Found ${NUM_BROKEN_SYMLINKS} symlinks." +fi + +log "βœ… Found 0 symlinks." diff --git a/scripts/check-local-swift-dependencies.sh b/scripts/check-local-swift-dependencies.sh new file mode 100755 index 00000000..0cccd116 --- /dev/null +++ b/scripts/check-local-swift-dependencies.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" + +read -ra PATHS_TO_CHECK <<< "$( \ + git -C "${REPO_ROOT}" ls-files -z \ + "Package.swift" \ + | xargs -0 \ +)" + +for FILE_PATH in "${PATHS_TO_CHECK[@]}"; do +echo $FILE_PATH + if [[ $(grep ".package(path:" "${FILE_PATH}"|wc -l) -ne 0 ]] ; then + fatal "❌ The '${FILE_PATH}' file contains local Swift package reference(s)." + fi +done + +log "βœ… Found 0 local Swift package dependency references." diff --git a/scripts/check-unacceptable-language.sh b/scripts/check-unacceptable-language.sh new file mode 100755 index 00000000..3d937070 --- /dev/null +++ b/scripts/check-unacceptable-language.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" +UNACCEPTABLE_LANGUAGE_PATTERNS_PATH="${CURRENT_SCRIPT_DIR}/unacceptable-language.txt" + +log "Checking for unacceptable language..." +PATHS_WITH_UNACCEPTABLE_LANGUAGE=$(git -C "${REPO_ROOT}" grep \ + -l -F -w \ + -f "${UNACCEPTABLE_LANGUAGE_PATTERNS_PATH}" \ + -- \ + ":(exclude)${UNACCEPTABLE_LANGUAGE_PATTERNS_PATH}" \ +) || true | /usr/bin/paste -s -d " " - + +if [ -n "${PATHS_WITH_UNACCEPTABLE_LANGUAGE}" ]; then + fatal "❌ Found unacceptable language in files: ${PATHS_WITH_UNACCEPTABLE_LANGUAGE}." +fi + +log "βœ… Found no unacceptable language." diff --git a/scripts/install-swift-format.sh b/scripts/install-swift-format.sh new file mode 100755 index 00000000..1dbe9e4c --- /dev/null +++ b/scripts/install-swift-format.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +# https://github.com/apple/swift-format + +VERSION="510.1.0" + +curl -L -o "${VERSION}.tar.gz" "https://github.com/swiftlang/swift-format/archive/refs/tags/${VERSION}.tar.gz" +tar -xf "${VERSION}.tar.gz" +cd "swift-format-${VERSION}" +swift build -c release +install .build/release/swift-format /usr/local/bin/swift-format +cd .. +rm -f "${VERSION}.tar.gz" +rm -rf "swift-format-${VERSION}" diff --git a/scripts/install-toucan.sh b/scripts/install-toucan.sh new file mode 100755 index 00000000..e6fca486 --- /dev/null +++ b/scripts/install-toucan.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + + +swift build -c release +install .build/release/toucan-cli /usr/local/bin/toucan diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 00000000..b42e0f91 --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +NUM_CHECKS_FAILED=0 + +FIX_FORMAT="" +for arg in "$@"; do + if [ "$arg" == "--fix" ]; then + FIX_FORMAT="--fix" + fi +done + +SCRIPT_PATHS=( + "${CURRENT_SCRIPT_DIR}/check-broken-symlinks.sh" + "${CURRENT_SCRIPT_DIR}/check-unacceptable-language.sh" + "${CURRENT_SCRIPT_DIR}/check-local-swift-dependencies.sh" +) + +for SCRIPT_PATH in "${SCRIPT_PATHS[@]}"; do + log "Running ${SCRIPT_PATH}..." + if ! bash "${SCRIPT_PATH}"; then + ((NUM_CHECKS_FAILED+=1)) + fi +done + +log "Running swift-format..." +bash "${CURRENT_SCRIPT_DIR}"/run-swift-format.sh $FIX_FORMAT > /dev/null +FORMAT_EXIT_CODE=$? +if [ $FORMAT_EXIT_CODE -ne 0 ]; then + ((NUM_CHECKS_FAILED+=1)) +fi + +if [ "${NUM_CHECKS_FAILED}" -gt 0 ]; then + fatal "❌ ${NUM_CHECKS_FAILED} check(s) failed." +fi + +log "βœ… All check(s) passed." diff --git a/scripts/run-chmod.sh b/scripts/run-chmod.sh new file mode 100755 index 00000000..783e89da --- /dev/null +++ b/scripts/run-chmod.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" +chmod -R oug+x "${REPO_ROOT}/scripts/" \ No newline at end of file diff --git a/scripts/run-swift-format.sh b/scripts/run-swift-format.sh new file mode 100755 index 00000000..4f5d8ed7 --- /dev/null +++ b/scripts/run-swift-format.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" + +FORMAT_COMMAND=(lint --strict) +for arg in "$@"; do + if [ "$arg" == "--fix" ]; then + FORMAT_COMMAND=(format --in-place) + fi +done + +SWIFTFORMAT_BIN=${SWIFTFORMAT_BIN:-$(command -v swift-format)} || fatal "❌ SWIFTFORMAT_BIN unset and no swift-format on PATH" + +git -C "${REPO_ROOT}" ls-files -z '*.swift' \ + | grep -z -v \ + -e 'Package.swift' \ + | xargs -0 "${SWIFTFORMAT_BIN}" "${FORMAT_COMMAND[@]}" --parallel \ + && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$? + +if [ "${SWIFT_FORMAT_RC}" -ne 0 ]; then + fatal "❌ Running swift-format produced errors. + + To fix, run the following command: + + % ./scripts/run-swift-format.sh --fix + " + exit "${SWIFT_FORMAT_RC}" +fi + +log "βœ… Ran swift-format with no errors." diff --git a/scripts/unacceptable-language.txt b/scripts/unacceptable-language.txt new file mode 100755 index 00000000..6ac4a985 --- /dev/null +++ b/scripts/unacceptable-language.txt @@ -0,0 +1,15 @@ +blacklist +whitelist +slave +master +sane +sanity +insane +insanity +kill +killed +killing +hang +hung +hanged +hanging diff --git a/scripts/uninstall-toucan.sh b/scripts/uninstall-toucan.sh new file mode 100755 index 00000000..a962fe36 --- /dev/null +++ b/scripts/uninstall-toucan.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + + +rm /usr/local/bin/toucan