Repository: toucansites/toucan Branch: main Commit: c00c213fba90 Files: 217 Total size: 908.3 KB Directory structure: gitextract__gpz1oly/ ├── .github/ │ └── workflows/ │ ├── actions.yml │ ├── linux.yml │ ├── macos.yml │ ├── pr-for-formula.yml │ └── tag_actions.yml ├── .gitignore ├── .swift-format ├── .swiftformat ├── .swiftformatignore ├── .swiftheaderignore ├── Docker/ │ ├── Dockerfile │ └── Dockerfile.testing ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources/ │ ├── ToucanCore/ │ │ ├── Extensions/ │ │ │ ├── Dictionary+Extensions.swift │ │ │ ├── Logging+Extensions.swift │ │ │ ├── String+Extensions.swift │ │ │ └── URL+Extensions.swift │ │ ├── GeneratorInfo.swift │ │ ├── Logger.swift │ │ └── ToucanError.swift │ ├── ToucanMarkdown/ │ │ ├── Markdown/ │ │ │ ├── HTML.swift │ │ │ ├── HTMLVisitor.swift │ │ │ ├── MarkdownBlockDirective.swift │ │ │ └── MarkdownToHTMLRenderer.swift │ │ ├── MarkdownRenderer.swift │ │ ├── Outline/ │ │ │ ├── Outline.swift │ │ │ └── OutlineParser.swift │ │ ├── ReadingTime/ │ │ │ └── ReadingTimeCalculator.swift │ │ └── Transformers/ │ │ ├── ContentTransformer.swift │ │ ├── TransformerExecutor.swift │ │ └── TransformerPipeline.swift │ ├── ToucanSDK/ │ │ ├── Behaviors/ │ │ │ ├── Behavior.swift │ │ │ ├── CompileSASSBehavior.swift │ │ │ └── MinifyCSSBehavior.swift │ │ ├── Content/ │ │ │ ├── Content+Query.swift │ │ │ ├── Content.swift │ │ │ ├── ContentResolver.swift │ │ │ ├── ContentTypeResolver.swift │ │ │ ├── IteratorInfo.swift │ │ │ ├── Query+Resolve.swift │ │ │ └── RelationValue.swift │ │ ├── DateFormats/ │ │ │ ├── DateContext.swift │ │ │ └── ToucanDateFormatters.swift │ │ ├── Models/ │ │ │ ├── ContextBundle.swift │ │ │ ├── Destination.swift │ │ │ ├── PipelineResult.swift │ │ │ └── Slug.swift │ │ ├── Outputs/ │ │ │ ├── ContextBundleToHTMLRenderer.swift │ │ │ └── ContextBundleToJSONRenderer.swift │ │ ├── Renderers/ │ │ │ ├── BuildTargetSourceRenderer.swift │ │ │ └── MustacheRenderer.swift │ │ ├── Toucan.swift │ │ ├── Utilities/ │ │ │ ├── Any+AnyCodable.swift │ │ │ ├── AnyCodable+Json.swift │ │ │ ├── Array+AnyCodable.swift │ │ │ ├── ContextKeys.swift │ │ │ ├── CopyManager.swift │ │ │ ├── Dictionary+AnyCodable.swift │ │ │ ├── Encodable+Json.swift │ │ │ └── FirstSucceeding.swift │ │ └── Validators/ │ │ ├── BuildTargetSourceValidator.swift │ │ └── TemplateValidator.swift │ ├── ToucanSerialization/ │ │ ├── ToucanDecoder.swift │ │ ├── ToucanDecoderError.swift │ │ ├── ToucanEncoder.swift │ │ ├── ToucanEncoderError.swift │ │ ├── ToucanJSONDecoder.swift │ │ ├── ToucanJSONEncoder.swift │ │ ├── ToucanYAMLDecoder.swift │ │ └── ToucanYAMLEncoder.swift │ ├── ToucanSource/ │ │ ├── Errors/ │ │ │ ├── ObjectLoaderError.swift │ │ │ ├── SourceLoaderError.swift │ │ │ └── TemplateLoaderError.swift │ │ ├── Extensions/ │ │ │ ├── Decoder+Validate.swift │ │ │ ├── Dictionary+AnyCodable.swift │ │ │ └── FileManagerKit+Extensions.swift │ │ ├── Loaders/ │ │ │ ├── BuildTargetSourceLoader.swift │ │ │ ├── ObjectLoader.swift │ │ │ ├── RawContentLoader.swift │ │ │ └── TemplateLoader.swift │ │ ├── MarkdownParser.swift │ │ ├── Models/ │ │ │ ├── BuildTargetSource.swift │ │ │ ├── BuiltTargetSourceLocations.swift │ │ │ ├── Markdown.swift │ │ │ ├── Origin.swift │ │ │ ├── Path.swift │ │ │ ├── RawContent.swift │ │ │ ├── Template.swift │ │ │ └── View.swift │ │ └── Objects/ │ │ ├── AnyCodable.swift │ │ ├── Blocks/ │ │ │ ├── Block+Attribute.swift │ │ │ ├── Block+Parameter.swift │ │ │ └── Block.swift │ │ ├── Config/ │ │ │ ├── Config+Blocks.swift │ │ │ ├── Config+Contents.swift │ │ │ ├── Config+DataTypes+Date.swift │ │ │ ├── Config+DataTypes.swift │ │ │ ├── Config+Location.swift │ │ │ ├── Config+Pipelines.swift │ │ │ ├── Config+Renderer+ParagraphStyles.swift │ │ │ ├── Config+RendererConfig.swift │ │ │ ├── Config+Site.swift │ │ │ ├── Config+Templates.swift │ │ │ ├── Config+Types.swift │ │ │ └── Config.swift │ │ ├── Date/ │ │ │ ├── DateFormatterConfig.swift │ │ │ └── DateLocalization.swift │ │ ├── Pipeline/ │ │ │ ├── Pipeline+Assets.swift │ │ │ ├── Pipeline+ContentTypes.swift │ │ │ ├── Pipeline+DataTypes+Date.swift │ │ │ ├── Pipeline+DataTypes.swift │ │ │ ├── Pipeline+Engine.swift │ │ │ ├── Pipeline+Output.swift │ │ │ ├── Pipeline+Scope+Context.swift │ │ │ ├── Pipeline+Scope.swift │ │ │ ├── Pipeline+Transformers+Transformer.swift │ │ │ ├── Pipeline+Transformers.swift │ │ │ └── Pipeline.swift │ │ ├── Property/ │ │ │ ├── Property.swift │ │ │ ├── PropertyType.swift │ │ │ └── SystemPropertyKeys.swift │ │ ├── Query/ │ │ │ ├── Condition.swift │ │ │ ├── Direction.swift │ │ │ ├── Operator.swift │ │ │ ├── Order.swift │ │ │ └── Query.swift │ │ ├── Relation/ │ │ │ ├── Relation.swift │ │ │ └── RelationType.swift │ │ ├── Settings/ │ │ │ └── Settings.swift │ │ ├── Target/ │ │ │ ├── Target.swift │ │ │ └── TargetConfig.swift │ │ └── Types/ │ │ └── ContentType.swift │ ├── _GitCommitHash/ │ │ ├── git_commit_hash.c │ │ └── include/ │ │ └── git_commit_hash.h │ ├── toucan/ │ │ └── Entrypoint.swift │ ├── toucan-generate/ │ │ └── Entrypoint.swift │ ├── toucan-init/ │ │ ├── Download.swift │ │ └── Entrypoint.swift │ ├── toucan-serve/ │ │ ├── Entrypoint.swift │ │ └── NotFoundMiddleware.swift │ └── toucan-watch/ │ └── Entrypoint.swift ├── Tests/ │ ├── ToucanCoreTests/ │ │ ├── Extensions/ │ │ │ ├── StringExtensionsTestSuite.swift │ │ │ └── URLExtensionsTestSuite.swift │ │ └── ToucanCoreTestSuite.swift │ ├── ToucanMarkdownTests/ │ │ ├── ContentRendererTestSuite.swift │ │ ├── HTMLVisitorTestSuite.swift │ │ ├── MarkdownBlockDirective+Mock.swift │ │ ├── MarkdownBlockDirectiveTestSuite.swift │ │ └── OutlineTestSuite.swift │ ├── ToucanSDKTests/ │ │ ├── BuildTargetSource/ │ │ │ ├── BuildTargetSourceRendererTestSuite.swift │ │ │ └── BuildTargetSourceValidatorTestSuite.swift │ │ ├── Content/ │ │ │ ├── ContentQueryTestSuite.swift │ │ │ └── ContentResolverTestSuite.swift │ │ ├── DateFormatter/ │ │ │ └── ToucanDateFormatterTestSuite.swift │ │ ├── E2ETestSuite.swift │ │ ├── Files/ │ │ │ ├── MarkdownFile.swift │ │ │ ├── MustacheFile.swift │ │ │ ├── RawContentBundle.swift │ │ │ └── YAMLFile.swift │ │ ├── Mocks/ │ │ │ ├── Mocks+Blocks.swift │ │ │ ├── Mocks+BuildTargetSources.swift │ │ │ ├── Mocks+ContentTypes.swift │ │ │ ├── Mocks+E2E.swift │ │ │ ├── Mocks+Files.swift │ │ │ ├── Mocks+Pipelines.swift │ │ │ ├── Mocks+RawContents.swift │ │ │ ├── Mocks+Templates.swift │ │ │ ├── Mocks+Views.swift │ │ │ └── Mocks.swift │ │ ├── Template/ │ │ │ └── TemplateValidatorTestSuite.swift │ │ ├── Toucan/ │ │ │ └── ToucanTestSuite.swift │ │ └── Utilities/ │ │ ├── AnyCodableWrapTests.swift │ │ ├── CopyManagerTestSuite.swift │ │ ├── PrettyPrint.swift │ │ ├── RecursiveMergeTests.swift │ │ ├── SlugTests.swift │ │ └── UnboxingTestSuite.swift │ └── ToucanSourceTests/ │ ├── BuildTargetSourceLoaderTestSuite.swift │ ├── Extensions/ │ │ └── FileManagerKitExtensionsTestSuite.swift │ ├── Files/ │ │ └── YAMLFile.swift │ ├── MarkdownParserTestSuite.swift │ ├── Models/ │ │ └── BuildTargetSourceLocationsTestSuite.swift │ ├── Objects/ │ │ ├── AnyCodableTestSuite.swift │ │ ├── Config/ │ │ │ └── ConfigTestSuite.swift │ │ ├── DateFormatting/ │ │ │ └── DateFormattingTestSuite.swift │ │ ├── Pipeline/ │ │ │ ├── PipelineContentTypeTestSuite.swift │ │ │ ├── PipelineScopeContextTestSuite.swift │ │ │ ├── PipelineScopeTestSuite.swift │ │ │ ├── PipelineTestSuite.swift │ │ │ └── PipelineTransformersTestSuite.swift │ │ ├── Property/ │ │ │ ├── PropertyTestSuite.swift │ │ │ └── PropertyTypeTestSuite.swift │ │ ├── Query/ │ │ │ ├── ConditionTestSuite.swift │ │ │ ├── DirectionTestSuite.swift │ │ │ ├── OperatorTestSuite.swift │ │ │ ├── OrderTestSuite.swift │ │ │ └── QueryTestSuite.swift │ │ ├── Relation/ │ │ │ ├── RelationTestSuite.swift │ │ │ └── RelationTypeTestSuite.swift │ │ ├── Settings/ │ │ │ └── SettingsTestSuite.swift │ │ ├── Target/ │ │ │ ├── TargetConfigTestSuite.swift │ │ │ └── TargetTestSuite.swift │ │ └── Types/ │ │ └── TypesTestSuite.swift │ ├── RawContentLoaderTestSuite.swift │ └── TemplateLoaderTestSuite.swift └── scripts/ ├── install-toucan.sh ├── packaging/ │ ├── deb.sh │ ├── dmg.sh │ ├── pkg.sh │ ├── rpm.sh │ └── toucan.spec ├── run-chmod.sh └── uninstall-toucan.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/actions.yml ================================================ name: Actions on: pull_request: branches: - main jobs: bb_checks: name: BB Checks uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main with: local_swift_dependencies_check_enabled : true swiftlang_checks: name: Swiftlang Checks uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main with: license_header_check_project_name: "Toucan" format_check_enabled : true broken_symlink_check_enabled : true unacceptable_language_check_enabled : true api_breakage_check_enabled : false docs_check_enabled : false license_header_check_enabled : false shell_check_enabled : false yamllint_check_enabled : false python_lint_check_enabled : false swiftlang_tests: name: Swiftlang Tests uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main with: enable_windows_checks : false linux_build_command: "swift test --parallel --enable-code-coverage" linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"6.0\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]" ================================================ FILE: .github/workflows/linux.yml ================================================ name: Build, Test and Upload Linux Binaries for tag on: workflow_call: inputs: version: required: true type: string run_rpm: required: false type: boolean default: true run_deb: required: false type: boolean default: true static_stdlib: required: false type: boolean default: true jobs: precheck: runs-on: ubuntu-latest outputs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: check run: | if [[ "${{ inputs.run_rpm }}" == "true" || "${{ inputs.run_deb }}" == "true" ]]; then echo "✅ At least one packaging format enabled" echo "should_run=true" >> $GITHUB_OUTPUT else echo "🚫 Both run_rpm and run_deb are false — skipping workflow" echo "should_run=false" >> $GITHUB_OUTPUT fi build-binaries: needs: precheck if: needs.precheck.outputs.should_run == 'true' runs-on: ubuntu-latest container: image: swift:6.3 permissions: contents: write steps: - uses: actions/checkout@v4 - name: Install required Swift tools run: | chmod +x ./scripts/packaging/*.sh apt-get update apt-get install -y curl git clang libcurl4-openssl-dev libssl-dev libatomic1 zip - name: Install RPM tooling if: inputs.run_rpm run: apt-get install -y rpm - name: Install DEB tooling if: inputs.run_deb run: apt-get install -y dpkg-dev - name: Build with static stdlib if: inputs.static_stdlib run: | echo "🔧 Building with static Swift stdlib" swift build -c release -Xswiftc -static-stdlib - name: Build without static stdlib if: ${{ !inputs.static_stdlib }} run: | echo "🔧 Building without static Swift stdlib" swift build -c release - name: Build RPM if: inputs.run_rpm run: ./scripts/packaging/rpm.sh ${{ inputs.version }} - name: Verify RPM if: inputs.run_rpm run: | RPM="build-rpm/toucan-linux-x86_64-${{ inputs.version }}.rpm" echo "🧪 Verifying $RPM" rpm -Kv "$RPM" rpm -qp "$RPM" echo "✅ RPM passed verification" - name: Build DEB if: inputs.run_deb run: ./scripts/packaging/deb.sh ${{ inputs.version }} - name: Verify DEB if: inputs.run_deb run: | DEB="build-deb/toucan-linux-amd64-${{ inputs.version }}.deb" echo "🧪 Verifying $DEB" dpkg-deb --info "$DEB" dpkg-deb --contents "$DEB" echo "✅ DEB passed verification" - name: Upload Linux artifacts if: inputs.run_rpm || inputs.run_deb uses: actions/upload-artifact@v4 with: name: linux-artifacts retention-days: 1 #no need to store it for 90 days path: | ${{ inputs.run_rpm && format('build-rpm/toucan-linux-x86_64-{0}.rpm', inputs.version) || '' }} ${{ inputs.run_rpm && format('build-rpm/toucan-linux-{0}.zip', inputs.version) || '' }} ${{ inputs.run_rpm && format('build-rpm/toucan-linux-{0}.sha256', inputs.version) || '' }} ${{ inputs.run_deb && format('build-deb/toucan-linux-amd64-{0}.deb', inputs.version) || '' }} test-and-upload: runs-on: ubuntu-latest needs: build-binaries steps: - name: Download artifacts uses: actions/download-artifact@v4 with: name: linux-artifacts path: ./packages - name: Check unpacked structure run: find packages - name: Test RPM in Fedora if: inputs.run_rpm run: | docker run --rm -v "$PWD/packages:/packages" fedora \ bash -c "dnf install -y /packages/build-rpm/toucan-linux-x86_64-${{ inputs.version }}.rpm && toucan --version" - name: Upload RPM binary to tag if: inputs.run_rpm uses: AButler/upload-release-assets@v3.0 with: files: packages/build-rpm/toucan-linux-x86_64-${{ inputs.version }}.rpm repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} - name: Upload zipped Linux binaries if: inputs.run_rpm uses: AButler/upload-release-assets@v3.0 with: files: packages/build-rpm/toucan-linux-${{ inputs.version }}.zip repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} - name: Upload SHA256 for zipped Linux binaries if: inputs.run_rpm uses: AButler/upload-release-assets@v3.0 with: files: packages/build-rpm/toucan-linux-${{ inputs.version }}.sha256 repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} - name: Test DEB in Ubuntu if: inputs.run_deb run: | docker run --rm -v "$PWD/packages:/packages" ubuntu \ bash -c ' apt-get update && apt-get install -y curl && dpkg -i /packages/build-deb/toucan-linux-amd64-${{ inputs.version }}.deb || apt-get install -f -y && toucan --version ' - name: Upload DEB binary to tag if: inputs.run_deb uses: AButler/upload-release-assets@v3.0 with: files: packages/build-deb/toucan-linux-amd64-${{ inputs.version }}.deb repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} ================================================ FILE: .github/workflows/macos.yml ================================================ name: Build and Publish macOS Binaries on: workflow_call: inputs: version: required: true type: string run_pkg: required: false type: boolean default: true run_dmg: required: false type: boolean default: true jobs: precheck: runs-on: ubuntu-latest outputs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: check run: | if [[ "${{ inputs.run_pkg }}" == "true" || "${{ inputs.run_dmg }}" == "true" ]]; then echo "✅ At least one packaging format enabled" echo "should_run=true" >> $GITHUB_OUTPUT else echo "🚫 Both run_pkg and run_dmg are false — skipping workflow" echo "should_run=false" >> $GITHUB_OUTPUT fi build-binaries: needs: precheck if: needs.precheck.outputs.should_run == 'true' runs-on: macos-14 permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Import certificates uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ secrets.MAC_CERTIFICATES }} p12-password: ${{ secrets.MAC_CERTIFICATES_PASSWORD }} - name: Verify certificates run: | set -e # Certs to check certs=("Developer ID Application" "Developer ID Installer") # Expiration threshold in days (for warnings) warning_days=30 warning_secs=$((warning_days * 86400)) for cert_name in "${certs[@]}"; do # Try to get the certificate if ! cert_pem=$(security find-certificate -c "$cert_name" -p); then echo "❌ Certificate '$cert_name' not found in keychain" exit 1 fi not_after=$(echo "$cert_pem" | openssl x509 -noout -enddate | cut -d= -f2) expiry_ts=$(date -j -f "%b %e %T %Y %Z" "$not_after" +%s 2>/dev/null || date -d "$not_after" +%s) now_ts=$(date +%s) if [ "$expiry_ts" -le "$now_ts" ]; then echo "❌ Certificate '$cert_name' is expired (expired on $not_after)" exit 1 fi if [ $((expiry_ts - now_ts)) -le "$warning_secs" ]; then echo "⚠️ Certificate '$cert_name' expires soon on $not_after" fi done - name: Install Swift 6.3.1 run: | curl -L https://download.swift.org/swift-6.3.1-release/xcode/swift-6.3.1-RELEASE/swift-6.3.1-RELEASE-osx.pkg -o /tmp/swift.pkg sudo installer -pkg /tmp/swift.pkg -target / TOOLCHAIN_PATH="/Library/Developer/Toolchains/swift-6.3.1-RELEASE.xctoolchain/usr/bin" echo "$TOOLCHAIN_PATH" >> $GITHUB_PATH export PATH="$TOOLCHAIN_PATH:$PATH" swift --version - name: Check for uncommitted changes run: | git_status=$(git status --porcelain) if [[ -n "$git_status" ]]; then echo "❌ Uncommitted changes detected:" echo "$git_status" exit 1 else echo "✅ Working directory is clean. No uncommitted changes." fi - name: Build Swift binaries for arm64 and x86_64 if: inputs.run_pkg run: | chmod +x scripts/packaging/pkg.sh swift build -c release --arch arm64 swift build -c release --arch x86_64 - name: Package .pkg and .zip if: inputs.run_pkg run: scripts/packaging/pkg.sh ${{ inputs.version }} env: MAC_APP_IDENTITY: ${{ secrets.MAC_APP_IDENTITY }} MAC_INSTALLER_IDENTITY: ${{ secrets.MAC_INSTALLER_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} - name: Verify .pkg file if: inputs.run_pkg run: | PKG="release/toucan-macos-${{ inputs.version }}.pkg" echo "🧪 Verifying $PKG" pkgutil --payload-files "$PKG" echo "✅ PKG passed verification" - name: Test installing .pkg file if: inputs.run_pkg run: | PKG="release/toucan-macos-${{ inputs.version }}.pkg" echo "📦 Installing $PKG to /" sudo installer -pkg "$PKG" -target / echo "🔍 Checking for installed binaries" ls -lh /usr/local/bin/toucan* echo "📈 Version output:" /usr/local/bin/toucan --version || echo "⚠️ toucan binary failed to run" - name: Upload .pkg file to tag if: inputs.run_pkg uses: AButler/upload-release-assets@v3.0 with: files: | release/toucan-macos-${{ inputs.version }}.pkg repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} - name: Upload .zip to tag if: inputs.run_pkg uses: AButler/upload-release-assets@v3.0 with: files: release/toucan-macos-${{ inputs.version }}.zip repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} - name: Upload SHA256 to tag if: inputs.run_pkg uses: AButler/upload-release-assets@v3.0 with: files: release/toucan-macos-${{ inputs.version }}.sha256 repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} - name: Create .dmg file if: inputs.run_pkg && inputs.run_dmg run: ./scripts/packaging/dmg.sh ${{ inputs.version }} env: MAC_APP_IDENTITY: ${{ secrets.MAC_APP_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} - name: Verify .dmg file structure and integrity if: inputs.run_pkg && inputs.run_dmg run: | DMG="release/toucan-macos-${{ inputs.version }}.dmg" echo "🧪 Verifying structure of $DMG" hdiutil verify "$DMG" echo "✅ Verified: $DMG is structurally valid" - name: Upload .dmg file to tag if: inputs.run_pkg && inputs.run_dmg uses: AButler/upload-release-assets@v3.0 with: files: | release/toucan-macos-${{ inputs.version }}.dmg repo-token: ${{ secrets.GITHUB_TOKEN }} release-tag: ${{ github.ref_name }} ================================================ FILE: .github/workflows/pr-for-formula.yml ================================================ name: Push Homebrew Formula on: workflow_call: inputs: version: required: true type: string baseurl: required: true type: string homepage: required: true type: string formulafile: required: true type: string formulaclass: required: true type: string repository: required: true type: string secrets: HOMEBREW_TAP_PAT: required: true jobs: push_formula: runs-on: ubuntu-latest steps: - name: Checkout Tap Repo uses: actions/checkout@v4 with: repository: ${{ inputs.repository }} token: ${{ secrets.HOMEBREW_TAP_PAT }} ref: main - name: Set Version Variables run: | echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV echo "BASE_URL=${{ inputs.baseurl }}" >> $GITHUB_ENV echo "HOMEPAGE=${{ inputs.homepage }}" >> $GITHUB_ENV - name: Download SHA256 files from release run: | curl -LO "$BASE_URL/toucan-linux-$VERSION.sha256" curl -LO "$BASE_URL/toucan-macos-$VERSION.sha256" - name: Read SHA256 values id: shas run: | macos_sha=$(awk '{print $1}' "toucan-macos-$VERSION.sha256") linux_sha=$(awk '{print $1}' "toucan-linux-$VERSION.sha256") echo "macos_sha=$macos_sha" >> $GITHUB_OUTPUT echo "linux_sha=$linux_sha" >> $GITHUB_OUTPUT - name: Write Formula File run: | mkdir -p Formula cat > Formula/${{ inputs.formulafile }} <> $GITHUB_ENV echo "version=$CLEAN_VERSION" >> $GITHUB_OUTPUT linux: needs: prepare uses: ./.github/workflows/linux.yml with: version: ${{ needs.prepare.outputs.version }} run_rpm: true run_deb: true static_stdlib: true secrets: inherit macos: needs: prepare uses: ./.github/workflows/macos.yml with: version: ${{ needs.prepare.outputs.version }} run_pkg: true run_dmg: false secrets: inherit pr_for_formula: name: Create a PR for Homebrew Formula needs: [prepare, linux, macos] uses: ./.github/workflows/pr-for-formula.yml with: version: ${{ needs.prepare.outputs.version }} baseurl: https://github.com/toucansites/toucan/releases/download/${{ github.ref_name }} homepage: https://github.com/toucansites/toucan formulafile: toucan.rb formulaclass: Toucan repository: toucansites/homebrew-toucan secrets: HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }} ================================================ FILE: .gitignore ================================================ .DS_Store .swiftpm .build .vscode .obsidian **/dist **/docs Tests/sites/benchmark/ Examples/try-o/ ================================================ FILE: .swift-format ================================================ { "fileScopedDeclarationPrivacy" : { "accessLevel" : "private" }, "indentation" : { "spaces" : 4 }, "multiElementCollectionTrailingCommas": true, "indentConditionalCompilationBlocks" : false, "indentSwitchCaseLabels" : false, "lineBreakAroundMultilineExpressionChainComponents" : true, "lineBreakBeforeControlFlowKeywords" : true, "lineBreakBeforeEachArgument" : true, "lineBreakBeforeEachGenericRequirement" : true, "lineLength" : 80, "maximumBlankLines" : 1, "prioritizeKeepingFunctionOutputTogether" : true, "respectsExistingLineBreaks" : true, "rules" : { "AllPublicDeclarationsHaveDocumentation" : true, "AlwaysUseLowerCamelCase" : false, "AmbiguousTrailingClosureOverload" : true, "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : false, "FileScopedDeclarationPrivacy" : true, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, "NeverForceUnwrap" : false, "NeverUseForceTry" : false, "NeverUseImplicitlyUnwrappedOptionals" : false, "NoAccessLevelOnExtensionDeclaration" : false, "NoAssignmentInExpressions" : true, "NoBlockComments" : true, "NoCasesWithOnlyFallthrough" : true, "NoEmptyTrailingClosureParentheses" : true, "NoLabelsInCasePatterns" : false, "NoLeadingUnderscores" : false, "NoParensAroundConditions" : true, "NoVoidReturnOnFunctionSignature" : true, "OneCasePerLine" : true, "OneVariableDeclarationPerLine" : true, "OnlyOneTrailingClosureArgument" : true, "OrderedImports" : false, "ReturnVoidInsteadOfEmptyTuple" : true, "UseEarlyExits" : false, "UseLetInEveryBoundCaseVariable" : false, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : false, "UseSynthesizedInitializer" : true, "UseTripleSlashForDocumentationComments" : true, "UseWhereClausesInForLoops" : false, "ValidateDocumentationComments" : true }, "spacesAroundRangeFormationOperators" : false, "tabWidth" : 4, "version" : 1 } ================================================ FILE: .swiftformat ================================================ --swiftversion 6 --indent 4 --indentstrings true --smarttabs true --xcodeindentation enabled --maxwidth 80 --trimwhitespace always --self init-only --elseposition next-line --guardelse next-line --ranges nospace --wraparguments before-first --wrapparameters before-first --wrapcollections before-first --wrapconditions before-first --enable trailingclosures --enable todos --enable preferKeyPath --enable organizeDeclarations --organizationmode type --visibilityorder private, fileprivate, internal, package, public, open --categorymark "MARK: - %c" --markcategories false --enable wrapAttributes --funcattributes prev-line --typeattributes prev-line --storedvarattrs prev-line --computedvarattrs prev-line --complexattrs prev-line --enable trailingCommas --commas always --disable wrapSingleLineComments --enable yodaConditions --enable acronyms --acronyms "ID,URL,UUID,HTTP,YML,YAML,JSON,XML" --preserveacronyms "rootUrl" ================================================ FILE: .swiftformatignore ================================================ Package.swift ================================================ FILE: .swiftheaderignore ================================================ .* *.c *.h *.txt *.html *.yaml README.md Package.resolved Makefile LICENSE Package.swift Docker/** scripts/** ================================================ FILE: Docker/Dockerfile ================================================ FROM swift:6.3-noble AS build WORKDIR /build COPY ./Package.* ./ RUN swift package resolve COPY Sources ./Sources COPY Tests ./Tests COPY Package.swift . COPY Package.resolved . RUN swift build -c release --static-swift-stdlib WORKDIR /staging RUN cp "$(swift build --package-path /build -c release --show-bin-path)/toucan" ./ RUN cp "$(swift build --package-path /build -c release --show-bin-path)/toucan-generate" ./ RUN cp "$(swift build --package-path /build -c release --show-bin-path)/toucan-init" ./ RUN cp "$(swift build --package-path /build -c release --show-bin-path)/toucan-serve" ./ RUN cp "$(swift build --package-path /build -c release --show-bin-path)/toucan-watch" ./ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; FROM ubuntu:noble RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ && apt-get -q update \ && apt-get -q dist-upgrade -y \ && apt-get -q install -y tzdata locales curl unzip \ && ln -fs /usr/share/zoneinfo/UTC /etc/localtime \ && dpkg-reconfigure -f noninteractive tzdata \ && locale-gen en_US.UTF-8 \ && update-locale LANG=en_US.UTF-8 \ && rm -r /var/lib/apt/lists/* ENV LANG=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8 RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app toucan WORKDIR /app COPY --from=build --chown=toucan:toucan /staging /app # ✅ Ensure all files in /app are executable by all users RUN chmod -R a+rx /app ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static ENV PATH="/app:$PATH" USER toucan:toucan EXPOSE 3000 ENTRYPOINT ["/app/toucan"] CMD ["--help"] ================================================ FILE: Docker/Dockerfile.testing ================================================ FROM swift:6.1 WORKDIR /app COPY . ./ RUN swift package resolve RUN swift package clean RUN swift package update CMD ["swift", "test", "--parallel", "--enable-code-coverage"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018-2022 Tibor Bödecs Copyright (c) 2022-2025 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. ================================================ FILE: Makefile ================================================ SHELL=/bin/bash .PHONY: docker baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts check: symlinks language deps lint headers symlinks: curl -s $(baseUrl)/check-broken-symlinks.sh | bash language: curl -s $(baseUrl)/check-unacceptable-language.sh | bash deps: curl -s $(baseUrl)/check-local-swift-dependencies.sh | bash lint: curl -s $(baseUrl)/run-swift-format.sh | bash fmt: swiftformat . format: curl -s $(baseUrl)/run-swift-format.sh | bash -s -- --fix headers: curl -s $(baseUrl)/check-swift-headers.sh | bash fix-headers: curl -s $(baseUrl)/check-swift-headers.sh | bash -s -- --fix build: swift build release: swift build -c release test: swift test --parallel test-with-coverage: swift test --parallel --enable-code-coverage clean: rm -rf .build install: ./scripts/install-toucan.sh uninstall: ./scripts/uninstall-toucan.sh docker-image: docker buildx build \ --platform linux/amd64,linux/arm64 \ -t toucan \ -f ./Docker/Dockerfile \ --load \ . # docker run --rm -v $(pwd):/app/site toucan generate /app/site/src /app/site/dist # docker run --rm -v $(pwd):/app/site toucan generate ./site/src ./site/dist --base-url "http://localhost:3000" # docker run --rm -v $(pwd):/app/site --entrypoint /app/toucan toucan generate ./site/src ./site/dist --base-url "http://localhost:3000" # docker run --rm -p 3000:3000 -v $(pwd):/app/site toucan serve --hostname "0.0.0.0" --port 3000 ./site/dist # docker run --rm -v $(pwd):/app/site --entrypoint toucan toucansites/toucan generate /app/site/src /app/site/dist docker-run: docker run --rm -v $(pwd):/app -it swift:6.1 docker-tests: docker build -t toucan-tests . -f ./Docker/Dockerfile.testing && docker run --rm toucan-tests diff: diff --color=always -r dist-live dist --exclude=api || true ================================================ FILE: Package.resolved ================================================ { "originHash" : "b0aa8e8208e365a7988670c760c7c1a87737dac7471c3bcff7b3381dbaabf37b", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { "revision" : "5dd84c7bb48b348751d7bbe7ba94a17bafdcef37", "version" : "1.30.2" } }, { "identity" : "file-manager-kit", "kind" : "remoteSourceControl", "location" : "https://github.com/binarybirds/file-manager-kit", "state" : { "revision" : "89a7485d5564aafd77d846fe4366f7e67d6b4b9b", "version" : "0.4.0" } }, { "identity" : "filemonitor", "kind" : "remoteSourceControl", "location" : "https://github.com/toucansites/FileMonitor", "state" : { "revision" : "082b744b35a2f3d53e19bc1925d358590761551f", "version" : "0.1.0" } }, { "identity" : "hummingbird", "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/hummingbird", "state" : { "revision" : "3ae359b1bb1e72378ed43b59fdcd4d44cac5d7a4", "version" : "2.16.0" } }, { "identity" : "semver.swift", "kind" : "remoteSourceControl", "location" : "https://github.com/johnfairh/Semver.swift.git", "state" : { "revision" : "0a6e2fe061ecb840c9bf80b6427e70ac039239fa", "version" : "1.2.4" } }, { "identity" : "sourcemapper", "kind" : "remoteSourceControl", "location" : "https://github.com/johnfairh/SourceMapper.git", "state" : { "revision" : "2c86d8f44beb2c41effa2f9c5f6cf29b871bf3b9", "version" : "2.0.0" } }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-algorithms", "state" : { "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", "version" : "1.2.1" } }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", "version" : "1.7.0" } }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", "version" : "1.7.0" } }, { "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", "version" : "1.1.3" } }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", "version" : "1.3.0" } }, { "identity" : "swift-certificates", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { "revision" : "bde8ca32a096825dfce37467137c903418c1893d", "version" : "1.19.1" } }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", "version" : "0.7.1" } }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", "version" : "1.4.1" } }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", "version" : "4.5.0" } }, { "identity" : "swift-css-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-css-parser", "state" : { "revision" : "6cf16c6696def00a313daef0e29eb27f5c39ece4", "version" : "0.1.2" } }, { "identity" : "swift-distributed-tracing", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", "version" : "1.4.1" } }, { "identity" : "swift-http-structured-headers", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", "version" : "1.7.0" } }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", "version" : "1.5.1" } }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", "version" : "1.9.1" } }, { "identity" : "swift-markdown", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-markdown", "state" : { "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94", "version" : "0.7.3" } }, { "identity" : "swift-metrics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { "revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5", "version" : "2.10.1" } }, { "identity" : "swift-mustache", "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/swift-mustache", "state" : { "revision" : "2e2a84698dd8a5fff2fe28857f0f95bb03d21d64", "version" : "2.0.2" } }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { "revision" : "bdf004b44f77c56fca752cd1cf243c802f8469c9", "version" : "2.97.0" } }, { "identity" : "swift-nio-extras", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", "version" : "1.34.0" } }, { "identity" : "swift-nio-http2", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", "version" : "1.43.0" } }, { "identity" : "swift-nio-ssl", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", "version" : "2.37.0" } }, { "identity" : "swift-nio-transport-services", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", "version" : "1.28.0" } }, { "identity" : "swift-numerics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", "version" : "1.1.1" } }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { "revision" : "81558271e243f8f47dfe8e9fdd55f3c2b5413f68", "version" : "1.37.0" } }, { "identity" : "swift-sass", "kind" : "remoteSourceControl", "location" : "https://github.com/johnfairh/swift-sass", "state" : { "revision" : "361b70bbb4f038cc850cb2d5e6ef5aa3495cb6ef", "version" : "3.3.0" } }, { "identity" : "swift-service-context", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-service-context.git", "state" : { "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", "version" : "1.3.0" } }, { "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", "version" : "2.11.0" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", "version" : "1.6.4" } }, { "identity" : "swiftcommand", "kind" : "remoteSourceControl", "location" : "https://github.com/Zollerboy1/SwiftCommand", "state" : { "revision" : "28efb038351a8c45010772b407adb9ea02ba67b5", "version" : "1.4.2" } }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", "location" : "https://github.com/scinfu/SwiftSoup", "state" : { "revision" : "6c7915e16f729857aec3e99068c361e58a00ed68", "version" : "2.13.4" } }, { "identity" : "version", "kind" : "remoteSourceControl", "location" : "https://github.com/mxcl/Version", "state" : { "revision" : "3043fcd2a50375db76d89ff206a612471833d1c2", "version" : "2.2.1" } }, { "identity" : "yams", "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams", "state" : { "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", "version" : "5.4.0" } } ], "version" : 3 } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 6.1 import PackageDescription let swiftSettings: [SwiftSetting] = [ .enableExperimentalFeature("StrictConcurrency=complete"), ] /// Git commit hash information based on context. var gitCommitHash: String { if let git = Context.gitInformation { let base = git.currentCommit return git.hasUncommittedChanges ? "\(base)-dev" : base } return "untracked" } let package = Package( name: "toucan", platforms: [ .macOS(.v14), .iOS(.v17), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1), ], products: [ .executable(name: "toucan", targets: ["toucan"]), .executable(name: "toucan-init", targets: ["toucan-init"]), .executable(name: "toucan-generate", targets: ["toucan-generate"]), .executable(name: "toucan-watch", targets: ["toucan-watch"]), .executable(name: "toucan-serve", targets: ["toucan-serve"]), .library(name: "ToucanCore", targets: ["ToucanCore"]), .library(name: "ToucanSerialization", targets: ["ToucanSerialization"]), .library(name: "ToucanMarkdown", targets: ["ToucanMarkdown"]), .library(name: "ToucanSource", targets: ["ToucanSource"]), .library(name: "ToucanSDK", targets: ["ToucanSDK"]), ], dependencies: [ .package( url: "https://github.com/apple/swift-argument-parser", "1.5.0"..<"1.7.1" // breaks dep graph for 6.0 ), .package( url: "https://github.com/apple/swift-markdown", from: "0.6.0" ), .package( url: "https://github.com/apple/swift-log", "1.6.0"..<"1.10.0" ), .package( url: "https://github.com/apple/swift-nio", "2.0.0"..<"2.97.1" // breaks dep graph for 6.0 ), .package( url: "https://github.com/binarybirds/file-manager-kit", exact: "0.4.0" ), .package( url: "https://github.com/swift-server/async-http-client", "1.0.0"..<"1.30.3" // swift configuration breaks on 6.3.1 ), .package( url: "https://github.com/hummingbird-project/hummingbird", "2.0.0"..<"2.17.0" // swift configuration breaks on 6.3.1 ), .package( url: "https://github.com/hummingbird-project/swift-mustache", from: "2.0.0" ), .package( url: "https://github.com/jpsim/Yams", from: "5.4.0" ), .package( url: "https://github.com/scinfu/SwiftSoup", from: "2.8.0" ), .package( url: "https://github.com/toucansites/FileMonitor", from: "0.1.0" ), .package( url: "https://github.com/Zollerboy1/SwiftCommand", from: "1.4.0" ), .package( url: "https://github.com/johnfairh/swift-sass", from: "3.1.0" ), .package( url: "https://github.com/stackotter/swift-css-parser", from: "0.1.2" ), .package( url: "https://github.com/mxcl/Version", from: "2.2.0" ), ], targets: [ .target( name: "_GitCommitHash", cSettings: [ .define("GIT_COMMIT_HASH", to: #""\#(gitCommitHash)""#) ] ), // MARK: - executable targets .executableTarget( name: "toucan", dependencies: [ .product( name: "ArgumentParser", package: "swift-argument-parser" ), .product(name: "Logging", package: "swift-log"), .product(name: "SwiftCommand", package: "SwiftCommand"), .target(name: "ToucanCore"), ], swiftSettings: swiftSettings ), .executableTarget( name: "toucan-init", dependencies: [ .product( name: "ArgumentParser", package: "swift-argument-parser" ), .product(name: "Logging", package: "swift-log"), .product(name: "FileManagerKit", package: "file-manager-kit"), .product(name: "SwiftCommand", package: "SwiftCommand"), .target(name: "ToucanCore"), .target(name: "ToucanSource") ], swiftSettings: swiftSettings ), .executableTarget( name: "toucan-generate", dependencies: [ .product( name: "ArgumentParser", package: "swift-argument-parser" ), .product(name: "Logging", package: "swift-log"), .target(name: "ToucanSDK"), ], swiftSettings: swiftSettings ), .executableTarget( name: "toucan-watch", dependencies: [ .product( name: "ArgumentParser", package: "swift-argument-parser" ), .product(name: "Logging", package: "swift-log"), .product(name: "FileMonitor", package: "FileMonitor"), .product(name: "SwiftCommand", package: "SwiftCommand"), .target(name: "ToucanCore"), ], swiftSettings: swiftSettings ), .executableTarget( name: "toucan-serve", dependencies: [ .product( name: "ArgumentParser", package: "swift-argument-parser" ), .product(name: "Logging", package: "swift-log"), .product(name: "Hummingbird", package: "hummingbird"), .target(name: "ToucanCore"), ], swiftSettings: swiftSettings ), // MARK: - regular targets .target( name: "ToucanCore", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "FileManagerKit", package: "file-manager-kit"), .product(name: "Version", package: "Version"), .target(name: "_GitCommitHash"), ], swiftSettings: swiftSettings ), .target( name: "ToucanSerialization", dependencies: [ .product(name: "Yams", package: "yams"), .target(name: "ToucanCore"), ], swiftSettings: swiftSettings ), .target( name: "ToucanMarkdown", dependencies: [ // for outline .product(name: "SwiftSoup", package: "SwiftSoup"), // for markdown to html .product(name: "Markdown", package: "swift-markdown"), // for transformers .product(name: "SwiftCommand", package: "SwiftCommand"), .target(name: "ToucanCore"), ], swiftSettings: swiftSettings ), .target( name: "ToucanSource", dependencies: [ .target(name: "ToucanCore"), .target(name: "ToucanSerialization"), ], swiftSettings: swiftSettings ), .target( name: "ToucanSDK", dependencies: [ .product(name: "Mustache", package: "swift-mustache"), .product(name: "DartSass", package: "swift-sass"), .product(name: "SwiftCSSParser", package: "swift-css-parser"), .target(name: "ToucanCore"), .target(name: "ToucanSerialization"), .target(name: "ToucanMarkdown"), .target(name: "ToucanSource"), ], swiftSettings: swiftSettings ), // MARK: - test targets .testTarget( name: "ToucanCoreTests", dependencies: [ .target(name: "ToucanCore"), ] ), .testTarget( name: "ToucanMarkdownTests", dependencies: [ .target(name: "ToucanMarkdown"), ] ), .testTarget( name: "ToucanSourceTests", dependencies: [ .target(name: "ToucanCore"), .target(name: "ToucanSource"), .product( name: "FileManagerKitBuilder", package: "file-manager-kit" ), ] ), .testTarget( name: "ToucanSDKTests", dependencies: [ .target(name: "ToucanSDK"), .product( name: "FileManagerKitBuilder", package: "file-manager-kit" ), ] ), ] ) ================================================ FILE: README.md ================================================ # Toucan Toucan is a markdown-based Static Site Generator (SSG) written in Swift. ## Installation ## Compile from source Make sure you have Swift 6+ installed. See [how to install swift](https://www.swift.org/install/) for instructions. To build Toucan from source, run the following commands: ```shell # clone the Toucan repository git clone https://github.com/toucansites/toucan.git cd toucan # install Toucan on your system under /usr/local/bin make install # enter your password, if needed # verify which toucan # should return /usr/local/bin/toucan # uninstall, remove Toucan from your system make uninstall # enter your password, if needed ``` ## Quickstart To quickly bootstrap a Toucan-based static site, run the following commands: ```shell toucan init my-site cd my-site toucan generate toucan serve # Visit: http://localhost:3000 ``` ## Documentation The complete documentation for Toucan is available on [toucansites.com](https://toucansites.com/docs/). ================================================ FILE: Sources/ToucanCore/Extensions/Dictionary+Extensions.swift ================================================ // // Dictionary+Extensions.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 03.. // public extension Dictionary { /// Transforms the keys of the dictionary using the given closure, preserving the associated values. /// /// This method applies the provided transformation to each key in the dictionary, /// resulting in a new dictionary with the transformed keys and original values. /// /// - Parameter t: A closure that takes a key as input and returns a transformed key. /// - Returns: A dictionary with transformed keys and the original values. func mapKeys( _ t: (Key) throws -> T ) rethrows -> [T: Value] { try .init( uniqueKeysWithValues: map { try (t($0.key), $0.value) } ) } } /// This extension allows recursive merging of dictionaries with String keys and Any values. public extension Dictionary where Key == String { /// Recursively merges another `[String: Value]` 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: Value] ) -> [String: Value] { var result = self for (key, value) in other { if let existingValue = result[key] as? [String: Value], let newValue = value as? [String: Value] { result[key] = existingValue.recursivelyMerged(with: newValue) as? Value } else { result[key] = value } } return result } /// Retrieves a nested value from the receiver using a dot-separated key path. /// Supports traversal through dictionaries with `String` keys and arrays with numeric indices. /// /// - Parameter keyPath: A dot-separated string representing the path to the nested value. /// - Returns: The value at the specified key path, or `nil` if the path is invalid. func value( forKeyPath keyPath: String ) -> Any? { let keys = keyPath.split(separator: ".").map(String.init) var current: Any? = self for key in keys { if let dict = current as? [String: Any], let next = dict[key] { current = next continue } if let array = current as? [Any], let index = Int(key), array.indices.contains(index) { current = array[index] continue } return nil } return current } } ================================================ FILE: Sources/ToucanCore/Extensions/Logging+Extensions.swift ================================================ // // Logging+Extensions.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import class Foundation.ProcessInfo import Logging public extension Logger { /// Returns a logger instance for the specified subsystem. /// /// Constructs a logger with a label based on the subsystem identifier and sets its log level. /// The log level is determined by checking environment variables /// for subsystem-specific or global log level settings. If none are found, the provided default level is used. /// /// - Parameters: /// - id: The subsystem identifier (e.g., `"generate"`, `"object-loader"`). /// - level: The default log level to use if not specified elsewhere. Defaults to `.info`. /// - Returns: A configured `Logger` instance for the subsystem. static func subsystem( _ id: String = "", _ level: Logger.Level = .info ) -> Logger { var logger = Logger(label: id.loggerLabel()) logger.logLevel = findEnvLogLevel(id) ?? level return logger } } private extension Logger { /// Returns the log level from environment variables for the given subsystem identifier. /// /// Checks for a subsystem-specific log level key and a global log level key (`TOUCAN_LOG_LEVEL`) /// in the environment. If a valid log level string is found, it is converted to a `Logger.Level`. /// /// - Parameter id: The subsystem identifier. /// - Returns: The log level if found and valid, otherwise `nil`. static func findEnvLogLevel(_ id: String) -> Logger.Level? { let env = ProcessInfo.processInfo.environment let keys = [ id.subsystemLogLevelKey(), "TOUCAN_LOG_LEVEL", ] for key in keys { if let rawLevel = env[key]?.lowercased(), let level = Logger.Level(rawValue: rawLevel) { return level } } return nil } } private extension String { /// Returns the logger label for a subsystem. /// /// Constructs a logger label by joining "TOUCAN" and the subsystem identifier with a hyphen. /// If the identifier is empty, returns "toucan". /// /// - Examples: /// - For an empty string: `"toucan"` /// - For `"generate"`: `"toucan-generate"` /// - For `"object-loader"`: `"toucan-object-loader"` func loggerLabel() -> String { let prefix = "toucan" let parts = isEmpty ? [prefix] : [prefix, self] return parts.joined(separator: "-") } /// Returns the environment variable key for the log level of a subsystem. /// /// This method constructs a log level key by converting the logger label to uppercase, /// replacing hyphens with underscores, and appending "_LOG_LEVEL". /// /// - Examples: /// - For an empty string: `"TOUCAN_LOG_LEVEL"` /// - For `"generate"`: `"TOUCAN_GENERATE_LOG_LEVEL"` /// - For `"object-loader"`: `"TOUCAN_OBJECT_LOADER_LOG_LEVEL"` func subsystemLogLevelKey() -> String { loggerLabel() .uppercased() .replacing("-", with: "_") .appending("_LOG_LEVEL") } } ================================================ FILE: Sources/ToucanCore/Extensions/String+Extensions.swift ================================================ // // String+Extensions.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 17.. // import Foundation public extension String { /// A convenience property that converts an empty string to `nil`. /// /// This is useful for cases where an empty string should be treated as the absence of a value, /// such as when preparing optional fields for encoding, form validation, or API payloads. /// /// For example: /// ```swift /// let name: String = "" /// let optionalName = name.emptyToNil // Result: nil /// ``` /// /// - Returns: `nil` if the string is empty; otherwise, returns the original string. var emptyToNil: String? { isEmpty ? nil : self } /// Removes the leading slash from the string if present. /// /// This method checks if the string starts with a slash (`/`). If so, it removes it. /// /// - Returns: A new string without a leading slash, or the original string if no leading slash exists. func dropLeadingSlash() -> String { if hasPrefix("/") { return String(dropFirst()) } return self } /// Removes the trailing slash from the string if present. /// /// This method checks if the string ends with a slash (`/`). If so, it removes it. /// /// - Returns: A new string without a trailing slash, or the original string if no trailing slash exists. func dropTrailingSlash() -> String { if hasSuffix("/") { return String(dropLast()) } return self } /// Ensures the string starts with a leading slash. /// /// This method checks if the string already begins with a slash (`/`). If it does, the original string is returned. /// Otherwise, it prepends a slash to the beginning of the string. /// /// - Returns: A new string with a leading slash ensured. func ensureLeadingSlash() -> String { if hasPrefix("/") { return self } return "/" + self } /// Appends a trailing slash to the string if not already present. /// /// This method checks if the string ends with a slash (`/`). If not, it appends one. /// /// - Returns: A new string with a trailing slash ensured. func ensureTrailingSlash() -> String { if hasSuffix("/") { return self } return self + "/" } /// Replaces substrings in the string using a given dictionary of replacements. /// /// This method iterates over the key-value pairs in the provided dictionary /// and replaces all occurrences of each key with its corresponding value. /// /// - Parameter dictionary: A dictionary where each key is a substring to search for, /// and the corresponding value is the string to replace it with. /// - Returns: A new string with all specified substrings replaced. func replacing( _ dictionary: [String: String] ) -> String { var result = self for (key, value) in dictionary { result = result.replacing(key, with: value) } return result } /// Converts the string into a URL-friendly slug. /// /// This method removes diacritics, trims whitespace, lowercases the string, /// and keeps only alphanumeric characters, dashes, underscores, and periods. /// Invalid characters are removed, and remaining components are joined with hyphens. /// /// - Returns: A slugified version of the original string. func slugify() -> String { let allowed = CharacterSet( charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-_." ) return trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .folding( options: .diacriticInsensitive, locale: .init(identifier: "en-US") ) .components(separatedBy: allowed.inverted) .filter { $0 != "" } .joined(separator: "-") } /// Resolves a relative asset path by combining it with a base URL, assets path, and slug. /// /// This method builds a complete asset URL by handling various cases: /// - If the base URL or assets path is empty, it returns the original string. /// - If the string starts with `/`, it appends the string directly to the base URL. /// - If the string starts with a relative prefix (e.g., `./assetsPath/`), it removes the prefix /// and combines the base URL, assets path, slug, and remaining path parts into a full URL. /// /// - Parameters: /// - baseURL: The base URL used to form the full path. /// - assetsPath: The relative directory for the assets. /// - slug: A string inserted in the final path for identification or grouping. /// - Returns: A full string URL combining all parts, or the original string if no resolution is applied. func resolveAsset( baseURL: String, assetsPath: String, slug: String ) -> String { if baseURL.isEmpty || assetsPath.isEmpty { return self } let baseURL = baseURL.dropTrailingSlash() if hasPrefix("/") { return [baseURL, dropLeadingSlash()].joined(separator: "/") } let prefix = "./\(assetsPath)/" guard hasPrefix(prefix) else { return self } let src = String(dropFirst(prefix.count)) return [baseURL, assetsPath, slug, src] .filter { !$0.isEmpty } .joined(separator: "/") } /// Checks if a string contains only valid URL characters. /// /// Allowed: unreserved (`A–Z a–z 0–9 - . _ ~`), reserved /// (`:/?#[]@!$&'()*+,;=`), and `%` for encoding. /// /// - Returns: `true` if all characters are valid, otherwise `false`. func containsOnlyValidURLCharacters() -> Bool { let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let numerics = "0123456789" let special = "-._~{}%" let reserved = ":/?#[]@!$&'()*+,;=" let allowed = CharacterSet( charactersIn: alphabet + numerics + special + reserved ) return unicodeScalars.allSatisfy { allowed.contains($0) } } /// Checks if a string contains only valid URL characters. /// /// Allowed: unreserved (`A–Z a–z 0–9 - . _ ~`), reserved /// (`:/?#[]@!$&'()*+,;=`), and `%` for encoding. /// /// - Returns: `true` if all characters are valid, otherwise `false`. func containsOnlyValidPathCharacters() -> Bool { let disallowed = CharacterSet( charactersIn: "%?#&=" ) return unicodeScalars.allSatisfy { !disallowed.contains($0) } } } ================================================ FILE: Sources/ToucanCore/Extensions/URL+Extensions.swift ================================================ // // URL+Extensions.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 17.. // import Foundation public extension URL { /// Returns a new URL by appending the given path component if it is non-nil and not empty. /// /// This method is useful when working with optional path components where you want to /// conditionally append the value only if it's meaningful (i.e., not `nil` or an empty string). /// /// - Parameter path: An optional string representing the path component to append. /// - Returns: A new `URL` with the appended path component if valid; otherwise, the original URL. /// /// ## Example /// ```swift /// let baseURL = URL(string: "https://example.com/api")! /// let endpoint: String? = "users" /// let fullURL = baseURL.appendingPathIfPresent(endpoint) /// // fullURL: https://example.com/api/users /// ``` func appendingPathIfPresent(_ path: String?) -> URL { guard let path, !path.isEmpty else { return self } return appending(path: path) } } ================================================ FILE: Sources/ToucanCore/GeneratorInfo.swift ================================================ // // GeneratorInfo.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 19.. // import _GitCommitHash import Version public extension GeneratorInfo { /// Returns the most current version of the generator. static var current: Self { .v1_0_0 } } /// List available releases here extension GeneratorInfo { static let v1_0_0 = GeneratorInfo(version: "1.0.0") static let v1_0_0_rc_1 = GeneratorInfo(version: "1.0.0-rc.1") static let v1_0_0_beta_6 = GeneratorInfo(version: "1.0.0-beta.6") static let v1_0_0_beta_5 = GeneratorInfo(version: "1.0.0-beta.5") static let v1_0_0_beta_4 = GeneratorInfo(version: "1.0.0-beta.4") static let v1_0_0_beta_3 = GeneratorInfo(version: "1.0.0-beta.3") static let v1_0_0_beta_2 = GeneratorInfo(version: "1.0.0-beta.2") static let v1_0_0_beta_1 = GeneratorInfo(version: "1.0.0-beta.1") } /// Metadata describing the content generator, including its name, version, and homepage link. public struct GeneratorInfo: Codable, Sendable { /// The name of the generator. public let name: String /// The version (e.g., `"1.0.0"`, `"1.0.0-beta.4"`). public let release: Version /// The git commit hash based on the SPM context. public var gitCommitHash: String { .init(cString: git_commit_hash()) } /// The complete version information based on the version and the git commit hash public var version: String { "\(release.description) (\(gitCommitHash))" } /// A URL pointing to the generator’s homepage or documentation. public let link: String /// Initializes a generator metadata instance. /// /// - Parameters: /// - name: The name of the generator (defaults to `"Toucan"`). /// - version: The generator version string. /// - link: A link to the project or documentation (defaults to GitHub). init( name: String = "Toucan", version: String, link: String = "https://github.com/toucansites/toucan" ) { self.name = name self.release = Version(version)! self.link = link } } ================================================ FILE: Sources/ToucanCore/Logger.swift ================================================ // // Logger.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import Logging /// A protocol for types that can provide structured metadata for logging. /// /// Conforming types expose a dictionary of metadata values used to enrich log messages. public protocol LoggerMetadataRepresentable { /// A dictionary of key-value pairs representing structured logging metadata. /// /// This metadata can be used to provide additional context in log output. var logMetadata: [String: Logger.MetadataValue] { get } } ================================================ FILE: Sources/ToucanCore/ToucanError.swift ================================================ // // ToucanError.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 20.. // import Foundation /// A protocol for custom errors used in the Toucan framework. /// /// Provides properties for logging, user-facing messages, and a method /// to format nested error messages in a readable stack-like format. public protocol ToucanError: Error { /// A developer-facing error description used for logging purposes. var logMessage: String { get } /// A simplified error message suitable for display to end users. var userFriendlyMessage: String { get } /// A list of underlying errors, useful for representing error hierarchies. var underlyingErrors: [Error] { get } /// Generates a readable stack-like message of the error and any underlying errors. /// /// - Returns: A formatted string detailing the error structure. func logMessageStack() -> String /// Searches for an error of a specific type in the error hierarchy. /// /// This method traverses the list of underlying errors and attempts to cast /// each one to the specified error type `T`. If a match is found, it is returned. /// The search is recursive and will descend into nested `ToucanError`s. /// /// - Parameter errorType: The type of error to search for. /// - Returns: An instance of the specified error type if found, otherwise `nil`. func lookup( _ errorType: T.Type ) -> T? /// Searches for a specific associated value in the error hierarchy using a custom matcher. /// /// This method first attempts to locate an error of type `T`, and if successful, /// applies the provided matcher closure to extract an associated value. /// /// - Parameter t: A closure that takes an error of type `T` and returns an associated value of type `V?`. /// - Returns: The extracted associated value if found, otherwise `nil`. func lookup( _ t: (T) -> V? ) -> V? } public extension ToucanError { /// Searches for an error of a specific type in the error hierarchy. /// /// This method traverses the list of underlying errors and attempts to cast /// each one to the specified error type `T`. If a match is found, it is returned. /// The search is recursive and will descend into nested `ToucanError`s. /// /// - Parameter errorType: The type of error to search for. /// - Returns: An instance of the specified error type if found, otherwise `nil`. func lookup( _ errorType: T.Type ) -> T? { for error in underlyingErrors { if let match = error as? T { return match } if let match = (error as ToucanError).lookup(errorType) { return match } } return nil } /// Searches for a specific associated value in the error hierarchy using a custom matcher. /// /// This method first attempts to locate an error of type `T`, and if successful, /// applies the provided matcher closure to extract an associated value. /// /// - Parameter t: A closure that takes an error of type `T` and returns an associated value of type `V?`. /// - Returns: The extracted associated value if found, otherwise `nil`. func lookup( _ t: (T) -> V? ) -> V? { lookup(T.self).flatMap(t) } } /// Conforms `NSError` to the `ToucanError` protocol, providing /// default implementations for logging and user-friendly messages. extension NSError: ToucanError { /// A detailed log message composed of the domain, code, and localized description. public var logMessage: String { "\(domain):\(code) - \(localizedDescription)" } /// A user-facing message derived from the localized description. public var userFriendlyMessage: String { "\(localizedDescription)" } } /// Provides default implementations for `ToucanError` protocol /// including empty `underlyingErrors` and a recursive `logMessageStack`. public extension ToucanError { /// A default empty list of underlying errors. Can be overridden by conforming types to provide error hierarchies. var underlyingErrors: [Error] { [] } /// Recursively builds a string that describes the error and its underlying errors in a readable format. /// /// - Returns: A formatted stack-like string representing the error and any nested underlying errors. func logMessageStack() -> String { format(error: self) } /// Recursively formats an error and its underlying errors into a structured log message. /// /// - Parameters: /// - error: The error to format. /// - prefix: The current indentation prefix. /// - isLast: Indicates whether the error is the last in its group. /// - Returns: A formatted string representing the error hierarchy. private func format( error: Error, prefix: String = "", isLast: Bool = true ) -> String { let type = type(of: error) var message: String var underlyingErrors: [Error] switch error { case let e as ToucanError: message = e.logMessage underlyingErrors = e.underlyingErrors case let e as LocalizedError: message = e.localizedDescription underlyingErrors = [] default: message = "\(error)" underlyingErrors = [] } let branch = prefix.isEmpty ? "" : (isLast ? "└─ " : "├─ ") var output = "\(prefix)\(branch)\(type): \"\(message)\"\n" let childPrefix = prefix + (isLast ? " " : "│ ") let childCount = underlyingErrors.count for (idx, error) in underlyingErrors.enumerated() { let lastChild = (idx == childCount - 1) output += format( error: error, prefix: childPrefix, isLast: lastChild ) } return output } } ================================================ FILE: Sources/ToucanMarkdown/Markdown/HTML.swift ================================================ // // HTML.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 19.. // struct HTML { enum TagType { case standard case short } struct Attribute { var key: String var value: String } var name: String var type: TagType var attributes: [Attribute] var contents: String? init( name: String, type: TagType = .standard, attributes: [Attribute] = [], contents: String? = nil ) { self.name = name self.type = type self.attributes = attributes self.contents = contents } func render() -> String { let attributeString = attributes .map { #"\#($0.key)="\#($0.value)""# } .joined(separator: " ") let tag = [name, attributeString] .filter { !$0.isEmpty } .joined(separator: " ") var result = "<\(tag)>" result += contents ?? "" if type == .standard { result += "" } return result } } ================================================ FILE: Sources/ToucanMarkdown/Markdown/HTMLVisitor.swift ================================================ // // HTMLVisitor.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 19.. // import Logging import Markdown import ToucanCore /// NOTE: https://www.markdownguide.org/basic-syntax/ private extension String { func escapeAngleBrackets() -> String { replacing( [ #"<"#: #"<"#, #">"#: #">"#, // #"&"#: #"&"#, // #"'"#: #"'"#, // #"""#: #"""#, ] ) } } private extension Markup { var isInsideList: Bool { self is ListItemContainer || parent?.isInsideList == true } } private extension [DirectiveArgument] { func getFirstValueBy(key name: String) -> String? { first(where: { $0.name == name })?.value } } struct HTMLVisitor: MarkupVisitor { typealias Result = String var customBlockDirectives: [MarkdownBlockDirective] var paragraphStyles: [String: [String]] var logger: Logger var slug: String var assetsPath: String var baseURL: String init( blockDirectives: [MarkdownBlockDirective] = [], paragraphStyles: [String: [String]], slug: String, assetsPath: String, baseURL: String, logger: Logger = .subsystem("html-visitor") ) { self.customBlockDirectives = blockDirectives self.paragraphStyles = paragraphStyles self.slug = slug self.assetsPath = assetsPath self.baseURL = baseURL self.logger = logger } // MARK: - visitor functions private mutating func visit( _ children: MarkupChildren ) -> Result { var result = "" for child in children { result += visit(child) } return result } mutating func defaultVisit( _ markup: any Markup ) -> Result { visit(markup.children) } mutating func visitText( _ text: Text ) -> Result { text.plainText } mutating func visitHTMLBlock( _ html: HTMLBlock ) -> Result { html.rawHTML //.escapeAngleBrackets() } mutating func visitInlineHTML( _ inlineHTML: InlineHTML ) -> Result { inlineHTML.rawHTML.escapeAngleBrackets() } // MARK: - simple HTML elements mutating func visitSoftBreak( _: SoftBreak ) -> Result { HTML(name: "br", type: .short).render() } mutating func visitLineBreak( _: LineBreak ) -> Result { HTML(name: "br", type: .short).render() } mutating func visitThematicBreak( _: ThematicBreak ) -> Result { HTML(name: "hr", type: .short).render() } mutating func visitListItem( _ listItem: ListItem ) -> Result { HTML(name: "li", contents: visit(listItem.children)).render() } mutating func visitOrderedList( _ orderedList: OrderedList ) -> Result { var attributes: [HTML.Attribute] = [] if orderedList.startIndex > 1 { attributes.append( .init( key: "start", value: String( orderedList.startIndex ) ) ) } return HTML( name: "ol", attributes: attributes, contents: visit(orderedList.children) ) .render() } mutating func visitUnorderedList( _ unorderedList: UnorderedList ) -> Result { HTML(name: "ul", contents: visit(unorderedList.children)).render() } mutating func visitInlineCode( _ inlineCode: InlineCode ) -> Result { HTML( name: "code", contents: inlineCode.code.escapeAngleBrackets() ) .render() } mutating func visitEmphasis( _ emphasis: Emphasis ) -> Result { HTML(name: "em", contents: visit(emphasis.children)).render() } mutating func visitStrong( _ strong: Strong ) -> Result { HTML(name: "strong", contents: visit(strong.children)).render() } mutating func visitStrikethrough( _ strikethrough: Strikethrough ) -> Result { HTML(name: "s", contents: visit(strikethrough.children)).render() } mutating func visitParagraph( _ paragraph: Paragraph ) -> Result { let filterBlocks = customBlockDirectives .filter { $0.removesChildParagraph ?? false } .map(\.name) if let block = paragraph.parent as? BlockDirective, filterBlocks.contains(block.name.lowercased()) { return visit(paragraph.children) } /// if the parent is a list element, we don't need to render the p tag if paragraph.isInsideList { return visit(paragraph.children) } return HTML(name: "p", contents: visit(paragraph.children)).render() } mutating func visitBlockQuote( _ blockQuote: BlockQuote ) -> Result { var paragraphCount = 0 var otherCount = 0 var type: String? var dropCount = 0 for i in blockQuote.children { if let p = i as? Paragraph { paragraphCount += 1 let text = p.plainText.lowercased() typeLoop: for (typeValue, prefixes) in paragraphStyles { for prefix in prefixes { let fullPrefix = "\(prefix): ".lowercased() if text.hasPrefix(fullPrefix) { type = typeValue dropCount = fullPrefix.count break typeLoop } } } } else { otherCount += 1 } } guard let type, otherCount == 0, paragraphCount == 1 else { return HTML( name: "blockquote", contents: visit(blockQuote.children) ) .render() } let paragraph = visit(blockQuote.children) let pTagCount = 3 let contents = paragraph.prefix(pTagCount) + paragraph.dropFirst(pTagCount).dropFirst(dropCount) return HTML( name: "blockquote", attributes: [ .init(key: "class", value: type) ], contents: String(contents) ) .render() } mutating func visitCodeBlock( _ codeBlock: CodeBlock ) -> Result { var attributes: [HTML.Attribute] = [] if let language = codeBlock.language { attributes.append( .init( key: "class", value: "language-\(language.lowercased())" ) ) } let code = HTML( name: "code", attributes: attributes, contents: codeBlock.code .escapeAngleBrackets() .replacing( [ #"/*!*/"#: #""#, #"/*.*/"#: "", ] ) ) .render() return HTML(name: "pre", contents: code).render() } mutating func visitHeading( _ heading: Heading ) -> Result { var attributes: [HTML.Attribute] = [] if [2, 3].contains(heading.level) { let fragment = heading.plainText.lowercased().slugify() let id = HTML.Attribute(key: "id", value: "\(fragment)") attributes.append(id) } return HTML( name: "h\(heading.level)", attributes: attributes, contents: visit(heading.children) ) .render() } mutating func visitLink( _ link: Link ) -> Result { var attributes: [HTML.Attribute] = [] if let destination = link.destination { let anchorPrefix = "#[name]" if destination.hasPrefix(anchorPrefix) { attributes.append( .init( key: "name", value: String(destination.dropFirst(anchorPrefix.count)) ) ) } else { var hrefDestination = destination if destination.hasPrefix("/") { hrefDestination = "\(baseURL.ensureTrailingSlash())\(destination.dropFirst())" } attributes.append( .init( key: "href", value: hrefDestination ) ) } if !destination.hasPrefix("."), !destination.hasPrefix("/"), !destination.hasPrefix("#") { attributes.append( .init( key: "target", value: "_blank" ) ) } } return HTML( name: "a", attributes: attributes, contents: visit(link.children) ) .render() } mutating func visitImage(_ image: Image) -> Result { guard let source = image.source, !source.isEmpty else { return "" } let imagePath = source.resolveAsset( baseURL: baseURL, assetsPath: assetsPath, slug: slug ) var attributes: [HTML.Attribute] = [ .init(key: "src", value: imagePath), .init(key: "alt", value: image.plainText), ] if let title = image.title { attributes.append( .init(key: "title", value: title) ) } return HTML( name: "img", type: .short, attributes: attributes ) .render() } // MARK: - table mutating func visitTable( _ table: Table ) -> Result { HTML(name: "table", contents: visit(table.children)).render() } mutating func visitTableHead( _ tableHead: Table.Head ) -> Result { HTML(name: "thead", contents: visit(tableHead.children)).render() } mutating func visitTableBody( _ tableBody: Table.Body ) -> Result { HTML(name: "tbody", contents: visit(tableBody.children)).render() } mutating func visitTableRow( _ tableRow: Table.Row ) -> Result { HTML(name: "tr", contents: visit(tableRow.children)).render() } mutating func visitTableCell( _ tableCell: Table.Cell ) -> Result { HTML(name: "td", contents: visit(tableCell.children)).render() } // MARK: - custom block directives 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 ) } let block = customBlockDirectives.first { $0.name.lowercased() == blockName.lowercased() } guard let block else { logger.warning( "Unrecognized block directive: `\(blockName)`", metadata: [ "name": .string(blockName) ] ) return "" } guard parseErrors.isEmpty else { let errors = parseErrors.map { error -> String in switch error { case let .duplicateArgument(name, _, _): return "Duplicate argument: `\(name)`." case let .missingExpectedCharacter(char, _): return "Misisng expected character: `\(char)`." case let .unexpectedCharacter(char, _): return "Unexpected character: `\(char)`." } } .joined(separator: ", ") logger.warning( "\(errors)", metadata: [ "name": .string(blockName) ] ) return "" } var parameters: [String: String] = [:] for p in block.parameters ?? [] { if p.required ?? false { if let v = arguments.getFirstValueBy(key: p.label) { parameters[p.label] = v } else { logger.warning( "Parameter `\(p.label)` for `\(block.name)` is required.", metadata: [ "name": .string(blockName) ] ) } } else { let v = arguments.getFirstValueBy(key: p.label) ?? p.default ?? "" parameters[p.label] = v } } let templateParams = parameters.mapKeys { "{{\($0)}}" } if let parent = block.requiresParentDirective, !parent.isEmpty { guard let p = blockDirective.parent as? BlockDirective, p.name.lowercased() == parent.lowercased() else { logger.warning( "Block directive `\(block.name)` requires parent block `\(parent)`", metadata: [ "name": .string(blockName) ] ) return "" } } if let output = block.output { var contents = "" for child in blockDirective.children { contents += visit(child) } var params = templateParams params["{{contents}}"] = contents return output.replacing(params) } if let name = block.tag { let attributes: [HTML.Attribute] = block.attributes? .map { a in .init( key: a.name, value: a.value.replacing(templateParams) ) } ?? [] return HTML( name: name, attributes: attributes, contents: visit(blockDirective.children) ) .render() } return "" } } ================================================ FILE: Sources/ToucanMarkdown/Markdown/MarkdownBlockDirective.swift ================================================ // // MarkdownBlockDirective.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 19.. // /// A representation of a custom block directive in Markdown, used for extending Markdown syntax with special tags or behaviors. public struct MarkdownBlockDirective: Codable, Equatable { /// Defines a configurable parameter for a directive, which may be required and have a default value. public struct Parameter: Codable, Equatable { /// The label of the parameter. public var label: String /// Indicates whether the parameter is required. Defaults to `nil` (optional). public var required: Bool? /// A default value for the parameter, used if it is not explicitly specified in the directive. public var `default`: String? /// Initializes a `Parameter` for a directive. /// /// - Parameters: /// - label: The name of the parameter. /// - isRequired: Indicates if the parameter must be provided. /// - defaultValue: A fallback value if none is provided. public init( label: String, isRequired: Bool? = nil, defaultValue: String? = nil ) { self.label = label self.required = isRequired self.default = defaultValue } } /// Represents a static HTML attribute that will be rendered on the directive's HTML tag. public struct Attribute: Codable, Equatable { /// The name of the HTML attribute (e.g., `class`, `id`). public var name: String /// The corresponding value of the attribute. public var value: String /// Initializes an `Attribute` for the rendered directive HTML tag. /// /// - Parameters: /// - name: The attribute key. /// - value: The attribute value. public init( name: String, value: String ) { self.name = name self.value = value } } /// The name of the directive (e.g., `"note"`, `"warning"`, `"info"`). public var name: String /// A list of supported parameters for the directive. public var parameters: [Parameter]? /// If specified, this directive must appear within another directive of the given name. public var requiresParentDirective: String? /// Indicates whether child paragraphs should be removed from the HTML output. Defaults to `nil`. public var removesChildParagraph: Bool? /// The HTML tag to render (e.g., `"div"`, `"section"`, `"aside"`). public var tag: String? /// Static attributes to apply to the rendered HTML tag. public var attributes: [Attribute]? /// Custom output HTML string that overrides default rendering behavior, if provided. public var output: String? /// Initializes a `MarkdownBlockDirective`. /// /// - Parameters: /// - name: The directive's name. /// - parameters: Optional list of accepted parameters. /// - requiresParentDirective: Name of a parent directive this one must reside within. /// - removesChildParagraph: Whether to exclude child `

` tags during rendering. /// - tag: HTML tag to be generated. /// - attributes: HTML attributes to apply. /// - output: Optional custom HTML output template. public init( name: String, parameters: [Parameter]? = nil, requiresParentDirective: String? = nil, removesChildParagraph: Bool? = nil, tag: String? = nil, attributes: [Attribute]? = nil, output: String? = nil ) { self.name = name self.parameters = parameters self.requiresParentDirective = requiresParentDirective self.removesChildParagraph = removesChildParagraph self.tag = tag self.attributes = attributes self.output = output } } ================================================ FILE: Sources/ToucanMarkdown/Markdown/MarkdownToHTMLRenderer.swift ================================================ // // MarkdownToHTMLRenderer.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 19.. // import Logging import Markdown import ToucanCore /// A renderer that converts Markdown text to HTML, with support for custom block directives and paragraph styling. public struct MarkdownToHTMLRenderer { /// Custom block directives to extend Markdown syntax. public let customBlockDirectives: [MarkdownBlockDirective] /// A collection of paragraph styles. public let paragraphStyles: [String: [String]] /// Logger instance public let logger: Logger /// Initializes a `MarkdownToHTMLRenderer`. /// /// - Parameters: /// - customBlockDirectives: A list of custom Markdown block directives to parse during rendering. /// - paragraphStyles: The paragraph styles configuration for styling rendered HTML. /// - logger: A logger instance for logging. Defaults to a logger labeled "MarkdownToHTMLRenderer". public init( customBlockDirectives: [MarkdownBlockDirective] = [], paragraphStyles: [String: [String]] = [:], logger: Logger = .subsystem("markdown-to-html-renderer") ) { self.customBlockDirectives = customBlockDirectives self.paragraphStyles = paragraphStyles self.logger = logger } // MARK: - render api /// Renders the provided Markdown string to an HTML string. /// /// - Parameters: /// - markdown: The input Markdown text to render. /// - slug: A slug identifier used for generating. /// - assetsPath: The path to the assets folder used for resource resolution. /// - baseURL: The base URL used to resolve relative links within the Markdown. /// /// - Returns: A fully rendered HTML string. public func renderHTML( markdown: String, slug: String, assetsPath: String, baseURL: String ) -> String { // Create a Markdown document, enabling block directives if any are provided. let document = Document( parsing: markdown, options: !customBlockDirectives.isEmpty ? [.parseBlockDirectives] : [] ) // Initialize the HTML visitor with the current configuration. var htmlVisitor = HTMLVisitor( blockDirectives: customBlockDirectives, paragraphStyles: paragraphStyles, slug: slug, assetsPath: assetsPath, baseURL: baseURL ) // Generate HTML by visiting the document tree. return htmlVisitor.visitDocument(document) } } ================================================ FILE: Sources/ToucanMarkdown/MarkdownRenderer.swift ================================================ // // MarkdownRenderer.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 20.. // import Logging import ToucanCore /// A comprehensive content processing engine that renders Markdown content to HTML, /// applies transformations, computes reading time, and generates an outline structure. public struct MarkdownRenderer { /// Holds all the settings required for rendering and processing content. public struct Configuration { /// Configuration specific to Markdown processing. public struct Markdown { /// Custom block directives to extend the Markdown grammar. public var customBlockDirectives: [MarkdownBlockDirective] // /// Initializes a Markdown configuration. public init( customBlockDirectives: [MarkdownBlockDirective] ) { self.customBlockDirectives = customBlockDirectives } } /// Configuration for outlining logic, such as which heading levels to parse. public struct Outline { /// Which heading levels to include in the parsed outline. public var levels: [Int] // /// Initializes an Outline configuration. public init( levels: [Int] ) { self.levels = levels } } /// Configuration for estimating reading time. public struct ReadingTime { /// Estimated words per minute reading speed. public var wordsPerMinute: Int // /// Initializes a ReadingTime configuration. public init( wordsPerMinute: Int ) { self.wordsPerMinute = wordsPerMinute } } /// Markdown-specific rendering options. public var markdown: Markdown /// Outline-parsing preferences. public var outline: Outline /// Reading time calculation preferences. public var readingTime: ReadingTime /// Optional transformation pipeline to apply pre-processing on the input. public var transformerPipeline: TransformerPipeline? /// Paragraph styles for customizing the HTML rendering. public var paragraphStyles: [String: [String]] /// Initializes a new rendering configuration. /// /// - Parameters: /// - markdown: Markdown rendering configuration. /// - outline: Outline extraction preferences. /// - readingTime: Reading time estimation settings. /// - transformerPipeline: Optional content transformation pipeline. /// - paragraphStyles: Block-level style customization for HTML rendering. public init( markdown: Markdown, outline: Outline, readingTime: ReadingTime, transformerPipeline: TransformerPipeline?, paragraphStyles: [String: [String]] ) { self.markdown = markdown self.outline = outline self.readingTime = readingTime self.transformerPipeline = transformerPipeline self.paragraphStyles = paragraphStyles } } /// Final output of the rendering pipeline. public struct Output { /// The fully rendered HTML output. public var html: String /// Estimated reading time in minutes. public var readingTime: Int /// A hierarchical structure representing the document's headings. public var outline: [Outline] } /// Configuration for rendering, including markdown styles, outline levels, and transformation settings. public var configuration: Configuration /// Responsible for converting Markdown into HTML with support for custom directives and styling. public var markdownToHTMLRenderer: MarkdownToHTMLRenderer /// Parses the rendered HTML to build a heading outline (used for TOC or navigation). public var outlineParser: OutlineParser /// Calculates the estimated reading time for a given HTML or Markdown document. public var readingTimeCalculator: ReadingTimeCalculator /// Logger for diagnostics and error reporting during rendering. public var logger: Logger /// Creates a new `ContentRenderer` instance with the provided configuration, file manager, and logger. /// /// - Parameters: /// - configuration: Rendering configuration including markdown, outline, and reading time options. /// - logger: Optional logger for tracking events and issues. public init( configuration: Configuration, logger: Logger = .subsystem("content-renderer") ) { self.configuration = configuration self.markdownToHTMLRenderer = MarkdownToHTMLRenderer( customBlockDirectives: configuration.markdown.customBlockDirectives, paragraphStyles: configuration.paragraphStyles ) self.outlineParser = OutlineParser( levels: configuration.outline.levels ) self.readingTimeCalculator = ReadingTimeCalculator( wordsPerMinute: configuration.readingTime.wordsPerMinute ) self.logger = logger } /// Processes the input Markdown content, optionally transforms it, renders it as HTML, /// calculates reading time, and generates an outline. /// /// - Parameters: /// - content: The raw Markdown content to process. /// - typeAwareID: A unique identifier used for transformation and rendering context. /// - slug: The slug of the content. /// - assetsPath: Path to associated assets (e.g., images or includes). /// - baseURL: The base URL for resolving relative paths or links. /// /// - Returns: A structured `Output` containing HTML, reading time, and outline. public func render( content: String, typeAwareID: String, slug: String, assetsPath: String, baseURL: String ) -> Output { var finalHtml = content var shouldRenderMarkdown = true // Step 1: Run transformer pipeline, if defined and non-empty. if let transformerPipeline = configuration.transformerPipeline { if !transformerPipeline.run.isEmpty { shouldRenderMarkdown = transformerPipeline.isMarkdownResult let executor = TransformerExecutor( pipeline: transformerPipeline ) do { finalHtml = try executor.transform( contents: finalHtml, id: typeAwareID, slug: slug ) } catch { logger.error("\(String(describing: error))") } } else { logger.warning("Empty transformer pipeline.") } } // Step 2: If the transformer output isn't already HTML, render Markdown to HTML. if shouldRenderMarkdown { finalHtml = markdownToHTMLRenderer.renderHTML( markdown: content, slug: slug, assetsPath: assetsPath, baseURL: baseURL ) } // Step 3: Calculate reading time and parse outline from HTML. let readingTime = readingTimeCalculator.calculate(for: finalHtml) let outline = outlineParser.parseHTML(finalHtml) return .init( html: finalHtml, readingTime: readingTime, outline: outline ) } } ================================================ FILE: Sources/ToucanMarkdown/Outline/Outline.swift ================================================ // // Outline.swift // Toucan // // Created by Tibor Bödecs on 2025. 04. 17.. // /// A hierarchical representation of an outline element, used for /// structuring headings or sections in a document or interface. public struct Outline: Equatable, Codable { /// The depth level of the outline node (e.g., 1 for top-level, 2 for a subheading, etc.). public var level: Int /// The display text of the outline entry, such as a heading title. public var text: String /// An optional fragment identifier that can be used for navigation (e.g., URL anchors). public var fragment: String? /// A list of child outlines, representing nested structure under this node. public var children: [Outline] /// Initializes a new `Outline` instance. /// /// - Parameters: /// - level: The heading level of the outline (e.g., 1 for `h1`, 2 for `h2`, etc.). /// - text: The display text for this outline item. /// - fragment: An optional anchor or link target associated with this item. /// - children: A list of nested `Outline` elements under this item. public init( level: Int, text: String, fragment: String? = nil, children: [Outline] = [] ) { self.level = level self.text = text self.fragment = fragment self.children = children } } ================================================ FILE: Sources/ToucanMarkdown/Outline/OutlineParser.swift ================================================ // // OutlineParser.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2024. 10. 14.. // import Logging import SwiftSoup import ToucanCore /// A parser that extracts heading elements (`

` to `

`) from HTML and converts them into a structured outline. public struct OutlineParser { /// The heading levels (e.g., `[1, 2, 3]` for `

`, `

`, and `

`) to include in the outline. public var levels: [Int] /// Logger instance public var logger: Logger /// Initializes an `OutlineParser` with optional levels and a logger. /// /// - Parameters: /// - levels: Heading levels to extract from the HTML. Must be between 1 and 6. Defaults to all (`[1, 2, 3, 4, 5, 6]`). /// - logger: A `Logger` instance for capturing logs. Defaults to a logger labeled "OutlineParser". public init( levels: [Int] = [1, 2, 3, 4, 5, 6], logger: Logger = .subsystem("outline-parser") ) { // Ensure levels are within the valid range of HTML headings. precondition( levels.allSatisfy { 1...6 ~= $0 }, "Values must be between 1 and 6." ) self.levels = levels self.logger = logger } /// Converts a single SwiftSoup element into an `Outline` if it corresponds to a valid heading. /// /// - Parameter element: A SwiftSoup `Element` representing a heading node. /// - Returns: An `Outline` instance if the element is a valid heading, otherwise `nil`. /// - Throws: An error if parsing the element fails. func createToC( from element: SwiftSoup.Element ) throws -> Outline? { let text = try element.text() let nodeName = element.nodeName() guard nodeName.count > 1, let rawLevel = nodeName.last, let level = Int(String(rawLevel)), (1...6).contains(level) else { return nil } var fragment: String? let id = try element.attr("id") if !id.isEmpty { fragment = id } return .init( level: level, text: text, fragment: fragment ) } /// Parses the given HTML string and returns a flat list of `Outline` items corresponding to the specified heading levels. /// /// - Parameter html: A string of HTML content. /// - Returns: An array of `Outline` instances representing the headings found. public func parseHTML( _ html: String ) -> [Outline] { do { // Parse HTML content into a SwiftSoup document. let document = try SwiftSoup.parse(html) // Build a CSS selector for the specified heading levels (e.g., "h1, h2, h3"). let tagSelector = levels.map { "h\($0)" }.joined(separator: ", ") // Select and process matching heading elements. let headings = try document.select(tagSelector) return try headings.compactMap { try createToC(from: $0) } } catch let Exception.Error(type, message) { logger.error("\(type) - \(message)") return [] } catch { logger.error("\(error.localizedDescription)") return [] } } } ================================================ FILE: Sources/ToucanMarkdown/ReadingTime/ReadingTimeCalculator.swift ================================================ // // ReadingTimeCalculator.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2024. 10. 15.. // import Logging import ToucanCore /// A utility to estimate the reading time of a given string of text based on words per minute. public struct ReadingTimeCalculator { /// The number of words assumed to be read per minute. public var wordsPerMinute: Int /// Logger instance public var logger: Logger /// Initializes a new instance of `ReadingTimeCalculator`. /// /// - Parameters: /// - wordsPerMinute: The number of words a person can read per minute. Defaults to 238. /// - logger: A `Logger` instance for logging internal operations. Defaults to a logger labeled "ReadingTimeCalculator". public init( wordsPerMinute: Int = 238, logger: Logger = .subsystem("reading-time-calculator") ) { self.wordsPerMinute = wordsPerMinute self.logger = logger } /// Calculates the estimated reading time for a given string. /// /// - Parameter string: The input text to estimate reading time for. /// - Returns: An estimated reading time in minutes. Returns at least 1 minute. public func calculate( for string: String ) -> Int { max(string.split(separator: " ").count / wordsPerMinute, 1) } } ================================================ FILE: Sources/ToucanMarkdown/Transformers/ContentTransformer.swift ================================================ // // ContentTransformer.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // /// Represents a content transformer command used in a transformation pipeline. public struct ContentTransformer { /// The directory path where the executable is located. /// Defaults to `"/usr/local/bin"` if not explicitly specified. public var path: String /// The name of the executable or script to run. public var name: String /// Initializes a new `ContentTransformer` with an optional path and required name. /// /// - Parameters: /// - path: The directory path to the executable. Defaults to `"/usr/local/bin"`. /// - name: The name of the command-line executable or script. public init( path: String = "/usr/local/bin", name: String ) { self.path = path self.name = name } } ================================================ FILE: Sources/ToucanMarkdown/Transformers/TransformerExecutor.swift ================================================ // // TransformerExecutor.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2024. 10. 15.. // import Foundation import Logging import SwiftCommand import ToucanCore /// Executes a sequence of shell-based transformation commands defined in a `TransformerPipeline`, /// allowing content to be programmatically modified. public struct TransformerExecutor { /// The transformation pipeline consisting of commands to execute. public var pipeline: TransformerPipeline /// File manager utility for file system interactions, including temp files and cleanup. public var fileManager: FileManager /// Logger instance. public var logger: Logger /// Initializes a `TransformerExecutor` with a transformation pipeline and file manager. /// /// - Parameters: /// - pipeline: A sequence of external commands to run for transformation. /// - fileManager: A file manager abstraction for working with files. /// - logger: A logger for capturing stdout, stderr, and errors. public init( pipeline: TransformerPipeline, fileManager: FileManager = .default, logger: Logger = .subsystem("transformer-executor") ) { self.pipeline = pipeline self.fileManager = fileManager self.logger = logger } /// Transforms the given content string using the defined pipeline. /// /// This function: /// - Saves the content to a temporary file. /// - Executes each command in the pipeline sequentially, modifying the file in place. /// - Captures and logs output and errors. /// - Returns the final transformed content. /// /// - Parameters: /// - contents: The raw content to be transformed. /// - id: An identifier used to pass context to the commands. /// - slug: The slug of the content. /// /// - Throws: Rethrows any error encountered during reading, writing, or transformation. /// - Returns: The final transformed content string. public func transform( contents: String, id: String, slug: String ) throws -> String { // Step 1: Write the content to a temp file let tempDirectoryURL = fileManager.temporaryDirectory let fileName = UUID().uuidString let fileURL = tempDirectoryURL.appendingPathComponent(fileName) try contents.write(to: fileURL, atomically: true, encoding: .utf8) // Step 2: Run each command in the transformation pipeline for command in pipeline.run { do { let arguments: [String] = [ "--id", id, "--file", fileURL.path, "--slug", slug, ] let commandURL = URL(fileURLWithPath: command.path) .appendingPathComponent(command.name) let command = Command(executablePath: .init(commandURL.path())) .addArguments(arguments) let result = try command.waitForOutput() // Log output and errors if !result.stdout.isEmpty { logger.debug("\(result)") } if let err = result.stderr, !err.isEmpty { logger.error("\(err)") } } catch { logger.error("\(error))") } } // Step 3: Read the transformed contents, clean up, and return do { let finalContents = try String( contentsOf: fileURL, encoding: .utf8 ) try fileManager.removeItem(at: fileURL) return finalContents } catch { // Ensure cleanup is still performed try fileManager.removeItem(at: fileURL) throw error } } } ================================================ FILE: Sources/ToucanMarkdown/Transformers/TransformerPipeline.swift ================================================ // // TransformerPipeline.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // /// Represents a sequence of content transformers to run before rendering, /// along with an indicator of whether the final result is Markdown. public struct TransformerPipeline { /// An ordered list of transformers (external commands or scripts) to execute. /// /// Each `ContentTransformer` represents an individual transformation step. public var run: [ContentTransformer] /// Indicates whether the final output from this pipeline is expected to be Markdown. /// /// If `false`, the renderer may treat the output as already-formatted HTML or another format. public var isMarkdownResult: Bool /// Initializes a new `TransformerPipeline`. /// /// - Parameters: /// - run: An array of `ContentTransformer` instances to execute. /// - isMarkdownResult: A flag indicating whether the final output is Markdown. Defaults to `true`. public init( run: [ContentTransformer] = [], isMarkdownResult: Bool = true ) { self.run = run self.isMarkdownResult = isMarkdownResult } } ================================================ FILE: Sources/ToucanSDK/Behaviors/Behavior.swift ================================================ // // Behavior.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 16.. // import struct Foundation.URL /// A protocol that defines a behavior with a unique identifier /// and an operation that runs on a given file URL. protocol Behavior { /// A unique identifier for the behavior. static var id: String { get } /// Executes the behavior with the given file URL. /// /// - Parameter fileURL: The URL of the file to process. /// - Returns: A `String` result of the behavior. /// - Throws: An error if the behavior fails. func run(fileURL: URL) throws -> String } ================================================ FILE: Sources/ToucanSDK/Behaviors/CompileSASSBehavior.swift ================================================ // // CompileSASSBehavior.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 12.. // import DartSass import Foundation struct CompileSASSBehavior: Behavior { static let id = "compile-sass" var compiler: Compiler init() throws { self.compiler = try .init() } /// NOTE: This is horrible... but we can live with it for a while :) private func unsafeSyncCompile(fileURL: URL) -> String { final class Enclosure: @unchecked Sendable { var value: CompilerResults! } let semaphore = DispatchSemaphore(value: 0) let enclosure = Enclosure() Task { do { enclosure.value = try await compiler.compile( fileURL: fileURL ) } catch { fatalError("\(error) - \(fileURL.path())") } semaphore.signal() } semaphore.wait() return enclosure.value.css } func run(fileURL: URL) throws -> String { let css = unsafeSyncCompile(fileURL: fileURL) return css } } ================================================ FILE: Sources/ToucanSDK/Behaviors/MinifyCSSBehavior.swift ================================================ // // MinifyCSSBehavior.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 12.. // import Foundation import SwiftCSSParser struct MinifyCSSBehavior: Behavior { static let id = "minify-css" func run(fileURL: URL) throws -> String { let src = try String( contentsOf: fileURL, encoding: .utf8 ) let stylesheet = try Stylesheet.parse(from: src) return stylesheet.minified() } } ================================================ FILE: Sources/ToucanSDK/Content/Content+Query.swift ================================================ // // Content+Query.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 31.. // import Foundation import ToucanSource import Logging public extension Content { /// Flattens the content's core properties, relations, and metadata into a single dictionary /// for use in filtering, querying, or templating contexts. /// /// - Includes: /// - All `properties` as defined in the content type /// - Resolved `relations`, where: /// - `.one` types return a single identifier (or an empty array if unresolved) /// - `.many` types return an array of identifiers /// - Additional metadata: /// - `"id"`: The content's unique identifier /// - `"slug"`: The slug string used for URLs /// - `"lastUpdate"`: Last modification timestamp of the content /// - `"iterator"`: Boolean flag indicating if this content is an iterator item /// /// - Returns: A `[String: AnyCodable]` dictionary representing queryable fields. var queryFields: [String: AnyCodable] { var fields = properties // Flatten relational fields by type for (key, relation) in relations { switch relation.type { case .one: if relation.identifiers.isEmpty { // Default to empty array if no target fields[key] = .init([]) } else { fields[key] = .init(relation.identifiers[0]) // Single ID } case .many: fields[key] = .init(relation.identifiers) // Array of IDs } } // Append metadata fields fields[SystemPropertyKeys.id.rawValue] = .init(typeAwareID) fields[SystemPropertyKeys.lastUpdate.rawValue] = .init( rawValue.lastModificationDate ) fields[SystemPropertyKeys.slug.rawValue] = .init(slug.value) fields[RootContextKeys.iterator.rawValue] = .init(isIterator) return fields } } public extension [Content] { /// Executes a `Query` against the current content collection, applying filtering, /// sorting, and pagination. /// /// - Parameters: /// - query: The `Query` object containing filtering, ordering, and limit logic. /// - now: The current timestamp used for time-based filtering. /// - logger: A `Logger` instance for capturing logs. /// - Returns: A filtered, sorted, and paginated array of `Content` items. func run( query: Query, now: TimeInterval, logger: Logger ) -> [Content] { let contents = filter { query.contentType == $0.type.id } return filter( contents: contents, using: query.resolveFilterParameters( with: [ "date.now": .init(now) ] ), logger: logger ) } /// Filters, sorts, and slices the given content array based on a query. private func filter( contents: [Content], using query: Query, logger: Logger ) -> [Content] { var filteredContents = contents.filter { element in evaluate(condition: query.filter, with: element.queryFields) } for order in query.orderBy.reversed() { filteredContents.sort { a, b in let propertyForOrderKey: (Content) -> AnyCodable? = { item in guard let value = item.properties[order.key] else { logger.warning( "Missing order property key: `\(order.key)`.", metadata: [ "slug": .string(item.slug.value), "contentType": .string(query.contentType), ] ) return nil } return value } guard let valueA = propertyForOrderKey(a), let valueB = propertyForOrderKey(b) else { return false } return compare( valueA, valueB, ascending: order.direction == .asc ) } } if let offset = query.offset { filteredContents = Array(filteredContents.dropFirst(offset)) } if let limit = query.limit { filteredContents = Array(filteredContents.prefix(limit)) } return filteredContents } /// Recursively evaluates a `Condition` tree against a set of content fields. private func evaluate( condition: Condition?, with props: [String: AnyCodable] ) -> Bool { guard let condition else { return true } switch condition { case let .field(key, `operator`, value): guard let fieldValue = props[key] else { return false } return evaluateField( fieldValue: fieldValue, operator: `operator`, value: value ) case let .and(conditions): return conditions.allSatisfy { evaluate(condition: $0, with: props) } case let .or(conditions): return conditions.contains { evaluate(condition: $0, with: props) } } } /// Compares two values for equality, supporting multiple types. private func equals(_ valueA: AnyCodable, _ valueB: AnyCodable) -> Bool { if let a = valueA.value(as: Bool.self), let b = valueB.value(as: Bool.self) { return a == b } if let a = valueA.value(as: Int.self), let b = valueB.value(as: Int.self) { return a == b } if let a = valueA.value(as: Double.self), let b = valueB.value(as: Double.self) { return a == b } if let a = valueA.value(as: String.self), let b = valueB.value(as: String.self) { return a == b } return false } /// Performs numeric or string comparison between two values, with optional inclusiveness. private func compare( _ valueA: AnyCodable, _ valueB: AnyCodable, ascending: Bool, isInclusive: Bool = false ) -> Bool { if let a = valueA.value(as: Int.self), let b = valueB.value(as: Int.self) { return isInclusive ? (ascending ? a <= b : a >= b) : (ascending ? a < b : a > b) } if let a = valueA.value(as: Double.self), let b = valueB.value(as: Double.self) { return isInclusive ? (ascending ? a <= b : a >= b) : (ascending ? a < b : a > b) } if let a = valueA.value(as: String.self), let b = valueB.value(as: String.self) { return isInclusive ? (ascending ? a <= b : a >= b) : (ascending ? a < b : a > b) } return false } /// Evaluates a field condition against a value using the provided operator. private func evaluateField( fieldValue: AnyCodable, operator: Operator, value: AnyCodable ) -> Bool { switch `operator` { case .equals: return equals(fieldValue, value) case .notEquals: return !equals(fieldValue, value) case .lessThan: return compare(fieldValue, value, ascending: true) case .greaterThan: return compare(fieldValue, value, ascending: false) case .lessThanOrEquals: return compare( fieldValue, value, ascending: true, isInclusive: true ) case .greaterThanOrEquals: return compare( fieldValue, value, ascending: false, isInclusive: true ) case .like: return fieldValue.value(as: String.self)? .contains(value.value(as: String.self) ?? "") ?? false case .caseInsensitiveLike: return fieldValue.value(as: String.self)? .lowercased() .contains(value.value(as: String.self)?.lowercased() ?? "") ?? false case .in: if let v = fieldValue.value(as: Int.self), let arr = value.value(as: [Int].self) { return arr.contains(v) } if let v = fieldValue.value(as: Double.self), let arr = value.value(as: [Double].self) { return arr.contains(v) } if let v = fieldValue.value(as: String.self), let arr = value.value(as: [String].self) { return arr.contains(v) } return false case .contains: if let arr = fieldValue.value(as: [Int].self), let v = value.value(as: Int.self) { return arr.contains(v) } if let arr = fieldValue.value(as: [Double].self), let v = value.value(as: Double.self) { return arr.contains(v) } if let arr = fieldValue.value(as: [String].self), let v = value.value(as: String.self) { return arr.contains(v) } return false case .matching: if let arr = fieldValue.value(as: [Int].self), let other = value.value(as: [Int].self) { return !Set(arr).intersection(other).isEmpty } if let arr = fieldValue.value(as: [Double].self), let other = value.value(as: [Double].self) { return !Set(arr).intersection(other).isEmpty } if let arr = fieldValue.value(as: [String].self), let other = value.value(as: [String].self) { return !Set(arr).intersection(other).isEmpty } return false } } } ================================================ FILE: Sources/ToucanSDK/Content/Content.swift ================================================ // // Content.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 15.. // import ToucanSource /// Represents a unit of structured content, with associated metadata, relationships, and rendering information. public struct Content { /// The content type definition that describes structure and expected fields. public var type: ContentType /// A globally unique string identifier for this content item. /// This value remains constant across contexts and is used for persistence or lookup. public var typeAwareID: String /// A URL-friendly slug that identifies the content in paths or links. public var slug: Slug /// The raw content representation, usually Markdown or HTML source. public var rawValue: RawContent /// A dictionary of properties that hold the parsed field values (e.g., title, date, body). /// Keys are field names as defined in the `ContentType`, and values are dynamically typed. public var properties: [String: AnyCodable] /// A dictionary of relations to other content items, keyed by relation name. /// The relation values may include identifiers or full references depending on usage. public var relations: [String: RelationValue] /// Arbitrary user-defined metadata not explicitly declared in the content definition. /// These are typically useful for extensibility or plugin features. public var userDefined: [String: AnyCodable] /// Optional iterator metadata if the content is generated through iteration (e.g., paginated or list item). public var iteratorInfo: IteratorInfo? /// A computed flag indicating whether this content instance was generated via iteration. public var isIterator: Bool { iteratorInfo != nil } /// Initializes a new `Content` instance. /// /// - Parameters: /// - type: Structural schema for this content. /// - typeAwareID: A unique identifier. /// - slug: A human-readable URL slug. /// - rawValue: The unparsed content. /// - properties: Parsed content fields. /// - relations: Links to other content. /// - userDefined: Freeform or plugin-provided metadata. /// - iteratorInfo: Optional info for repeated or generated content. public init( type: ContentType, typeAwareID: String, slug: Slug, rawValue: RawContent, properties: [String: AnyCodable], relations: [String: RelationValue], userDefined: [String: AnyCodable], iteratorInfo: IteratorInfo? ) { self.type = type self.typeAwareID = typeAwareID self.slug = slug self.rawValue = rawValue self.properties = properties self.relations = relations self.userDefined = userDefined self.iteratorInfo = iteratorInfo } } ================================================ FILE: Sources/ToucanSDK/Content/ContentResolver.swift ================================================ // // ContentResolver.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import Foundation import Logging import ToucanCore import ToucanSerialization import ToucanSource private extension Path { func getTypeAwareIdentifier() -> String { let newRawPath = value .split(separator: "/") .last .map(String.init) ?? "" return Path(newRawPath).trimmingBracketsContent() } } enum ContentResolverError: ToucanError { case contentType(ContentTypeResolverError) case missingProperty(String, String) case missingRelation(String, String) case invalidProperty(String, String, String) case invalidSlug(String) case unknown(Error) var underlyingErrors: [any Error] { switch self { case let .contentType(error): [error] case .missingProperty: [] case .missingRelation: [] case .invalidProperty: [] case .invalidSlug: [] case let .unknown(error): [error] } } var logMessage: String { switch self { case .contentType(_): "Content type related error." case let .missingProperty(name, slug): "Missing property `\(name)` for content: \(slug)." case let .missingRelation(name, slug): "Missing property `\(name)` for content: \(slug)." case let .invalidProperty(name, value, slug): "Invalid property `\(name): \(value)` for content: \(slug)." case let .invalidSlug(slug): "Invalid slug for content: \(slug)." case let .unknown(error): error.localizedDescription } } var userFriendlyMessage: String { switch self { case .contentType(_): "Content type related error." case let .missingProperty(name, slug): "Missing property `\(name)` for content: `\(slug)`." case let .missingRelation(name, slug): "Missing property `\(name)` for content: `\(slug)`." case let .invalidProperty(name, value, slug): "Invalid property `\(name): \(value)` for content: \(slug)." case let .invalidSlug(slug): "Invalid slug for content: \(slug)." case .unknown: "Unknown content conversion error." } } } struct ContentResolver { var contentTypeResolver: ContentTypeResolver var encoder: ToucanEncoder var decoder: ToucanDecoder var dateFormatter: ToucanInputDateFormatter var logger: Logger init( contentTypeResolver: ContentTypeResolver, encoder: ToucanEncoder, decoder: ToucanDecoder, dateFormatter: ToucanInputDateFormatter, logger: Logger = .subsystem("content-resolver") ) { self.contentTypeResolver = contentTypeResolver self.encoder = encoder self.decoder = decoder self.dateFormatter = dateFormatter self.logger = logger } private func rewrite( iteratorID: String, pageIndex: Int, _ value: inout String ) { value = value.replacing([ "{{\(iteratorID)}}": String(pageIndex) ]) } private func rewrite( number: Int, total: Int, _ array: inout [String: AnyCodable] ) { for (key, _) in array { if let stringValue = array[key]?.stringValue() { array[key] = .init( replace( in: stringValue, number: number, total: total ) ) } } } private func replace( in value: String, number: Int, total: Int ) -> String { value.replacing([ "{{number}}": String(number), "{{total}}": String(total), ]) } private func createDictionaryValues( assetKeys: [String], array: [String] ) -> [String: AnyCodable] { var values: [String: AnyCodable] = [:] for i in 0.. [String] { paths.filter { filePath in guard let url = URL(string: filePath) else { return false } let path = url.deletingLastPathComponent().path let name = url.deletingPathExtension().lastPathComponent let ext = url.pathExtension let inputPath = input.path ?? "" let pathMatches = inputPath == "*" || inputPath.isEmpty || path == inputPath let nameMatches = input.name == "*" || input.name.isEmpty || name == input.name let extMatches = input.ext == "*" || input.ext.isEmpty || ext == input.ext return pathMatches && nameMatches && extMatches } } // MARK: - asset behaviors private func getNameAndExtension( from path: String ) -> (name: String, ext: String) { let safePath = path.split(separator: "/").last.map(String.init) ?? "" let parts = safePath.split( separator: ".", omittingEmptySubsequences: false ) guard parts.count >= 2 else { return (String(safePath), "") // No extension } let ext = String(parts.last!) let filename = parts.dropLast().joined(separator: ".") return (filename, ext) } // MARK: - conversion func convert( rawContents: [RawContent] ) throws(ContentResolverError) -> [Content] { do { return try rawContents.map { try convert(rawContent: $0) } } catch let error as ContentResolverError { throw error } catch { throw .unknown(error) } } // MARK: - error helper func getContentType( for origin: Origin, using id: String? ) throws(ContentResolverError) -> ContentType { do { return try contentTypeResolver.getContentType( for: origin, using: id ) } catch { throw .contentType(error) } } // MARK: - conversion helpers func convert( property: Property, rawValue: AnyCodable?, forKey key: String, slug: String ) throws(ContentResolverError) -> AnyCodable? { let value = rawValue ?? property.defaultValue switch property.type { case let .date(config): guard let rawDateValue = value?.value(as: String.self) else { throw .invalidProperty( key, value?.stringValue() ?? "nil", slug ) } guard let date = dateFormatter.date( from: rawDateValue, using: config ) else { throw .invalidProperty( key, value?.stringValue() ?? "nil", slug ) } return .init(date.timeIntervalSince1970) default: return value } } func convert( rawContent: RawContent ) throws(ContentResolverError) -> Content { let typeID = rawContent.markdown.frontMatter.string( SystemPropertyKeys.type.rawValue ) let contentType = try getContentType( for: rawContent.origin, using: typeID ) var properties: [String: AnyCodable] = [:] // validate properties let frontMatter = rawContent.markdown.frontMatter let missingProperties = contentType.properties .filter { name, property in let isRequiredButMissing = property.required && frontMatter[name] == nil let hasNoDefaultValue = property.defaultValue?.value == nil let isNotSystemProperty = !SystemPropertyKeys.allCases .map { $0.rawValue } .contains(name) return isRequiredButMissing && hasNoDefaultValue && isNotSystemProperty } for name in missingProperties.keys { throw .missingProperty(name, rawContent.origin.slug) } /// validate relations let missingRelations = contentType.relations.keys.filter { frontMatter[$0] == nil } for name in missingRelations { throw .missingRelation(name, rawContent.origin.slug) } // Extrant `id` from front matter or path or fallback to origin path var typeAwareID = rawContent.origin.path.getTypeAwareIdentifier() if let id = rawContent.markdown.frontMatter.string( SystemPropertyKeys.id.rawValue ) { typeAwareID = id } // Extract `slug` from front matter or fallback to origin slug var slug: String = rawContent.origin.slug if let rawSlug = rawContent.markdown.frontMatter.string( SystemPropertyKeys.slug.rawValue, allowingEmptyValue: true ) { guard rawSlug.containsOnlyValidURLCharacters() else { throw .invalidSlug(rawSlug) } slug = rawSlug // .slugify() } // Convert schema-defined properties for (key, property) in contentType.properties.sorted(by: { $0.key < $1.key }) { var rawValue: AnyCodable? switch key { case SystemPropertyKeys.id.rawValue: rawValue = .init(typeAwareID) case SystemPropertyKeys.lastUpdate.rawValue: rawValue = .init(rawContent.lastModificationDate) case SystemPropertyKeys.slug.rawValue: rawValue = .init(slug) case SystemPropertyKeys.type.rawValue: rawValue = .init(typeID) default: rawValue = rawContent.markdown.frontMatter[key] } properties[key] = try convert( property: property, rawValue: rawValue, forKey: key, slug: rawContent.origin.slug ) } // Convert schema-defined relations var relations: [String: RelationValue] = [:] for (key, relation) in contentType.relations.sorted(by: { $0.key < $1.key }) { let rawValue = rawContent.markdown.frontMatter[key] var identifiers: [String] = [] switch relation.type { case .one: if let id = rawValue?.value as? String { identifiers.append(id) } case .many: if let ids = rawValue?.value as? [String] { identifiers.append(contentsOf: ids) } } relations[key] = .init( contentType: relation.references, type: relation.type, identifiers: identifiers ) } // Filter out reserved keys and schema-mapped fields to extract user-defined fields let keysToRemove = SystemPropertyKeys.allCases .map { $0.rawValue } + contentType.properties.keys + contentType.relations.keys var userDefined = rawContent.markdown.frontMatter for key in keysToRemove { userDefined.removeValue(forKey: key) } logger.trace( "Converting content", metadata: [ "type": .string(contentType.id), "typeAwareID": .string(typeAwareID), "slug": .string(slug), "origin": .dictionary( [ "path": .string(rawContent.origin.path.value), "slug": .string(rawContent.origin.slug), ] ), ] ) return .init( type: contentType, typeAwareID: typeAwareID, slug: .init(slug), rawValue: rawContent, properties: properties, relations: relations, userDefined: userDefined, iteratorInfo: nil ) } // MARK: - filter /// Applies the filtering rules to the provided content items. /// /// - Parameters: /// - filterRules: A dictionary mapping content type identifiers to filtering conditions. /// - contents: The list of `Content` items to filter. /// - now: The current timestamp used for time-based filtering. /// - Returns: A new list containing only the filtered content items. func apply( filterRules: [String: Condition], to contents: [Content], now: TimeInterval ) -> [Content] { let groups = Dictionary(grouping: contents, by: { $0.type.id }) var result: [Content] = [] for (id, contents) in groups { if let condition = filterRules[id] ?? filterRules["*"] { let items = contents.run( query: .init( contentType: id, filter: condition ), now: now, logger: logger ) result.append(contentsOf: items) } else { result.append(contentsOf: contents) } } return result } // MARK: - iterators func apply( iterators: [String: Query], to contents: [Content], baseURL: String, now: TimeInterval ) -> [Content] { var finalContents: [Content] = [] for content in contents { if let iteratorID = content.slug.extractIteratorID() { guard let query = iterators[iteratorID] else { continue } let countQuery = Query( contentType: query.contentType, scope: query.scope, limit: nil, offset: nil, filter: query.filter, orderBy: query.orderBy ) let total = contents.run(query: countQuery, now: now, logger: logger) .count let limit = max(1, query.limit ?? 10) let numberOfPages = (total + limit - 1) / limit for i in 0.. [Content] { var results: [Content] = [] for content in contents { var item: Content = content for property in assetProperties { let path = item.rawValue.origin.path let url = contentsURL.appendingPathComponent(path.value) let assetsURL = url.appending(path: assetsPath) let filteredAssets = filterFilePaths( from: content.rawValue.assets, input: property.input ) guard !filteredAssets.isEmpty else { continue } let assetKeys = filteredAssets.compactMap { $0.split(separator: ".").first } .map(String.init) let resolvedAssets = filteredAssets.map { "./\(assetsPath)/\($0)" .resolveAsset( baseURL: baseURL, assetsPath: assetsPath, slug: content.slug.value ) } let frontMatter = item.rawValue.markdown.frontMatter let finalAssets = property.resolvePath ? resolvedAssets : filteredAssets switch property.action { case .add: if let originalItems = frontMatter[property.property]? .arrayValue(as: String.self) { item.properties[property.property] = .init( originalItems + finalAssets ) } else { item.properties[property.property] = .init(finalAssets) } case .set: if finalAssets.count == 1 { let asset = finalAssets[0] item.properties[property.property] = .init(asset) } else { item.properties[property.property] = .init( createDictionaryValues( assetKeys: assetKeys, array: finalAssets ) ) } case .load: if filteredAssets.count == 1 { let asset = filteredAssets[0] let url = assetsURL.appending(path: asset) let contents = try String( contentsOf: url, encoding: .utf8 ) item.properties[property.property] = .init(contents) } else { var values: [String: AnyCodable] = [:] for i in 0.. [PipelineResult] { var results: [PipelineResult] = [] for content in contents { var assetsReady: Set = .init() for behavior in pipeline.assets.behaviors { let isAllowed = pipeline.contentTypes.isAllowed( contentType: content.type.id ) guard isAllowed else { continue } let remainingAssets = Set(content.rawValue.assets) .subtracting(assetsReady) let matchingRemainingAssets = filterFilePaths( from: Array(remainingAssets), input: behavior.input ) guard !matchingRemainingAssets.isEmpty else { continue } for inputAsset in matchingRemainingAssets { let basePath = content.rawValue.origin.path let sourcePath = [ basePath.value, assetsPath, inputAsset, ] .joined(separator: "/") let file = getNameAndExtension(from: inputAsset) let destPath = [ assetsPath, content.slug.value, inputAsset, ] .filter { !$0.isEmpty } .joined(separator: "/") .split(separator: "/") .dropLast() .joined(separator: "/") logger.trace( "Resolving matching asset behavior.", metadata: [ "behavior": .string(behavior.id), "source": .string(sourcePath), "destination": .string(destPath), ] ) let fileURL = contentsURL.appending(path: sourcePath) switch behavior.id { case CompileSASSBehavior.id: let script = try CompileSASSBehavior() let css = try script.run(fileURL: fileURL) // TODO: proper output management later on results.append( .init( source: .asset(css), destination: .init( path: destPath, file: behavior.output.name, ext: behavior.output.ext ) ) ) case MinifyCSSBehavior.id: let script = MinifyCSSBehavior() let css = try script.run(fileURL: fileURL) results.append( .init( source: .asset(css), destination: .init( path: destPath, file: behavior.output.name, ext: behavior.output.ext ) ) ) default: // copy results.append( .init( source: .assetFile(sourcePath), destination: .init( path: destPath, file: file.name, ext: file.ext ) ) ) } assetsReady.insert(inputAsset) } } } return results } } ================================================ FILE: Sources/ToucanSDK/Content/ContentTypeResolver.swift ================================================ // // ContentTypeResolver.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 30.. // import ToucanCore import ToucanSource enum ContentTypeResolverError: ToucanError { case missingContentType(String, String) case unknown(Error) var underlyingErrors: [any Error] { switch self { case .missingContentType: [] case let .unknown(error): [error] } } var logMessage: String { switch self { case let .missingContentType(id, path): "Missing content type for identifier: `\(id)` at `\(path)`." case let .unknown(error): error.localizedDescription } } var userFriendlyMessage: String { switch self { case .missingContentType: "Missing content type." case .unknown: "Unknown content conversion error." } } } struct ContentTypeResolver { let contentTypes: [ContentType] init( types: [ContentType], pipelines: [Pipeline] ) { let virtualTypes = pipelines.compactMap { $0.definesType ? ContentType(id: $0.id) : nil } self.contentTypes = (types + virtualTypes).sorted { $0.id < $1.id } } func getContentType( for origin: Origin, using id: String? ) throws(ContentTypeResolverError) -> ContentType { if let id { guard let result = contentTypes.first(where: { $0.id == id }) else { throw .missingContentType(id, origin.path.value) } return result } if let type = contentTypes.first( where: { type in type.paths.contains { origin.path.value.hasPrefix($0) } } ) { return type } let results = contentTypes.filter(\.default) precondition( !results.isEmpty, "Don't forget to validate build target first." ) return results[0] } } ================================================ FILE: Sources/ToucanSDK/Content/IteratorInfo.swift ================================================ // // IteratorInfo.swift // Toucan // // Created by gerp83 on 2025. 04. 17.. // /// Provides pagination and iteration metadata for a content collection, /// used when rendering paginated list views. public struct IteratorInfo { /// Represents a navigation link within a paginated content sequence. public struct Link: Codable { /// The page number this link points to. public var number: Int /// The permalink URL for this page. public var permalink: String /// Whether this link refers to the currently active page. public var isCurrent: Bool /// Initializes a new pagination link. /// /// - Parameters: /// - number: The page number. /// - permalink: The URL for that page. /// - isCurrent: Whether this link is for the current page. public init( number: Int, permalink: String, isCurrent: Bool ) { self.number = number self.permalink = permalink self.isCurrent = isCurrent } } /// The current page number (1-based). public var current: Int /// The total number of pages in the iterator. public var total: Int /// The number of items per page. public var limit: Int /// The subset of `Content` items that belong to the current page. public var items: [Content] /// A list of links to all available pages for UI navigation. public var links: [Link] /// An optional scope key used to identify the context or view this iterator belongs to. /// /// This can help differentiate between multiple iterators for the same content type /// (e.g., "allPosts", "featuredPosts"). public var scope: String? /// Initializes a new iterator metadata structure. /// /// - Parameters: /// - current: The current page number. /// - total: The total number of pages. /// - limit: Items per page. /// - items: The content items for the current page. /// - links: Pagination links to all pages. /// - scope: An optional scope identifier. public init( current: Int, total: Int, limit: Int, items: [Content], links: [Link], scope: String? ) { self.current = current self.total = total self.limit = limit self.items = items self.links = links self.scope = scope } } ================================================ FILE: Sources/ToucanSDK/Content/Query+Resolve.swift ================================================ // // Query+Resolve.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 16.. // import ToucanSource public extension Condition { /// Recursively resolves dynamic placeholders in the condition using a parameter map. /// /// Placeholders must be strings in the form `{{parameterKey}}` and will be /// replaced by values from the given parameters dictionary. /// /// - Parameter parameters: A dictionary of key-value pairs to substitute into the condition. /// - Returns: A new `Condition` with resolved values where applicable. func resolve(with parameters: [String: AnyCodable]) -> Self { switch self { case let .field(key, op, value): guard let stringValue = value.value(as: String.self), stringValue.count > 4, stringValue.hasPrefix("{{"), stringValue.hasSuffix("}}") else { return self } let paramKeyToUse = String(stringValue.dropFirst(2).dropLast(2)) guard let newValue = parameters[paramKeyToUse] else { return self } return .field(key: key, operator: op, value: newValue) case let .and(conditions): return .and(conditions.map { $0.resolve(with: parameters) }) case let .or(conditions): return .or(conditions.map { $0.resolve(with: parameters) }) } } } public extension Query { /// Resolves dynamic filter parameters by injecting values into the filter condition tree. /// /// This is useful when filters include placeholders that need to be resolved at runtime. /// /// - Parameter parameters: A dictionary of key-value pairs to replace placeholders in the filter. /// - Returns: A new `Query` instance with resolved filter conditions. func resolveFilterParameters( with parameters: [String: AnyCodable] ) -> Self { .init( contentType: contentType, scope: scope, limit: limit, offset: offset, filter: filter?.resolve(with: parameters), orderBy: orderBy ) } } ================================================ FILE: Sources/ToucanSDK/Content/RelationValue.swift ================================================ // // RelationValue.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 30.. // import ToucanSource /// Represents the resolved value of a relation in content, including the target content type, /// the relation's cardinality, and the identifiers of related items. public struct RelationValue { /// The type of content this relation points to (e.g., `"author"`, `"post"`, `"product"`). public var contentType: String /// The relation type indicating if it's a one-to-one or one-to-many relationship. public var type: RelationType /// A list of string identifiers for the related content items. /// For `.one`, this should typically contain a single ID; for `.many`, multiple. public var identifiers: [String] /// Initializes a new `RelationValue` representing the resolved target(s) of a content relation. /// /// - Parameters: /// - contentType: The name of the target content type. /// - type: The type of relation (single or multiple). /// - identifiers: A list of string IDs pointing to related content. public init( contentType: String, type: RelationType, identifiers: [String] ) { self.contentType = contentType self.type = type self.identifiers = identifiers } } ================================================ FILE: Sources/ToucanSDK/DateFormats/DateContext.swift ================================================ // // DateContext.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 12.. // /// A configuration container for date, time, and custom date-time formatting patterns. /// /// `DateFormats` includes predefined formatting levels (full, long, medium, short) /// for both dates and times, as well as support for arbitrary format labels. public struct DateContext: Codable { /// Represents standardized formatting levels for a date or time value. /// /// These levels mirror common locale-aware date style options. public struct Standard: Codable { /// A fully verbose date format (e.g., `"EEEE, MMMM d, yyyy"`). public var full: String /// A long-form date format (e.g., `"MMMM d, yyyy"`). public var long: String /// A medium-form date format (e.g., `"MMM d, yyyy"`). public var medium: String /// A short-form date format (e.g., `"M/d/yy"`). public var short: String /// Initializes a new `Standard` date format set. /// /// - Parameters: /// - full: Full verbose date format string. /// - long: Long format string. /// - medium: Medium format string. /// - short: Short format string. public init( full: String, long: String, medium: String, short: String ) { self.full = full self.long = long self.medium = medium self.short = short } } /// Standardized date format strings (e.g., full, medium, short). public var date: Standard /// Standardized time format strings (e.g., full, medium, short). public var time: Standard /// A standard iso8601 date string. public var iso8601: String /// A Unix timestamp representing a default or reference point in time. public var timestamp: Double /// Additional named date format strings keyed by label. /// /// These can be used for custom formatting beyond the standard levels. public var formats: [String: String] /// Initializes a `DateFormats` configuration. /// /// - Parameters: /// - date: Standardized date formatting options. /// - time: Standardized time formatting options. /// - timestamp: A base or reference timestamp, typically in Unix format. /// - iso8601: A standard iso8601 date string. /// - formats: Custom named format strings for specialized use cases. public init( date: Standard, time: Standard, timestamp: Double, iso8601: String, formats: [String: String] ) { self.date = date self.time = time self.timestamp = timestamp self.iso8601 = iso8601 self.formats = formats } } ================================================ FILE: Sources/ToucanSDK/DateFormats/ToucanDateFormatters.swift ================================================ // // ToucanDateFormatters.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 26.. // import Foundation import Logging import ToucanCore import ToucanSource /// ``` /// target: /// dev: /// input: ./src /// output: ./dist /// config: ./src/config.dev.yml => auto lookup like this? /// -> default looks up for config.yml /// /// live: /// config: ./src/config.live.yml /// /// config.dev.yml: /// url: http://localhost:3000/ /// /// # output date formats basis /// /// date: /// input: /// # input date formats basis /// locale: en-US /// timezone: Americas/Los_Angeles /// format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z' /// output: /// locale: en-US /// timezone: Americas/Los_Angeles /// formats: /// year: /// format: "y" /// locale: hu-HU /// timezone: Europe/Budapest /// /// pipeline -> overrides config completely /// date: /// input: /// locale: ??? /// timezone: ??? /// format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z' /// output: /// locale: en-US /// timezone: Americas/Los_Angeles /// formats: /// year: /// format: "y" /// locale: ??? /// timezone: ??? /// /// # content type /// post /// publication: /// type: date /// config: # input /// format: /// locale: /// timeZone: /// ``` /// Extension to configure `DateFormatter` with localization and config options. private extension DateFormatter { /// Creates and configures a `DateFormatter`. /// /// - Parameters: /// - localization: The locale and time zone settings to apply. /// - block: A closure to further configure the formatter. /// - Returns: A fully configured `DateFormatter`. static func build( using localization: DateLocalization = .defaults, _ block: (inout DateFormatter) -> Void ) -> DateFormatter { var formatter = DateFormatter() formatter.use(localization: localization) formatter.dateStyle = .none formatter.timeStyle = .none block(&formatter) return formatter } /// Applies the given localization (locale and time zone) to the formatter. /// /// - Parameter localization: The locale and time zone options. func use(localization: DateLocalization) { let id = Locale.identifier(.icu, from: localization.locale) locale = .init(identifier: id) timeZone = .init(identifier: localization.timeZone) } /// Applies a `DateFormatterConfig` (format, locale, time zone) to the formatter. /// /// - Parameter config: The date formatter configuration. func use(config: DateFormatterConfig) { use(localization: config.localization) dateFormat = config.format } } /// Holds system date and time style `DateFormatter` instances and an ISO8601 formatter. private struct SystemDateFormatters { struct Date { var full: DateFormatter var long: DateFormatter var medium: DateFormatter var short: DateFormatter } struct Time { var full: DateFormatter var long: DateFormatter var medium: DateFormatter var short: DateFormatter } var date: Date var time: Time var iso8601: DateFormatter } /// Main utility for parsing and formatting dates based on project configuration. /// /// Combines input parsing, system-style formatters, and user-defined formats. public struct ToucanInputDateFormatter { private var dateConfig: Config.DataTypes.Date private var inputFormatter: DateFormatter private var ephemeralFormatter: DateFormatter var logger: Logger /// Initializes the date formatter utility. /// /// - Parameters: /// - dateConfig: The base date configuration from the project. /// - logger: A logger instance for diagnostics. public init( dateConfig: Config.DataTypes.Date, logger: Logger = .subsystem("input-date-formatter") ) { self.dateConfig = dateConfig self.inputFormatter = .build { $0.use(config: dateConfig.input) } self.ephemeralFormatter = .build { $0.use(config: dateConfig.input) } self.logger = logger } /// Parses a date string into a `Date` object. /// /// - Parameters: /// - string: The string representation of the date. /// - config: Optional `DateFormatterConfig` to override the input format. /// - Returns: A `Date` if parsing succeeds, otherwise `nil`. public func date( from string: String, using config: DateFormatterConfig? = nil ) -> Date? { if let config { ephemeralFormatter.use(config: config) return ephemeralFormatter.date(from: string) } return inputFormatter.date(from: string) } /// Converts a date into a `String` object. /// /// - Parameters: /// - date: The date representation. /// - config: Optional `DateFormatterConfig` to override the input format. /// - Returns: A `String` using the provided date format config. public func string( from date: Date, using config: DateFormatterConfig? = nil ) -> String { if let config { ephemeralFormatter.use(config: config) return ephemeralFormatter.string(from: date) } return inputFormatter.string(from: date) } } /// Main utility for parsing and formatting dates based on project configuration. /// /// Combines input parsing, system-style formatters, and user-defined formats. public struct ToucanOutputDateFormatter { private var dateConfig: Config.DataTypes.Date private var pipelineDateConfig: Pipeline.DataTypes.Date? private var systemFormatters: SystemDateFormatters private var userFormatters: [String: DateFormatter] var logger: Logger /// Initializes the date formatter utility. /// /// - Parameters: /// - dateConfig: The base date configuration from the project. /// - pipelineDateConfig: Optional overrides for date configuration. /// - logger: A logger instance for diagnostics. public init( dateConfig: Config.DataTypes.Date, pipelineDateConfig: Pipeline.DataTypes.Date? = nil, logger: Logger = .subsystem("date-formatter") ) { self.dateConfig = dateConfig self.pipelineDateConfig = pipelineDateConfig var localization = dateConfig.output if let outputLocalization = pipelineDateConfig?.output { localization = outputLocalization } self.systemFormatters = .init( date: .init( full: .build(using: localization) { $0.dateStyle = .full }, long: .build(using: localization) { $0.dateStyle = .long }, medium: .build(using: localization) { $0.dateStyle = .medium }, short: .build(using: localization) { $0.dateStyle = .short } ), time: .init( full: .build(using: localization) { $0.timeStyle = .full }, long: .build(using: localization) { $0.timeStyle = .long }, medium: .build(using: localization) { $0.timeStyle = .medium }, short: .build(using: localization) { $0.timeStyle = .short } ), iso8601: .build(using: localization) { $0.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" } ) self.userFormatters = [:] self.logger = logger let userFormatterConfig = dateConfig.formats.merging( (pipelineDateConfig?.formats ?? [:]), uniquingKeysWith: { _, new in new } ) for (key, config) in userFormatterConfig { userFormatters[key] = .build(using: localization) { $0.use(config: config) } } } /// Formats a `Date` into a `DateContext`, providing multiple style outputs and custom formats. /// /// - Parameter date: The `Date` to format. /// - Returns: A `DateContext` containing formatted strings and timestamp. public func format( _ date: Date ) -> DateContext { .init( date: .init( full: systemFormatters.date.full.string(from: date), long: systemFormatters.date.long.string(from: date), medium: systemFormatters.date.medium.string(from: date), short: systemFormatters.date.short.string(from: date) ), time: .init( full: systemFormatters.time.full.string(from: date), long: systemFormatters.time.long.string(from: date), medium: systemFormatters.time.medium.string(from: date), short: systemFormatters.time.short.string(from: date) ), timestamp: date.timeIntervalSince1970, iso8601: systemFormatters.iso8601.string(from: date), formats: userFormatters.mapValues { $0.string(from: date) } ) } /// Formats a time interval since 1970 into a `DateContext`. /// /// - Parameter timestamp: The time interval (seconds since 1970). /// - Returns: A `DateContext` with formatted outputs. public func format( _ timestamp: TimeInterval ) -> DateContext { format(.init(timeIntervalSince1970: timestamp)) } } ================================================ FILE: Sources/ToucanSDK/Models/ContextBundle.swift ================================================ // // ContextBundle.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // import ToucanSource /// A bundle containing a single content item, its rendering context, and its destination metadata. /// /// `ContextBundle` is typically used as an input for template rendering or output generation, /// combining the actual content with any supplemental data required for processing. public struct ContextBundle { /// The primary content item to be rendered or processed. public var content: Content /// A key-value store representing the extended rendering context (e.g., metadata, global variables). /// These values can be used during template evaluation or logic processing. public var context: [String: AnyCodable] /// The intended destination of the output generated from this bundle. public var destination: Destination /// Initializes a new `ContextBundle` with content, context data, and a destination. /// /// - Parameters: /// - content: The `Content` instance to render. /// - context: A context dictionary providing additional rendering metadata or variables. /// - destination: Where the rendered output should be saved. public init( content: Content, context: [String: AnyCodable], destination: Destination ) { self.content = content self.context = context self.destination = destination } } ================================================ FILE: Sources/ToucanSDK/Models/Destination.swift ================================================ // // Destination.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // /// Represents the destination location and filename for rendered or transformed content. public struct Destination: Sendable { /// The relative or absolute path to the target directory where the file should be placed. public var path: String /// The base name of the file (without extension). public var file: String /// The file extension (e.g., "html", "json", "md"). public var ext: String /// Initializes a new `Destination` describing where and how a file should be written. /// /// - Parameters: /// - path: The directory path to write the file to. /// - file: The base file name (without extension). /// - ext: The file extension (e.g., `"html"`, `"json"`). public init( path: String, file: String, ext: String ) { self.path = path self.file = file self.ext = ext } } ================================================ FILE: Sources/ToucanSDK/Models/PipelineResult.swift ================================================ // // PipelineResult.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // /// Represents the output of a content transformation pipeline, including the /// transformed content and its intended destination. public struct PipelineResult: Sendable { /// The source material for the pipeline result. public enum Source: Sendable { /// An asset source, that needs top be copied. case assetFile(String) /// A generated asset source case asset(String) /// The final transformed content (e.g., HTML, Markdown, etc.). case content(String) /// A Boolean value indicating whether the pipeline result's source is content-based. /// Returns `true` if the source is `.content`, otherwise `false`. public var isContent: Bool { !isAsset } /// A Boolean value indicating whether the pipeline result's source is an asset. /// Returns `true` if the source is `.asset`, otherwise `false`. public var isAsset: Bool { switch self { case .content: false case .assetFile: true case .asset: true } } } /// The source material. public var source: Source /// The destination metadata describing where or how the content should be output. public var destination: Destination /// Initializes a new `PipelineResult` with transformed content and a destination. /// /// - Parameters: /// - source: The source material. /// - destination: A `Destination` indicating where the result should be saved or rendered. public init( source: Source, destination: Destination ) { self.source = source self.destination = destination } } ================================================ FILE: Sources/ToucanSDK/Models/Slug.swift ================================================ // // Slug.swift // Toucan // // Created by gerp83 on 2025. 04. 17.. // /// A value type representing a URL-friendly identifier for a content item. public struct Slug: Equatable { /// The raw slug string (e.g., `"blog/welcome"`, `"about"`, `""`). public var value: String /// Initializes a new slug. /// /// - Parameter value: The raw slug string. public init( _ value: String ) { self.value = value } /// Extracts a dynamic iterator identifier from a slug value containing /// a templated range (e.g., `"blog/{{page}}"` → `"page"`). /// /// - Returns: The identifier inside `{{...}}`, or `nil` if not found. public func extractIteratorID() -> String? { guard let startRange = value.range(of: "{{"), let endRange = value.range( of: "}}", range: startRange.upperBound.. String { let components = value.split(separator: "/").map(String.init) if components.isEmpty { return baseURL.ensureTrailingSlash() } if components.last?.split(separator: ".").count ?? 0 > 1 { // If last segment has a file extension, return without trailing slash return ([baseURL] + components).joined(separator: "/") } return ([baseURL] + components) .joined(separator: "/") .ensureTrailingSlash() } } extension Slug: Codable { /// Generates a context-aware identifier string based on the last path component of a value. /// public func contextAwareIdentifier() -> String { .init(value.split(separator: "/").last ?? "") } /// Creates a new instance by decoding from the given decoder. /// /// This initializer attempts to decode the value as a single string. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if reading from the decoder fails, or if the data is not a single string. public init( from decoder: Decoder ) throws { let container = try decoder.singleValueContainer() self.value = try container.decode(String.self) } /// Encodes this value into the given encoder. /// /// This method encodes the value as a single string. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if encoding fails. public func encode( to encoder: Encoder ) throws { var container = encoder.singleValueContainer() try container.encode(value) } } ================================================ FILE: Sources/ToucanSDK/Outputs/ContextBundleToHTMLRenderer.swift ================================================ // // ContextBundleToHTMLRenderer.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 13.. // import Foundation import Logging import ToucanSource struct ContextBundleToHTMLRenderer { let mustacheRenderer: MustacheRenderer let engineContentTypesOptions: [String: AnyCodable] let pipelineViewKey: String let logger: Logger init( pipeline: Pipeline, templates: [String: String], logger: Logger = .subsystem("context-bundle-to-html-renderer") ) throws { self.mustacheRenderer = try MustacheRenderer( templates: templates.mapValues { try .init(string: $0) } ) let engineOptions = pipeline.engine.options self.engineContentTypesOptions = engineOptions.dict("contentTypes") self.pipelineViewKey = [ ViewFrontMatterKeys.views.rawValue, pipeline.id, ] .joined(separator: ".") self.logger = logger } func render( _ contextBundles: [ContextBundle] ) -> [PipelineResult] { contextBundles.compactMap { render($0) } } func render( _ contextBundle: ContextBundle ) -> PipelineResult? { let contentTypeOptions = engineContentTypesOptions.dict( contextBundle.content.type.id ) let frontMatter = contextBundle.content.rawValue.markdown.frontMatter let contentTypeView = contentTypeOptions.string( ViewFrontMatterKeys.view.rawValue ) let genericContentView = frontMatter.string( ViewFrontMatterKeys.any.rawValue ) let contentView = frontMatter.string(pipelineViewKey) let viewId = contentView ?? genericContentView ?? contentTypeView guard let viewId, !viewId.isEmpty else { logger.warning( "No view has been specified for this content.", metadata: [ "slug": "\(contextBundle.content.slug.value)", "type": "\(contextBundle.content.type.id)", ] ) return nil } let html = mustacheRenderer.render( using: viewId, with: contextBundle.context ) guard let html else { logger.warning( "Could not get valid HTML from content using view.", metadata: [ "slug": .string(contextBundle.content.slug.value), "type": .string(contextBundle.content.type.id), "view": .string(viewId), ] ) return nil } return .init( source: .content(html), destination: contextBundle.destination ) } } ================================================ FILE: Sources/ToucanSDK/Outputs/ContextBundleToJSONRenderer.swift ================================================ // // ContextBundleToJSONRenderer.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 13.. // import Foundation import Logging import ToucanSource struct ContextBundleToJSONRenderer { let pipeline: Pipeline let encoder: JSONEncoder let logger: Logger let keyPath: String? let keyPaths: [String: AnyCodable]? init( pipeline: Pipeline, logger: Logger = .subsystem("context-bundle-to-json-renderer") ) { let encoder = JSONEncoder() encoder.outputFormatting = [ .prettyPrinted, .withoutEscapingSlashes, .sortedKeys, ] self.pipeline = pipeline self.encoder = encoder self.logger = logger self.keyPath = pipeline.engine.options.string("keyPath") self.keyPaths = pipeline.engine.options.value( "keyPaths", as: [String: AnyCodable].self ) } private func data( from context: [String: Any], at keyPath: String?, using encoder: JSONEncoder ) throws -> Data? { guard let keyPath else { return nil } if let value = context.value(forKeyPath: keyPath) { return try encoder.encode(AnyCodable(value)) } return nil } private func data( from context: [String: Any], keyPaths: [String: AnyCodable]?, using encoder: JSONEncoder ) throws -> Data? { var result: [String: AnyCodable] = [:] guard let keyPaths else { return nil } for (keyPath, value) in keyPaths { guard let newKeyPath = value.value(as: String.self) else { continue } if let value = context.value(forKeyPath: keyPath) { result[newKeyPath] = .init(value) } } return try encoder.encode(result) } func render(_ contextBundles: [ContextBundle]) -> [PipelineResult] { contextBundles.compactMap { render($0) } } func render(_ contextBundle: ContextBundle) -> PipelineResult? { let metadata: Logger.Metadata = [ "slug": "\(contextBundle.content.slug.value)" ] let context = contextBundle.context let unboxedContext = context.unboxed(encoder) let encodedData = firstSucceeding([ { try data( from: unboxedContext, keyPaths: keyPaths, using: encoder ) }, { try data(from: unboxedContext, at: keyPath, using: encoder) }, { try encoder.encode(context) }, ]) guard let encodedData else { logger.warning( "Could not encode context data as JSON object.", metadata: metadata ) return nil } let json = String(data: encodedData, encoding: .utf8) guard let json else { logger.warning( "Could not encode context data as JSON output.", metadata: metadata ) return nil } return .init( source: .content(json), destination: contextBundle.destination ) } } ================================================ FILE: Sources/ToucanSDK/Renderers/BuildTargetSourceRenderer.swift ================================================ // // BuildTargetSourceRenderer.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 25.. // import Foundation import Logging import ToucanCore import ToucanMarkdown import ToucanSerialization import ToucanSource enum BuildTargetSourceRendererError: ToucanError { case invalidEngine(String) case unknown(Error) var underlyingErrors: [any Error] { switch self { case let .unknown(error): [error] default: [] } } var logMessage: String { switch self { case let .invalidEngine(engine): "Invalid engine: `\(engine)`." case let .unknown(error): error.localizedDescription } } var userFriendlyMessage: String { switch self { case let .invalidEngine(engine): "Invalid engine: `\(engine)`." case .unknown: "Unknown source validator error." } } } /// Responsible for rendering the entire site bundle based on the `BuildTargetSource` configuration. /// /// It processes content pipelines using the configured engine (Mustache, JSON, etc.), /// resolves content and site-level context, and outputs rendered content using templates /// or encoded formats. public struct BuildTargetSourceRenderer { /// Site configuration + all raw content let buildTargetSource: BuildTargetSource /// Generator metadata (e.g., version, name) let generatorInfo: GeneratorInfo /// Logger for warnings and errors let logger: Logger /// Cache var contentContextCache: [String: [String: AnyCodable]] = [:] /// Initializes a renderer from a source bundle. /// /// - Parameters: /// - buildTargetSource: The structured bundle containing settings, pipelines, and contents. /// - generatorInfo: Info about the content generator (defaults to `.current`). /// - logger: Logger for reporting issues or metrics. public init( buildTargetSource: BuildTargetSource, generatorInfo: GeneratorInfo = .current, logger: Logger = .subsystem("build-target-source-renderer") ) { self.buildTargetSource = buildTargetSource self.generatorInfo = generatorInfo self.logger = logger } /// Returns the last content update based on the pipeline config private func getLastContentUpdate( contents: [Content], pipeline: Pipeline, now: TimeInterval ) -> TimeInterval? { var updateTypes = Set(contents.map(\.type.id)) if !pipeline.contentTypes.lastUpdate.isEmpty { updateTypes = updateTypes.filter { pipeline.contentTypes.lastUpdate.contains($0) } } let lastUpdate = updateTypes.compactMap { let items = contents.run( query: .init( contentType: $0, scope: nil, limit: 1, orderBy: [ .init( key: SystemPropertyKeys.lastUpdate.rawValue, direction: .desc ) ] ), now: now, logger: logger ) return items.first?.rawValue.lastModificationDate } .sorted(by: >).first logger.trace( "Last update for the pipeline.", metadata: [ "pipeline": .string(pipeline.id), "lastUpdate": .string( Date(timeIntervalSince1970: lastUpdate ?? 0).description ), ] ) return lastUpdate } private func baseURL() -> String { buildTargetSource.target.url.dropTrailingSlash() } /// Returns the renderable context bundle for each content for a given pipeline using the global context mutating func getContextBundles( contents: [Content], context globalContext: [String: AnyCodable], pipeline: Pipeline, dateFormatter: ToucanOutputDateFormatter, now: TimeInterval ) throws -> [ContextBundle] { contents.compactMap { content in let isAllowed = pipeline.contentTypes.isAllowed( contentType: content.type.id ) guard isAllowed else { logger.trace( "Skipping content type for the pipeline.", metadata: [ "pipeline": .string(pipeline.id), "content-type": .string(content.type.id), "include": .array( pipeline.contentTypes.include.map { .string($0) } ), "exclude": .array( pipeline.contentTypes.exclude.map { .string($0) } ), ] ) return nil } let pipelineContext = getPipelineContext( contents: contents, pipeline: pipeline, dateFormatter: dateFormatter, now: now ) .recursivelyMerged(with: globalContext) return getContextBundle( contents: contents, content: content, pipeline: pipeline, pipelineContext: pipelineContext, dateFormatter: dateFormatter, now: now ) } } mutating func getPipelineContext( contents: [Content], pipeline: Pipeline, dateFormatter: ToucanOutputDateFormatter, now: TimeInterval ) -> [String: AnyCodable] { var rawContext: [String: AnyCodable] = [:] for (key, query) in pipeline.queries { let results = contents.run(query: query, now: now, logger: logger) rawContext[key] = .init( results.map { getContentContext( contents: contents, for: $0, pipeline: pipeline, dateFormatter: dateFormatter, now: now, scopeKey: query.scope ?? Pipeline.Scope.Keys.list.rawValue ) } ) } return [ RootContextKeys.context.rawValue: .init(rawContext) ] } mutating func getIteratorContext( contents: [Content], content: Content, pipeline: Pipeline, dateFormatter: ToucanOutputDateFormatter, now: TimeInterval ) -> [String: AnyCodable] { guard let iteratorInfo = content.iteratorInfo else { return [:] } let itemContext = iteratorInfo.items.map { getContentContext( contents: contents, for: $0, pipeline: pipeline, dateFormatter: dateFormatter, now: now, scopeKey: iteratorInfo.scope ?? Pipeline.Scope.Keys.list.rawValue ) } return [ RootContextKeys.iterator.rawValue: .init( [ IteratorKeys.total.rawValue: .init(iteratorInfo.total), IteratorKeys.limit.rawValue: .init(iteratorInfo.limit), IteratorKeys.current.rawValue: .init(iteratorInfo.current), IteratorKeys.items.rawValue: .init(itemContext), IteratorKeys.links.rawValue: .init(iteratorInfo.links), ] as [String: AnyCodable] ) ] } mutating func getContextBundle( contents: [Content], content: Content, pipeline: Pipeline, pipelineContext: [String: AnyCodable], dateFormatter: ToucanOutputDateFormatter, now: TimeInterval ) -> ContextBundle { let pageContext = getContentContext( contents: contents, for: content, pipeline: pipeline, dateFormatter: dateFormatter, now: now, scopeKey: Pipeline.Scope.Keys.detail.rawValue ) let iteratorContext = getIteratorContext( contents: contents, content: content, pipeline: pipeline, dateFormatter: dateFormatter, now: now ) let context: [String: AnyCodable] = [ RootContextKeys.page.rawValue: .init(pageContext) ] .recursivelyMerged(with: iteratorContext) .recursivelyMerged(with: pipelineContext) var outputArgs: [String: String] = [ "{{id}}": content.typeAwareID, "{{slug}}": content.slug.value, ] if let info = content.iteratorInfo { outputArgs["{{iterator.current}}"] = String(info.current) outputArgs["{{iterator.total}}"] = String(info.total) outputArgs["{{iterator.limit}}"] = String(info.limit) } let path = pipeline.output.path.replacing(outputArgs) let file = pipeline.output.file.replacing(outputArgs) let ext = pipeline.output.ext.replacing(outputArgs) return .init( content: content, context: context, destination: .init( path: path, file: file, ext: ext ) ) } mutating func getContentContext( contents: [Content], for content: Content, pipeline: Pipeline, dateFormatter: ToucanOutputDateFormatter, now: TimeInterval, scopeKey: String, allowSubQueries: Bool = true // allow top level queries only ) -> [String: AnyCodable] { var result: [String: AnyCodable] = [:] let scope = pipeline.getScope( keyedBy: scopeKey, for: content.type.id ) logger.trace( "Using scope for content", metadata: [ "pipeline": .string(pipeline.id), "content": .string(content.slug.value), "scope": .string(scopeKey), "context": .array( scope.context.stringValues.map { .string($0) } ), "fields": .array( scope.fields.map { .string($0) } ), "allowSubQueries": .string(allowSubQueries.description), ] ) let cacheKey = [ pipeline.id, content.slug.value, scopeKey, String(allowSubQueries), ] .joined(separator: "_") if let cachedContext = contentContextCache[cacheKey] { return cachedContext } if scope.context.contains(.userDefined) { result = result.recursivelyMerged(with: content.userDefined) } if scope.context.contains(.properties) { for (k, v) in content.properties { if let p = content.type.properties[k] { switch p.type { /// resolve assets case .asset: guard let rawValue = v.stringValue() else { continue } let resolvedValue = rawValue.resolveAsset( baseURL: baseURL(), assetsPath: content.rawValue.assetsPath, slug: content.slug.value ) result[k] = .init(resolvedValue) /// format dates case .date: guard let rawValue = v.doubleValue() else { continue } result[k] = .init( dateFormatter.format(rawValue) ) default: result[k] = .init(v.value) } } else { result[k] = .init(v.value) } } result[SystemPropertyKeys.slug.rawValue] = .init(content.slug.value) result[SystemPropertyKeys.lastUpdate.rawValue] = .init( dateFormatter.format(content.rawValue.lastModificationDate) ) result[PageContextKeys.permalink.rawValue] = .init( content.slug.permalink(baseURL: baseURL()) ) } if scope.context.contains(.contents) { let transformers = pipeline.transformers[ content.type.id ] let renderer = MarkdownRenderer( configuration: .init( markdown: .init( customBlockDirectives: buildTargetSource.blocks .map { .init( name: $0.name, parameters: $0.parameters? .map { .init( label: $0.label, isRequired: $0.isRequired, defaultValue: $0.defaultValue ) }, requiresParentDirective: $0 .requiresParentDirective, removesChildParagraph: $0 .removesChildParagraph, tag: $0.tag, attributes: $0.attributes? .map { .init( name: $0.name, value: $0.value ) }, output: $0.output ) } ), outline: .init( levels: buildTargetSource.config.renderer .outlineLevels ), readingTime: .init( wordsPerMinute: buildTargetSource.config .renderer.wordsPerMinute ), transformerPipeline: transformers.map { .init( run: $0.run.map { .init(path: $0.path, name: $0.name) }, isMarkdownResult: $0.isMarkdownResult ) }, paragraphStyles: buildTargetSource.config.renderer .paragraphStyles.styles ) ) let contents = renderer.render( content: content.rawValue.markdown.contents, typeAwareID: content.typeAwareID, slug: content.slug.value, assetsPath: buildTargetSource.config.contents.assets.path, baseURL: baseURL() ) result[PageContextKeys.contents.rawValue] = [ PageContentsKeys.html.rawValue: contents.html, PageContentsKeys.readingTime.rawValue: contents.readingTime, PageContentsKeys.outline.rawValue: contents.outline, ] } if scope.context.contains(.relations) { for (key, relation) in content.type.relations { var orderBy: [Order] = [] if let order = relation.order { orderBy.append(order) } let relationContents = contents.run( query: .init( contentType: relation.references, filter: .field( key: "id", operator: .in, value: .init( content.relations[key]?.identifiers ?? [] ) ), orderBy: orderBy ), now: now, logger: logger ) let relationContexts = relationContents.map { getContentContext( contents: contents, for: $0, pipeline: pipeline, dateFormatter: dateFormatter, now: now, scopeKey: Pipeline.Scope.Keys.reference.rawValue, allowSubQueries: false ) } switch relation.type { case .many: result[key] = .init(relationContexts) case .one: if let item = relationContexts.first { result[key] = .init(item) } } } } if allowSubQueries, scope.context.contains(.queries) { for (key, query) in content.type.queries { let queryContents = contents.run( query: query.resolveFilterParameters( with: content.queryFields ), now: now, logger: logger ) result[key] = .init( queryContents.map { getContentContext( contents: contents, for: $0, pipeline: pipeline, dateFormatter: dateFormatter, now: now, scopeKey: query.scope ?? Pipeline.Scope.Keys.list.rawValue, allowSubQueries: false ) } ) } } logger.trace( "Returning context for content", metadata: [ "pipeline": .string(pipeline.id), "content": .string(content.slug.value), "scope": .string(scopeKey), "context": .array( scope.context.stringValues.map { .string($0) } ), "fields": .array( scope.fields.map { .string($0) } ), "allowSubQueries": .string(allowSubQueries.description), "result": .dictionary( [ "slug": .string( result["slug"]?.stringValue() ?? "nil" ), "permalink": .string( result[PageContextKeys.permalink.rawValue]? .stringValue() ?? "nil" ), ] ), ] ) guard !scope.fields.isEmpty else { contentContextCache[cacheKey] = result return result } contentContextCache[cacheKey] = result return result.filter { scope.fields.contains($0.key) } } /// Renders pipelines by processing content using defined resolvers and formatting logic, /// and executes a renderer block with the resulting context bundles. /// /// - Parameters: /// - now: The current date used for time-sensitive filtering and formatting. /// - rendererBlock: A closure that takes a pipeline and its corresponding context bundles, /// and returns the rendered pipeline results. /// - Returns: An array of `PipelineResult` containing all results from all processed pipelines. /// - Throws: Rethrows any error encountered during content processing or rendering. public mutating func render( now: Date, rendererBlock: @escaping ( (_: Pipeline, _: [ContextBundle]) throws -> [PipelineResult] ) ) throws -> [PipelineResult] { let now = now.timeIntervalSince1970 let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let inputDateFormatter = ToucanInputDateFormatter( dateConfig: buildTargetSource.config.dataTypes.date ) // TODO: This should be in a .toucaninfo file or similar let globalContext: [String: AnyCodable] = [ GlobalContextKeys.baseUrl.rawValue: .init(baseURL()), GlobalContextKeys.generator.rawValue: .init(generatorInfo), ] let contentTypeResolver = ContentTypeResolver( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ) let contentResolver = ContentResolver( contentTypeResolver: contentTypeResolver, encoder: encoder, decoder: decoder, dateFormatter: inputDateFormatter ) let baseContents = try contentResolver.convert( rawContents: buildTargetSource.rawContents ) var results: [PipelineResult] = [] // TODO: `for` probably should happen in Toucan.swift, and we could deal with a single pipeline here for pipeline in buildTargetSource.pipelines { let filteredContents = contentResolver.apply( filterRules: pipeline.contentTypes.filterRules, to: baseContents, now: now ) let iteratedContents = contentResolver.apply( iterators: pipeline.iterators, to: filteredContents, baseURL: baseURL(), now: now ) let finalContents = try contentResolver.apply( assetProperties: pipeline.assets.properties, to: iteratedContents, contentsURL: buildTargetSource.locations.contentsURL, assetsPath: buildTargetSource.config.contents.assets.path, baseURL: baseURL() ) let dateFormatter = ToucanOutputDateFormatter( dateConfig: buildTargetSource.config.dataTypes.date, pipelineDateConfig: pipeline.dataTypes.date ) let assetResults = try contentResolver.applyBehaviors( pipeline: pipeline, to: finalContents, contentsURL: buildTargetSource.locations.contentsURL, assetsPath: buildTargetSource.config.contents.assets.path ) results.append(contentsOf: assetResults) let lastUpdate = getLastContentUpdate( contents: finalContents, pipeline: pipeline, now: now ) ?? now let contextBundles = try getContextBundles( contents: finalContents, context: globalContext.recursivelyMerged( with: [ SystemPropertyKeys.lastUpdate.rawValue: .init( dateFormatter.format(lastUpdate) ), GlobalContextKeys.generation.rawValue: .init( dateFormatter.format(now) ), GlobalContextKeys.site.rawValue: .init( buildTargetSource.settings.values ), ] ), pipeline: pipeline, dateFormatter: dateFormatter, now: now ) logger.trace( "Rendering contents for pipeline", metadata: [ "pipeline": .string(pipeline.id), "counts": [ "base": .string(baseContents.count.description), "iterated": .string(iteratedContents.count.description), "final": .string(finalContents.count.description), "context": .string(contextBundles.count.description), ], "final": .array( finalContents.map(\.slug.value).map { .string($0) } ), "context": .array( contextBundles.map( \.content.slug.value ) .map { .string($0) } ), ] ) results += try rendererBlock(pipeline, contextBundles) } return results } } ================================================ FILE: Sources/ToucanSDK/Renderers/MustacheRenderer.swift ================================================ // // MustacheRenderer.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 16.. // import Foundation import Logging import Mustache import ToucanSource import ToucanCore /// Renders Mustache templates using a predefined template library and a dynamic context object. public struct MustacheRenderer { /// A list of all available template IDs in the library. var ids: [String] /// The Mustache template library holding precompiled templates. var library: MustacheLibrary /// Logger used for reporting missing templates or rendering failures. var logger: Logger /// Initializes a renderer with a set of compiled Mustache templates and a logger. /// /// - Parameters: /// - templates: A dictionary of template IDs and their corresponding `MustacheTemplate` objects. /// - logger: A logger instance used for error reporting. public init( templates: [String: MustacheTemplate], logger: Logger = .subsystem("mustache-renderer") ) { self.ids = Array(templates.keys) self.library = .init(templates: templates) self.logger = logger } /// Renders a Mustache template using the given context object. /// /// - Parameters: /// - id: The ID of the template to render. /// - object: A dictionary representing the context (`[String: AnyCodable]`). /// - Returns: The rendered HTML string, or `nil` if rendering fails or the template is missing. public func render( using id: String, with object: [String: AnyCodable] ) -> String? { // Ensure the ID is valid guard ids.contains(id) else { logger.error( "Missing or invalid template file.", metadata: [ "id": .string(id) ] ) return nil } // Unwrap the object for rendering let local = unwrap(object) as Any // Attempt rendering using the Mustache library guard let html = library.render(local, withTemplate: id) else { logger.error( "Could not render HTML using the template file.", metadata: [ "id": .string(id) ] ) return nil } return html } } ================================================ FILE: Sources/ToucanSDK/Toucan.swift ================================================ // // Toucan.swift // Toucan // // Created by Tibor Bödecs on 2025. 04. 17.. // import FileManagerKit import Foundation import Logging import ToucanCore import ToucanSerialization import ToucanSource /// Primary entry point for generating a static site using the Toucan framework. public struct Toucan { let fileManager: FileManagerKit let encoder: ToucanEncoder let decoder: ToucanDecoder let logger: Logger /// Initialize a new instance. /// /// - Parameters: /// - fileManager: The file manager used to perform file operations. /// - encoder: The encoder used to encode data. Defaults to a YAML encoder. /// - decoder: The decoder used to decode data. Defaults to a YAML decoder. /// - logger: A logger instance for logging. Defaults to a logger labeled "toucan". public init( fileManager: FileManagerKit = FileManager.default, encoder: ToucanEncoder = ToucanYAMLEncoder(), decoder: ToucanDecoder = ToucanYAMLDecoder(), logger: Logger = .subsystem() ) { self.fileManager = fileManager self.encoder = encoder self.decoder = decoder self.logger = logger } func resolveHomeURL( for path: String ) -> URL { let home = fileManager.homeDirectoryForCurrentUser.path return .init( fileURLWithPath: path.replacing(["~": home]) ) .standardized } func absoluteURL( for path: String, cwd url: URL? = nil ) -> URL { if path.hasPrefix("/") { return URL(filePath: path).standardized } if path.hasPrefix("~") { return resolveHomeURL(for: path).standardized } let cwdURL = url ?? URL(filePath: fileManager.currentDirectoryPath) if path == "." || path == "./" { return cwdURL.standardized } return cwdURL.appendingPathIfPresent(path).standardized } func resetDirectory(at url: URL) throws { if fileManager.exists(at: url) { try fileManager.delete(at: url) } try fileManager.createDirectory( at: url, attributes: nil ) } func prepareTemporaryWorkingDirectory() throws -> URL { let url = fileManager .temporaryDirectory .appendingPathComponent("toucan") .appendingPathComponent(UUID().uuidString) try resetDirectory(at: url) logger.debug( "Working at temporary directory.", metadata: [ "path": .string(url.path()) ] ) return url } func loadTargetConfig( workDirURL: URL ) throws -> TargetConfig { try ObjectLoader( url: workDirURL, locations: fileManager .find( name: "toucan", extensions: ["yml", "yaml"], at: workDirURL ), encoder: encoder, decoder: decoder ) .load(TargetConfig.self) } func getActiveBuildTargets( targetConfig: TargetConfig, targetsToBuild: [String] ) -> [Target] { var buildTargets = targetConfig.targets.filter { targetsToBuild.contains($0.name) } if buildTargets.isEmpty { buildTargets.append(targetConfig.default) } return buildTargets } // MARK: - api /// Generates the static site. /// /// - Parameters: /// - workDir: The working directory URL as a path string. /// - targetsToBuild: The list of target names to build. /// - now: The current date used during the build. /// - Throws: An error if the generation fails. public func generate( workDir: String, targetsToBuild: [String] = [], now: Date = .init() ) throws { let workDirURL = absoluteURL(for: workDir) let temporaryWorkDirURL = try prepareTemporaryWorkingDirectory() do { let targetConfig = try loadTargetConfig( workDirURL: workDirURL ) let activeBuildTargets = getActiveBuildTargets( targetConfig: targetConfig, targetsToBuild: targetsToBuild ) for target in activeBuildTargets { let sourceURL = absoluteURL( for: target.input, cwd: workDirURL ) let distURL = absoluteURL( for: target.output, cwd: workDirURL ) logger.debug( "Building target.", metadata: [ "name": .string(target.name), "input": .string(target.input), "output": .string(target.output), "workDir": .string(workDirURL.path()), "srcDir": .string(sourceURL.path()), "distDir": .string(distURL.path()), "tmpDir": .string(temporaryWorkDirURL.path()), ] ) let buildTargetSourceLoader = BuildTargetSourceLoader( sourceURL: workDirURL, target: target, fileManager: fileManager, encoder: encoder, decoder: decoder ) let buildTargetSource = try buildTargetSourceLoader.load() let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let generatorInfo = GeneratorInfo.current var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource, generatorInfo: generatorInfo ) let templateLoader = TemplateLoader( locations: buildTargetSource.locations, fileManager: fileManager, encoder: encoder, decoder: decoder ) let results = try renderer.render( now: now ) { pipeline, contextBundles in logger.trace( "Rendering pipeline", metadata: [ "id": .string(pipeline.id), "contextBundleCount": .string( String(contextBundles.count) ), ] ) switch pipeline.engine.id { case "json": let renderer = ContextBundleToJSONRenderer( pipeline: pipeline ) return renderer.render(contextBundles) case "mustache": let template = try templateLoader.load() let templateValidator = try TemplateValidator( generatorInfo: generatorInfo ) try templateValidator.validate(template) let renderer = try ContextBundleToHTMLRenderer( pipeline: pipeline, templates: template.getViewIDsWithContents() ) return renderer.render(contextBundles) default: throw BuildTargetSourceRendererError.invalidEngine( pipeline.engine.id ) } } logger.debug( "Target ready.", metadata: [ "name": .string(target.name), "resultsCount": .string(String(results.count)), ] ) try resetDirectory(at: temporaryWorkDirURL) // MARK: - Copy default assets let copyManager = CopyManager( fileManager: fileManager, sources: [ buildTargetSource.locations.currentTemplateAssetsURL, buildTargetSource.locations .currentTemplateAssetOverridesURL, buildTargetSource.locations.siteAssetsURL, ], destination: temporaryWorkDirURL ) try copyManager.copy() // MARK: - Writing results for result in results { let destinationFolder = temporaryWorkDirURL .appendingPathIfPresent(result.destination.path) try fileManager.createDirectory( at: destinationFolder, attributes: nil ) let resultOutputURL = destinationFolder .appendingPathIfPresent(result.destination.file) .appendingPathExtension(result.destination.ext) switch result.source { case let .assetFile(path): let srcURL = buildTargetSource.locations.contentsURL .appendingPathIfPresent(path) try fileManager.copy(from: srcURL, to: resultOutputURL) case let .asset(string), let .content(string): try string.write( to: resultOutputURL, atomically: true, encoding: .utf8 ) } } // MARK: - Finalize and cleanup try resetDirectory(at: distURL) try fileManager.copyRecursively( from: temporaryWorkDirURL, to: distURL ) try fileManager.delete(at: temporaryWorkDirURL) } } catch { try fileManager.delete(at: temporaryWorkDirURL) throw error } } /// Attempts to generate the static site and logs any errors encountered. /// - Parameters: /// - workDir: The working directory URL as a path string. /// - targetsToBuild: The list of target names to build. /// - now: The current date used during the build. /// - Returns: `true` if generation succeeds without errors; otherwise, `false`. /// @discardableResult public func generateAndLogErrors( workDir: String, targetsToBuild: [String], now: Date ) -> Bool { do { try generate( workDir: workDir, targetsToBuild: targetsToBuild, now: now ) return true } catch let error as ToucanError { logger.error("\(error.logMessageStack())") } catch { logger.error("\(error)") } return false } } ================================================ FILE: Sources/ToucanSDK/Utilities/Any+AnyCodable.swift ================================================ // // Any+AnyCodable.swift // Toucan // // Created by Tibor Bödecs on 2025. 03. 06.. // import ToucanSource /// Recursively unwraps a value that may contain `AnyCodable` types into native Swift types. /// /// - Parameter value: A possibly wrapped `Any?`, including `[String: AnyCodable]`, `[AnyCodable]`, etc. /// - Returns: A fully unwrapped `Any?`, preserving dictionaries and arrays but removing all `AnyCodable` wrappers. public func unwrap(_ value: Any?) -> Any? { if let anyCodable = value as? AnyCodable { return unwrap(anyCodable.value) } if let dict = value as? [String: AnyCodable] { var result: [String: Any] = [:] for (key, val) in dict { result[key] = unwrap(val) } return result } if let dict = value as? [String: Any] { var result: [String: Any] = [:] for (key, val) in dict { result[key] = unwrap(val) } return result } if let array = value as? [Any?] { return array.compactMap { unwrap($0) } } return value } /// Recursively wraps a native Swift value into an `AnyCodable` structure, /// enabling flexible serialization and dynamic schema support. /// /// - Parameter value: A raw value of any supported type (`Int`, `Bool`, `String`, array, dictionary, etc.). /// - Returns: A wrapped `AnyCodable` version of the input, preserving nested structure. public func wrap(_ value: Any?) -> AnyCodable { if let anyCodable = value as? AnyCodable { return anyCodable } if let dict = value as? [String: AnyCodable] { var result: [String: AnyCodable] = [:] for (key, val) in dict { result[key] = wrap(val) } return AnyCodable(result) } if let dict = value as? [String: Any] { var result: [String: AnyCodable] = [:] for (key, val) in dict { result[key] = wrap(val) } return AnyCodable(result) } if let array = value as? [Any] { return AnyCodable(array.map { wrap($0) }) } return AnyCodable(value) } ================================================ FILE: Sources/ToucanSDK/Utilities/AnyCodable+Json.swift ================================================ // // AnyCodable+Json.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 12.. // import Foundation import ToucanSource public extension AnyCodable { /// Recursively unwraps all nested `AnyCodable` values into Swift-native types, encodable objects are converted into [String: Any] if possible. /// /// - Returns: A Swift-native representation of the value, unwrapped from any nested `AnyCodable` containers. func unboxed(_ encoder: JSONEncoder) -> Any { switch value { case let dict as [String: AnyCodable]: dict.unboxed(encoder) case let array as [[String: AnyCodable]]: array.map { $0.unboxed(encoder) } case let array as [AnyCodable]: array.unboxed(encoder) case let nested as AnyCodable: nested.unboxed(encoder) case let encodable as Encodable: encodable.toJSONDictionary(encoder) ?? value ?? NSNull() default: value ?? NSNull() } } } ================================================ FILE: Sources/ToucanSDK/Utilities/Array+AnyCodable.swift ================================================ // // Array+AnyCodable.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 11.. // import Foundation import ToucanSource public extension [AnyCodable] { /// Returns an array of unboxed elements by applying `unboxed()` to each element in the sequence. /// /// - Returns: An array containing the result of calling `unboxed()` on each element. func unboxed(_ encoder: JSONEncoder) -> [Any] { reduce(into: []) { result, element in result.append(element.unboxed(encoder)) } } } ================================================ FILE: Sources/ToucanSDK/Utilities/ContextKeys.swift ================================================ // // ContextKeys.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 09. 04.. // /// Root-level keys used in the rendered context bundles. public enum RootContextKeys: String, CaseIterable { case page case iterator case context } /// Standard keys included in the page context dictionary. public enum PageContextKeys: String, CaseIterable { case contents case permalink } /// Keys for the `page.contents` dictionary. public enum PageContentsKeys: String, CaseIterable { case html case readingTime case outline } /// Keys for iterator metadata inside the context bundle. public enum IteratorKeys: String, CaseIterable { case total case limit case current case items case links } /// Global keys merged into the rendering context. public enum GlobalContextKeys: String, CaseIterable { case baseUrl case generator case generation case site } /// Front matter keys related to view resolution. public enum ViewFrontMatterKeys: String, CaseIterable { case view case views case any = "views.*" } ================================================ FILE: Sources/ToucanSDK/Utilities/CopyManager.swift ================================================ // // CopyManager.swift // Toucan // // Created by gerp83 on 2025. 04. 17.. // import FileManagerKit import Foundation /// Responsible for copying static assets from various source locations into the working directory. public struct CopyManager { /// File manager abstraction for performing file operations. let fileManager: FileManagerKit /// Source configuration, containing paths to asset directories. let sources: [URL] /// The target directory where all assets should be written. let destination: URL /// Initializes a new asset writer for copying static files. /// /// - Parameters: /// - fileManager: The file system manager. /// - sources: Provides paths based on the source urls. /// - destination: Target directory for copying all assets. public init( fileManager: FileManagerKit, sources: [URL], destination: URL ) { self.fileManager = fileManager self.sources = sources self.destination = destination } /// Copies all default, overridden, and site-level assets into the working directory. /// /// - Throws: Errors from the file system if copying fails. public func copy() throws { for source in sources { try fileManager.copyRecursively( from: source, to: destination ) } } } ================================================ FILE: Sources/ToucanSDK/Utilities/Dictionary+AnyCodable.swift ================================================ // // Dictionary+AnyCodable.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 31.. // import Foundation import ToucanSource public extension [String: AnyCodable] { /// Returns a dictionary with the same keys as the original, where each value has been unwrapped or transformed using the `unboxed` method. /// /// - Returns: A `[String: Any]` dictionary with unboxed values. func unboxed(_ encoder: JSONEncoder) -> [String: Any] { reduce(into: [:]) { result, element in result[element.key] = element.value.unboxed(encoder) } } } ================================================ FILE: Sources/ToucanSDK/Utilities/Encodable+Json.swift ================================================ // // Encodable+Json.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 11.. // import Foundation public extension Encodable { /// Converts the current object to a JSON dictionary using the specified encoder. /// /// - Parameter encoder: The `JSONEncoder` used to encode the object. /// - Returns: A dictionary representation of the object if encoding and serialization succeed; otherwise, `nil`. func toJSONDictionary(_ encoder: JSONEncoder) -> [String: Any]? { do { let data = try encoder.encode(self) let json = try JSONSerialization.jsonObject(with: data, options: []) return json as? [String: Any] } catch { return nil } } } ================================================ FILE: Sources/ToucanSDK/Utilities/FirstSucceeding.swift ================================================ // // FirstSucceeding.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 11.. // /// Attempts to execute a sequence of throwing closures, returning the first non-nil result. /// /// Iterates over the provided array of closures and executes each in order. /// If a closure throws an error, the error is ignored and the next closure is attempted. /// The function returns the first non-nil value produced by a closure, or `nil` if all closures either throw or return nil. /// /// - Parameter blocks: An array of throwing closures that each return an optional value. /// - Returns: The first non-nil result returned by a closure, or `nil` if none succeed. func firstSucceeding(_ blocks: [() throws -> T?]) -> T? { for block in blocks { if let result = try? block() { return result } } return nil } ================================================ FILE: Sources/ToucanSDK/Validators/BuildTargetSourceValidator.swift ================================================ // // BuildTargetSourceValidator.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 23.. // import Foundation import ToucanCore import ToucanSerialization import ToucanSource enum BuildTargetSourceValidatorError: ToucanError { case duplicateContentTypes([String]) case noDefaultContentType case multipleDefaultContentTypes([String]) case duplicatePipelines([String]) case invalidRawContentOriginPath(String) case invalidRawContentOriginSlug(String) case duplicateRawContentSlugs([String]) case duplicateBlocks([String]) case invalidLocale(String) case invalidTimeZone(String) case unknown(Error) var underlyingErrors: [any Error] { switch self { case let .unknown(error): [error] default: [] } } var logMessage: String { switch self { case let .duplicateContentTypes(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate content types: \(items)." case .noDefaultContentType: return "No default content type." case let .multipleDefaultContentTypes(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Multiple default content types: \(items)." case let .duplicatePipelines(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate pipelines: \(items)." case let .invalidRawContentOriginPath(path): return "Invalid path: \(path)." case let .invalidRawContentOriginSlug(slug): return "Invalid slug: \(slug)." case let .duplicateRawContentSlugs(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate slugs: \(items)." case let .duplicateBlocks(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate blocks: \(items)." case let .invalidLocale(locale): return "Invalid site locale: `\(locale)`." case let .invalidTimeZone(timeZone): return "Invalid site time zone: `\(timeZone)`." case let .unknown(error): return error.localizedDescription } } var userFriendlyMessage: String { switch self { case let .duplicateContentTypes(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate content types: \(items)." case .noDefaultContentType: return "No default content type." case let .multipleDefaultContentTypes(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Multiple default content types: \(items)." case let .duplicatePipelines(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate pipelines: \(items)." case let .invalidRawContentOriginPath(path): return "Invalid path: \(path)." case let .invalidRawContentOriginSlug(slug): return "Invalid slug: \(slug)." case let .duplicateRawContentSlugs(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate slugs: \(items)." case let .duplicateBlocks(values): let items = values.map { "`\($0)`" }.joined(separator: ", ") return "Duplicate blocks: \(items)." case let .invalidLocale(locale): return "Invalid site locale: `\(locale)`." case let .invalidTimeZone(timeZone): return "Invalid site time zone: `\(timeZone)`." case .unknown: return "Unknown source validator error." } } } struct BuildTargetSourceValidator { var buildTargetSource: BuildTargetSource func validate() throws(BuildTargetSourceValidatorError) { try validatePipelines() try validateBlocks() try validateContentTypes() try validateRawContentOrigins() try validateRawContents() } func validatePipelines() throws(BuildTargetSourceValidatorError) { let ids = buildTargetSource.pipelines.map(\.id) let duplicates = Dictionary(grouping: ids, by: { $0 }) .mapValues { $0.count } .filter { $1 > 1 } if !duplicates.isEmpty { throw .duplicateContentTypes( duplicates.keys.map { String($0) }.sorted() ) } } func validateBlocks() throws(BuildTargetSourceValidatorError) { let names = buildTargetSource.blocks.map(\.name) let duplicates = Dictionary(grouping: names, by: { $0 }) .mapValues { $0.count } .filter { $1 > 1 } if !duplicates.isEmpty { throw .duplicateContentTypes( duplicates.keys.map { String($0) }.sorted() ) } } func validateContentTypes() throws(BuildTargetSourceValidatorError) { let ids = buildTargetSource.types.map(\.id) let duplicates = Dictionary(grouping: ids, by: { $0 }) .mapValues { $0.count } .filter { $1 > 1 } if !duplicates.isEmpty { throw .duplicateContentTypes( duplicates.keys.map { String($0) }.sorted() ) } let items = buildTargetSource.types.filter(\.default) if items.isEmpty { throw .noDefaultContentType } if items.count > 1 { throw .multipleDefaultContentTypes(items.map(\.id).sorted()) } } func validateRawContentOrigins() throws(BuildTargetSourceValidatorError) { let origins = buildTargetSource.rawContents.map(\.origin) for origin in origins { guard origin.path.value.containsOnlyValidPathCharacters() else { throw .invalidRawContentOriginPath(origin.path.value) } guard origin.slug.containsOnlyValidURLCharacters() else { throw .invalidRawContentOriginSlug(origin.slug) } } } func validateRawContents() throws(BuildTargetSourceValidatorError) { let slugs = buildTargetSource.rawContents.map(\.origin.slug) let duplicates = Dictionary(grouping: slugs, by: { $0 }) .mapValues { $0.count } .filter { $1 > 1 } if !duplicates.isEmpty { throw .duplicateRawContentSlugs( duplicates.keys.map { String($0) }.sorted() ) } } } ================================================ FILE: Sources/ToucanSDK/Validators/TemplateValidator.swift ================================================ // // TemplateValidator.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 06. 17.. // import Foundation import ToucanCore import ToucanSource import Version enum TemplateValidatorError: ToucanError { case unsupportedGeneratorVersion( generatorVersion: Template.Metadata.GeneratorVersion, currentVersion: Version ) var logMessage: String { switch self { case let .unsupportedGeneratorVersion(generatorVersion, currentVersion): return "Unsupported generator version: `\(generatorVersion.type)(\(generatorVersion.value))`. Current Toucan version: \(currentVersion)." } } var userFriendlyMessage: String { logMessage } } struct TemplateValidator { let version: Version init(generatorInfo: GeneratorInfo = .current) throws { self.version = generatorInfo.release } func validate(_ template: Template) throws(TemplateValidatorError) { let generatorVersion = template.metadata.generatorVersion let isSupported: Bool switch generatorVersion.type { case .upNextMajor: let lowerBound = generatorVersion.value let upperBound = Version(generatorVersion.value.major + 1, 0, 0) isSupported = (lowerBound..( _ type: T.Type, from string: String ) throws(ToucanDecoderError) -> T /// Decodes a `Decodable` type from raw data. /// /// - Parameters: /// - type: The expected type to decode (conforms to `Decodable`). /// - from: The raw `Data` input (e.g., file contents as Data). /// - Returns: A decoded instance of the specified type. /// - Throws: `ToucanDecoderError` if decoding fails or data is invalid. func decode( _ type: T.Type, from: Data ) throws(ToucanDecoderError) -> T } public extension ToucanDecoder { /// Decodes a `Decodable` type from raw data. /// /// - Parameters: /// - type: The expected type to decode (conforms to `Decodable`). /// - string: The raw `String` input (e.g., file contents as String). /// - Returns: A decoded instance of the specified type. /// - Throws: `ToucanDecoderError` if decoding fails or data is invalid. func decode( _ type: T.Type, from string: String ) throws(ToucanDecoderError) -> T { guard let data = string.data(using: .utf8) else { throw .init( type: T.self, error: DecodingError.dataCorrupted( .init( codingPath: [], debugDescription: "The string cannot be represented as UTF-8 encoded data." ) ) ) } return try decode(type, from: data) } } ================================================ FILE: Sources/ToucanSerialization/ToucanDecoderError.swift ================================================ // // ToucanDecoderError.swift // Toucan // // Created by gerp83 on 2025. 04. 17.. // import ToucanCore /// Extension providing a custom `logMessage` for decoding context. /// /// Transforms the coding path into a readable string or returns the debug description if the path is empty. extension DecodingError.Context { /// A string representation of the decoding path or debug description. var logMessage: String { var components = [debugDescription] if !codingPath.isEmpty { components.append("Coding path:") let path = codingPath .map(\.stringValue) .joined(separator: ".") components.append("`\(path)`.") } return components.joined(separator: " ") } } /// Extension to make `DecodingError` conform to the `ToucanError` protocol. /// /// Provides developer and user-facing messages based on the type of decoding error. extension DecodingError: ToucanError { /// A detailed message describing the decoding failure, including context. public var logMessage: String { switch self { case let .dataCorrupted(context): "Data corrupted: \(context.logMessage)" case let .keyNotFound(key, context): "Key not found: \(key) - \(context.logMessage)" case let .typeMismatch(type, context): "Type mismatch: \(type) - \(context.logMessage)" case let .valueNotFound(type, context): "Value not found: \(type) - \(context.logMessage)" default: "\(self)" } } /// A localized description suitable for displaying to end users. public var userFriendlyMessage: String { localizedDescription } } /// A custom error type representing a decoding failure for a specific type. /// /// Wraps an optional underlying error and includes type information for logging. public struct ToucanDecoderError: ToucanError { /// The type that failed to decode. let type: Any.Type /// An optional underlying error providing more context. let error: Error? /// An array containing the underlying error, if any. public var underlyingErrors: [any Error] { error.map { [$0] } ?? [] } /// A developer-facing message indicating which type failed to decode. public var logMessage: String { "Type decoding error: `\(type)`." } /// A user-friendly message indicating a decoding failure. public var userFriendlyMessage: String { "Could not decode object." } /// Creates a new `ToucanDecoderError`. /// /// - Parameters: /// - type: The type that failed decoding. /// - error: An optional underlying error. init( type: Any.Type, error: Error? = nil ) { self.type = type self.error = error } } ================================================ FILE: Sources/ToucanSerialization/ToucanEncoder.swift ================================================ // // ToucanEncoder.swift // Toucan // // Created by Tibor Bödecs on 2025. 03. 06.. // import struct Foundation.Data /// A protocol representing a custom encoder that serializes `Encodable` types into `String` output. public protocol ToucanEncoder { /// Encodes an object conforming to `Encodable` into a `String` representation. /// /// - Parameter object: The value to encode. /// - Returns: A serialized string output. /// - Throws: `ToucanEncoderError` if encoding fails. func encode( _ object: some Encodable ) throws(ToucanEncoderError) -> String /// Encodes an object conforming to `Encodable` into a `Data` representation. /// /// - Parameter object: The value to encode. /// - Returns: The Data representation of the Encodable. /// - Throws: `ToucanEncoderError` if encoding fails. func encode( _ object: some Encodable ) throws(ToucanEncoderError) -> Data } public extension ToucanEncoder { /// Encodes an object conforming to `Encodable` into a `Data` representation. /// /// - Parameter object: The value to encode. /// - Returns: The Data representation of the Encodable. /// - Throws: `ToucanEncoderError` if encoding fails. func encode( _ object: T ) throws(ToucanEncoderError) -> Data { let string: String = try encode(object) guard let data = string.data(using: .utf8) else { throw ToucanEncoderError( type: T.self, error: EncodingError.invalidValue( string, .init( codingPath: [], debugDescription: "The string cannot be represetned as UTF-8 encoded data." ) ) ) } return data } } ================================================ FILE: Sources/ToucanSerialization/ToucanEncoderError.swift ================================================ // // ToucanEncoderError.swift // Toucan // // Created by gerp83 on 2025. 04. 17.. // import ToucanCore /// Extension to make `EncodingError` conform to the `ToucanError` protocol. /// /// Provides developer and user-facing messages based on the encoding error. extension EncodingError: ToucanError { /// A detailed log message representing the encoding error. public var logMessage: String { "\(self)" } /// A localized description suitable for display to end users. public var userFriendlyMessage: String { localizedDescription } } /// A custom error type representing a failure to encode a specific type. /// /// Wraps an optional underlying error and includes the associated type information. public struct ToucanEncoderError: ToucanError { /// The type that failed to encode. let type: Any.Type /// An optional underlying error providing additional context. let error: Error? /// An array containing the underlying error, if present. public var underlyingErrors: [any Error] { error.map { [$0] } ?? [] } /// A developer-facing message describing the type that failed to encode. public var logMessage: String { "Type encoding error: `\(type)`." } /// A user-facing message indicating that the object could not be encoded. public var userFriendlyMessage: String { "Could not encode object." } /// Creates a new `ToucanEncoderError`. /// /// - Parameters: /// - type: The type that failed encoding. /// - error: An optional underlying error. init( type: Any.Type, error: Error? = nil ) { self.type = type self.error = error } } ================================================ FILE: Sources/ToucanSerialization/ToucanJSONDecoder.swift ================================================ // // ToucanJSONDecoder.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import struct Foundation.Data import class Foundation.JSONDecoder /// An implementation of `ToucanDecoder` that uses `JSONDecoder`. public struct ToucanJSONDecoder: ToucanDecoder { /// Initializes a new instance of `ToucanJSONDecoder`. /// /// Uses a `JSONDecoder` that allows JSON5 parsing by default. public init() {} /// Decodes a JSON or JSON5-encoded `Data` object into a strongly-typed model. /// /// - Parameters: /// - type: The target `Decodable` type. /// - data: Raw data to decode. /// - Returns: A decoded instance of the provided type. /// - Throws: `ToucanDecoderError.decoding` if decoding fails. public func decode( _ type: T.Type, from data: Data ) throws(ToucanDecoderError) -> T { do { let decoder = JSONDecoder() decoder.allowsJSON5 = true return try decoder.decode(type, from: data) } catch { throw .init(type: T.self, error: error) } } } ================================================ FILE: Sources/ToucanSerialization/ToucanJSONEncoder.swift ================================================ // // ToucanJSONEncoder.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import struct Foundation.Data import class Foundation.JSONEncoder /// An implementation of `ToucanEncoder` that uses JSON`. public struct ToucanJSONEncoder: ToucanEncoder { /// Initializes a new instance of the JSON encoder. public init() {} /// Encodes a given `Encodable` object into a JSON `String`. /// /// - Parameter object: The value to encode. /// - Returns: A YAML-formatted string representation of the object. /// - Throws: `ToucanEncoderError.encoding` if encoding fails. public func encode( _ object: T ) throws(ToucanEncoderError) -> String { do { let encoder = JSONEncoder() encoder.outputFormatting = [ .sortedKeys, .prettyPrinted, .withoutEscapingSlashes, ] let data = try encoder.encode(object) guard let string = String(data: data, encoding: .utf8) else { throw ToucanEncoderError( type: T.self, error: EncodingError.invalidValue( data, .init( codingPath: [], debugDescription: "The data cannot be represetned as UTF-8 encoded string." ) ) ) } return string } catch { throw .init(type: T.self, error: error) } } } ================================================ FILE: Sources/ToucanSerialization/ToucanYAMLDecoder.swift ================================================ // // ToucanYAMLDecoder.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import struct Foundation.Data import class Yams.YAMLDecoder /// An implementation of `ToucanDecoder` that uses `YAMLDecoder`. public struct ToucanYAMLDecoder: ToucanDecoder { /// Creates a new YAML decoder instance for use in the Toucan system. public init() {} /// Decodes a YAML-formatted `Data` object into a strongly typed model. /// /// - Parameters: /// - type: The expected `Decodable` type. /// - data: The raw YAML data to decode. /// - Returns: A decoded instance of the specified type. /// - Throws: `ToucanDecoderError.decoding` if the input cannot be decoded. public func decode( _ type: T.Type, from data: Data ) throws(ToucanDecoderError) -> T { do { let decoder = YAMLDecoder() return try decoder.decode(type, from: data) } catch { throw .init(type: T.self, error: error) } } } ================================================ FILE: Sources/ToucanSerialization/ToucanYAMLEncoder.swift ================================================ // // ToucanYAMLEncoder.swift // Toucan // // Created by Tibor Bödecs on 2025. 03. 06.. // import struct Foundation.Data import class Yams.YAMLEncoder /// A n implementation of `ToucanEncoder` that uses `YAMLEncoder`. public struct ToucanYAMLEncoder: ToucanEncoder { /// Initializes a new instance of the YAML encoder. public init() {} /// Encodes a given `Encodable` object into a YAML `String`. /// /// - Parameter object: The value to encode. /// - Returns: A YAML-formatted string representation of the object. /// - Throws: `ToucanEncoderError.encoding` if encoding fails. public func encode( _ object: T ) throws(ToucanEncoderError) -> String { do { let encoder = YAMLEncoder() encoder.options.sortKeys = true return try encoder.encode(object) } catch { throw .init(type: T.self, error: error) } } } ================================================ FILE: Sources/ToucanSource/Errors/ObjectLoaderError.swift ================================================ // // ObjectLoaderError.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import struct Foundation.URL import ToucanCore /// A custom error type representing a failure to load or decode a file. /// /// Wraps the file URL and an optional underlying error for context and debugging. public struct ObjectLoaderError: ToucanError { /// The URL of the file that caused the error. let url: URL /// The underlying error that occurred during loading or decoding. let error: Error? /// An array containing the underlying error if present. public var underlyingErrors: [Error] { error.map { [$0] } ?? [] } /// A developer-facing log message including the path of the failed file. public var logMessage: String { "File issue at: `\(url.path())`." } /// A user-facing message indicating a loading failure. public var userFriendlyMessage: String { "Could not load object." } /// Initializes a new `ObjectLoaderError`. /// /// - Parameters: /// - url: The URL of the file involved in the error. /// - error: An optional underlying error. init( url: URL, error: Error? = nil ) { self.url = url self.error = error } } ================================================ FILE: Sources/ToucanSource/Errors/SourceLoaderError.swift ================================================ // // SourceLoaderError.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import ToucanCore /// A custom error type representing failures during the source loading process. /// /// Wraps the type of failure and an optional underlying error for context. public struct SourceLoaderError: ToucanError { /// A string representing the type of component that failed to load. let type: String /// An optional error providing additional context about the failure. let error: Error? /// An array containing the underlying error if available, used for nested error representation. public var underlyingErrors: [Error] { error.map { [$0] } ?? [] } /// A developer-facing message indicating the type that failed to load. public var logMessage: String { "Could not load: `\(type)`." } /// A user-facing message indicating a generic failure to load source content. public var userFriendlyMessage: String { "Could not load source." } /// Initializes a new `SourceLoaderError`. /// /// - Parameters: /// - type: A string indicating the failed component type. /// - error: An optional error that triggered the failure. init( type: String, error: Error? = nil ) { self.type = type self.error = error } } ================================================ FILE: Sources/ToucanSource/Errors/TemplateLoaderError.swift ================================================ // // TemplateLoaderError.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import ToucanCore /// A custom error type representing failures during the source loading process. /// /// Wraps the type of failure and an optional underlying error for context. public struct TemplateLoaderError: ToucanError { /// A string representing the type of component that failed to load. let type: String /// An optional error providing additional context about the failure. let error: Error? /// An array containing the underlying error if available, used for nested error representation. public var underlyingErrors: [Error] { error.map { [$0] } ?? [] } /// A developer-facing message indicating the type that failed to load. public var logMessage: String { "Could not load: `\(type)`." } /// A user-facing message indicating a generic failure to load source content. public var userFriendlyMessage: String { "Could not load template metadata." } /// Initializes a new `SourceLoaderError`. /// /// - Parameters: /// - type: A string indicating the failed component type. /// - error: An optional error that triggered the failure. init( type: String, error: Error? = nil ) { self.type = type self.error = error } } ================================================ FILE: Sources/ToucanSource/Extensions/Decoder+Validate.swift ================================================ // // Decoder+Validate.swift // Toucan // // Created by Ferenc Viasz-Kadi on 2025. 08. 19.. // extension Decoder { /// Validates that the top-level decoded object contains no unknown keys outside the given `CodingKey` set. /// /// This method inspects the raw decoded object as a `[String: AnyCodable]` dictionary and compares its keys /// against the expected cases defined in the provided `CodingKey` type. If any extra keys are found that are /// not part of the enum, it throws a `DecodingError.dataCorruptedError`. /// /// - Parameter keyType: A `CodingKey` type that conforms to `CaseIterable`. This type defines the set of known/expected keys. /// - Throws: A `DecodingError.dataCorruptedError` if unexpected keys are found in the decoded object. public func validateUnknownKeys( keyType: K.Type ) throws { guard let _ = try? container(keyedBy: keyType) else { return } // Decode raw dictionary let raw = try singleValueContainer().decode([String: AnyCodable].self) let expectedKeys = Set(K.allCases.map { $0.stringValue }) let actualKeys = Set(raw.keys) let unknownKeys = actualKeys.subtracting(expectedKeys) if !unknownKeys.isEmpty { let inputKeys = unknownKeys .sorted() .map { "`\($0)`" } .joined(separator: ", ") let expectedKeys = expectedKeys .sorted() .map { "`\($0)`" } .joined(separator: ", ") throw DecodingError.dataCorrupted( .init( codingPath: codingPath, debugDescription: "Unknown keys found: \(inputKeys). Expected keys: \(expectedKeys)." ) ) } } } ================================================ FILE: Sources/ToucanSource/Extensions/Dictionary+AnyCodable.swift ================================================ // // Dictionary+AnyCodable.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import struct Foundation.Date import class Foundation.DateFormatter public extension [String: AnyCodable] { func value(_ keyPath: String, as _: T.Type) -> T? { let keys = keyPath.split(separator: ".").map { String($0) } guard !keys.isEmpty else { return nil } var currentDict: [String: AnyCodable] = self for key in keys.dropLast() { if let dict = currentDict[key]?.value as? [String: AnyCodable] { currentDict = dict } else { return nil } } return currentDict[keys.last!]?.value as? T } func bool(_ keyPath: String) -> Bool? { value(keyPath, as: Bool.self) } func int(_ keyPath: String) -> Int? { value(keyPath, as: Int.self) } func double(_ keyPath: String) -> Double? { value(keyPath, as: Double.self) } func string( _ keyPath: String, allowingEmptyValue: Bool = false ) -> String? { let result = value(keyPath, as: String.self) if allowingEmptyValue { return result } return (result ?? "").isEmpty ? nil : result } func array(_ keyPath: String, as _: T.Type) -> [T] { value(keyPath, as: [T].self) ?? [] } func dict(_ keyPath: String) -> [String: AnyCodable] { value(keyPath, as: [String: AnyCodable].self) ?? [:] } func date(_ keyPath: String, formatter: DateFormatter) -> Date? { guard let rawDate = value(keyPath, as: String.self) else { return nil } return formatter.date(from: rawDate) } } ================================================ FILE: Sources/ToucanSource/Extensions/FileManagerKit+Extensions.swift ================================================ // // FileManagerKit+Extensions.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import FileManagerKit import struct Foundation.URL private extension URL { /// Computes a relative path from the current URL (`self`) to another base URL. /// /// This method compares the standardized path components of both URLs, /// identifies their shared prefix, and removes it from the current URL path /// to return a relative path string. /// /// - Parameter url: The base URL to which the path should be made relative. /// - Returns: A relative path string from `url` to `self`. func relativePath(to url: URL) -> String { // Break both paths into components (standardized removes '.', '..', etc.) let components = standardized.pathComponents let baseComponents = url.standardized.pathComponents // Determine how many leading components are shared between both paths let commonPrefixCount = zip(components, baseComponents) .prefix { $0 == $1 } .count // Remove the common prefix to compute the relative path let relativeComponents = components.dropFirst(commonPrefixCount) // Join the remaining components with "/" to form the relative path return relativeComponents.joined(separator: "/") } } public extension FileManagerKit { /// Find files in the specified directory that match the given name and extensions criteria. /// /// - Parameters: url: The URL of the directory to search. /// - Returns: An array of file names that match the specified criteria. func find( name: String? = nil, extensions: [String]? = nil, recursively: Bool = false, skipHiddenFiles: Bool = true, at url: URL ) -> [String] { var items: [String] = [] if recursively { items = listDirectoryRecursively(at: url) .map { // Convert to a relative path based on the root URL $0.relativePath(to: url) } } else { items = listDirectory(at: url) } if skipHiddenFiles { items = items.filter { !$0.hasPrefix(".") } } return items.filter { fileName in let fileURL = URL(fileURLWithPath: fileName) let baseName = fileURL.deletingPathExtension().lastPathComponent let ext = fileURL.pathExtension switch (name, extensions) { case (nil, nil): return true case (let name?, nil): return baseName == name case (nil, let extensions?): return extensions.contains(ext) case let (name?, extensions?): return baseName == name && extensions.contains(ext) } } } } ================================================ FILE: Sources/ToucanSource/Loaders/BuildTargetSourceLoader.swift ================================================ // // BuildTargetSourceLoader.swift // Toucan // // Created by Tibor Bödecs on 2025. 04. 04.. // import FileManagerKit import Foundation import Logging import ToucanCore import ToucanSerialization /// Loads and processes various parts of a build target's source bundle. /// /// Uses dependency-injected tools to fetch, decode, and construct structured data from source files. public struct BuildTargetSourceLoader { /// The URL of the root source directory. var sourceURL: URL /// Metadata describing the current build target. var target: Target /// A utility for accessing and searching the file system. var fileManager: FileManagerKit /// Encoder and decoder for serializing and deserializing content. var encoder: ToucanEncoder var decoder: ToucanDecoder /// Logger instance for emitting structured debug information. var logger: Logger /// Initializes a new instance of `BuildTargetSourceLoader`. /// /// - Parameters: /// - sourceURL: The root directory containing source files. /// - target: The build target metadata. /// - fileManager: File system access helper. /// - encoder: The encoder used for serialization. /// - decoder: The decoder used for deserialization. /// - logger: Optional logger for debugging and diagnostics. public init( sourceURL: URL, target: Target, fileManager: FileManagerKit, encoder: ToucanEncoder, decoder: ToucanDecoder, logger: Logger = .subsystem("source-loader") ) { self.sourceURL = sourceURL self.target = target self.fileManager = fileManager self.encoder = encoder self.decoder = decoder self.logger = logger } /// Loads raw contents from the source using the provided configuration. /// /// - Parameter config: The configuration object used to determine content locations. /// - Returns: An array of `RawContent` objects. /// - Throws: A `SourceLoaderError` if loading fails. private func loadRawContents( using config: Config ) throws(SourceLoaderError) -> [RawContent] { do { let locations = BuiltTargetSourceLocations( sourceURL: sourceURL, config: config ) let rawContentsLoader = RawContentLoader( contentsURL: locations.contentsURL, assetsPath: config.contents.assets.path, decoder: .init(), markdownParser: .init(decoder: decoder), fileManager: fileManager ) return try rawContentsLoader.load() } catch { throw .init(type: "RawContent", error: error) } } /// Loads a single Codable object of the specified type from a named file at a given URL. /// /// - Parameters: /// - type: The type to decode. /// - name: The name of the file to load. /// - url: The directory URL to search within. /// - Returns: An instance of the decoded type. /// - Throws: A `SourceLoaderError` if loading or decoding fails. private func load( type: T.Type, named name: String, at url: URL ) throws(SourceLoaderError) -> T { do { return try ObjectLoader( url: url, locations: fileManager.find( name: name, extensions: ["yaml", "yml"], at: url ), encoder: encoder, decoder: decoder ) .load(type) } catch { throw .init(type: "ObjectLoader", error: error) } } /// Loads an array of Decodable objects of the specified type from YAML files at a given URL. /// /// - Parameters: /// - type: The type to decode. /// - url: The directory URL to search within. /// - Returns: An array of decoded objects. /// - Throws: A `SourceLoaderError` if loading or decoding fails. private func load( type: T.Type, at url: URL ) throws(SourceLoaderError) -> [T] { do { return try ObjectLoader( url: url, locations: fileManager.find( extensions: ["yaml", "yml"], at: url ), encoder: encoder, decoder: decoder ) .load(type) } catch { throw .init(type: "\(type)", error: error) } } /// Loads the main configuration object from the source. /// /// The loader first attempts to locate a configuration file named `config-{target.name}` at the computed source location. /// - If the file exists, it attempts to parse and load it. If parsing fails, a `SourceLoaderError` is thrown. /// - If the file does not exist, the loader falls back to load the default `config` file from the same location. /// - If neither configuration file can be successfully loaded, a `SourceLoaderError` is thrown with detailed context. /// /// - Returns: A `Config` object loaded from the source. /// - Throws: A `SourceLoaderError` if loading fails or parsing the located configuration file fails. func loadConfig() throws(SourceLoaderError) -> Config { do { let configURL = sourceURL.appendingPathIfPresent(target.config) let targetConfigName = "config-\(target.name)" let targetConfigLocation = fileManager .find(extensions: ["yaml", "yml"], at: configURL) .first { $0.hasPrefix(targetConfigName) } if targetConfigLocation != nil { return try load( type: Config.self, named: targetConfigName, at: configURL ) } return try load( type: Config.self, named: "config", at: configURL ) } catch { throw .init(type: "Config", error: error) } } /// Constructs the locations object based on the configuration. /// /// - Parameter config: The loaded configuration. /// - Returns: A `BuiltTargetSourceLocations` instance. func getLocations( using config: Config ) -> BuiltTargetSourceLocations { .init( sourceURL: sourceURL, config: config ) } /// Loads the site settings from the specified locations. /// /// - Parameter locations: The source locations to use. /// - Returns: A `Settings` object. /// - Throws: A `SourceLoaderError` if loading fails. func loadSettings( using locations: BuiltTargetSourceLocations ) throws(SourceLoaderError) -> Settings { do { return try load( type: Settings.self, named: "site", at: locations.siteSettingsURL ) } catch { throw .init(type: "Settings", error: error) } } /// Loads pipeline definitions from the specified locations. /// /// - Parameter locations: The source locations to use. /// - Returns: An array of `Pipeline` objects. /// - Throws: A `SourceLoaderError` if loading fails. func loadPipelines( using locations: BuiltTargetSourceLocations ) throws(SourceLoaderError) -> [Pipeline] { try load( type: Pipeline.self, at: locations.pipelinesURL ) .sorted { $0.id < $1.id } } /// Loads content types /// /// - Parameter locations: The source locations to use. /// - Returns: An array of `ContentType` objects. /// - Throws: A `SourceLoaderError` if loading fails. func loadTypes( using locations: BuiltTargetSourceLocations ) throws(SourceLoaderError) -> [ContentType] { try load( type: ContentType.self, at: locations.typesURL ) .sorted { $0.id < $1.id } } /// Loads block directives from the specified locations. /// /// - Parameter locations: The source locations to use. /// - Returns: An array of `Block` objects. /// - Throws: A `SourceLoaderError` if loading fails. func loadBlocks( using locations: BuiltTargetSourceLocations ) throws(SourceLoaderError) -> [Block] { try load( type: Block.self, at: locations.blocksURL ) .sorted { $0.name < $1.name } } /// Loads and processes source content from the specified source URL. /// This function retrieves configuration, settings, content definitions, block directives, /// and raw contents, then transforms them into structured content. /// /// - Returns: A `BuildTargetSource` containing the loaded and processed data. /// - Throws: An error if any of the loading operations fail. public func load() throws(SourceLoaderError) -> BuildTargetSource { let config = try loadConfig() let locations = getLocations(using: config) let settings = try loadSettings(using: locations) let pipelines = try loadPipelines(using: locations) let types = try loadTypes(using: locations) let blocks = try loadBlocks(using: locations) let rawContents = try loadRawContents(using: config) return .init( locations: locations, target: target, config: config, settings: settings, pipelines: pipelines, types: types, rawContents: rawContents, blockDirectives: blocks ) } } ================================================ FILE: Sources/ToucanSource/Loaders/ObjectLoader.swift ================================================ // // ObjectLoader.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 16.. // import Foundation import Logging import ToucanCore import ToucanSerialization /// A utility to load and decode objects from files using a specified set of encoders and decoders. public struct ObjectLoader { /// The base directory where the files are located. let url: URL /// A list of relative paths (from `url`) to the files to be loaded. let locations: [String] /// An encoder used for interpreting Swift models into data. let encoder: ToucanEncoder /// A decoder used for interpreting data into Swift models. let decoder: ToucanDecoder /// Logger instance for emitting debug output during loading. let logger: Logger /// Initializes a new `ObjectLoader` instance. /// /// - Parameters: /// - url: The base directory of the files. /// - locations: A list of relative paths to the files. /// - encoder: Encoder for serializing intermediate data. /// - decoder: Decoder for parsing file contents into models. /// - logger: Optional logger for debugging purposes. public init( url: URL, locations: [String], encoder: ToucanEncoder, decoder: ToucanDecoder, logger: Logger = .subsystem("object-loader") ) { self.url = url self.locations = locations self.encoder = encoder self.decoder = decoder self.logger = logger } /// Loads and decodes each file separately into an array of the specified type. /// /// - Parameter value: The `Codable` type to decode each file into. /// - Returns: An array of decoded objects. /// - Throws: An `ObjectLoaderError` if reading or decoding any file fails. public func load( _ value: T.Type ) throws(ObjectLoaderError) -> [T] { logger.debug( "Loading each \(type(of: value)) files (\(locations)) at: `\(url.absoluteString)`" ) var lastURL: URL? var result: [T] = [] do { for location in locations { let fileURL = url.appendingPathIfPresent(location) lastURL = fileURL let data = try Data(contentsOf: fileURL) let decoded = try decoder.decode(T.self, from: data) result.append(decoded) } } catch { throw .init( url: lastURL ?? url, error: error ) } return result } /// Loads, merges, and decodes multiple files into a single instance of the specified type. /// /// - Parameter value: The `Codable` type to decode the combined YAML data into. /// - Returns: A decoded object of the specified type. /// - Throws: An `ObjectLoaderError` if reading, merging, or decoding fails. public func load( _ value: T.Type ) throws(ObjectLoaderError) -> T { logger.debug( "Loading and combining \(type(of: value)) files (\(locations)) at: `\(url.absoluteString)`" ) var lastURL: URL? var combinedRawCodableObject: [String: AnyCodable] = [:] do { for location in locations { let fileURL = url.appendingPathIfPresent(location) lastURL = fileURL let data = try Data(contentsOf: fileURL) let decoded = try decoder.decode( [String: AnyCodable].self, from: data ) combinedRawCodableObject = combinedRawCodableObject.recursivelyMerged(with: decoded) } // TODO: Tries to decode 0 files too let data: Data = try encoder.encode(combinedRawCodableObject) return try decoder.decode(T.self, from: data) } catch { throw .init( url: lastURL ?? url, error: error ) } } } ================================================ FILE: Sources/ToucanSource/Loaders/RawContentLoader.swift ================================================ // // RawContentLoader.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 03.. // import FileManagerKit import Foundation import Logging import ToucanCore import ToucanSerialization /// A utility structure responsible for loading and parsing raw content files public struct RawContentLoader { /// Source configuration. let contentsURL: URL /// The relative path where asset files are expected to be found. let assetsPath: String /// Decoder used to decode YAML files. let decoder: ToucanYAMLDecoder /// A parser responsible for processing front matter data. let markdownParser: MarkdownParser /// A file manager instance for handling file operations. let fileManager: FileManagerKit /// The logger instance let logger: Logger /// Creates a new instance of `RawContentLoader` with the provided dependencies. /// /// - Parameters: /// - contentsURL: The base URL where content files are located. /// - assetsPath: The relative path to the directory containing asset files. /// - decoder: A decoder used to parse YAML content from files. /// - markdownParser: A parser used to extract front matter and body from Markdown files. /// - fileManager: An instance responsible for file system operations. /// - logger: A logger instance used for recording diagnostic messages. Defaults to a subsystem-specific logger. public init( contentsURL: URL, assetsPath: String, decoder: ToucanYAMLDecoder, markdownParser: MarkdownParser, fileManager: FileManagerKit, logger: Logger = .subsystem("raw-content-loader") ) { self.contentsURL = contentsURL self.assetsPath = assetsPath self.decoder = decoder self.markdownParser = markdownParser self.fileManager = fileManager self.logger = logger } /// Recursively finds all assets in the given directory. /// /// - Parameter url: The URL of the directory to search. /// - Returns: A sorted list of relative asset file paths. private func locateAssets( at url: URL ) -> [String] { fileManager.find(recursively: true, at: url).sorted() } /// Recursively traverses the content directory to locate index-based content definitions. /// /// - Parameters: /// - contentsURL: The base directory for contents. /// - slug: The accumulated slug segments (used to form the output slug). /// - path: The accumulated path segments (used to navigate the file system). /// - Returns: A list of discovered `RawContentLocation` objects. private func locateRawContentsOrigins( at contentsURL: URL, slug: [String] = [], path: [String] = [] ) -> [Origin] { var result: [Origin] = [] let currentPath = Path(path.joined(separator: "/")) let currentSlug = Path(slug.joined(separator: "/")) .trimmingBracketsContent() let currentURL = contentsURL.appendingPathIfPresent(currentPath.value) logger.trace( "Trying to locate raw content item.", metadata: [ "contentsURL": .string(contentsURL.absoluteString), "path": .string(currentPath.value), "slug": .string(currentSlug), ] ) if hasIndex(at: currentURL) { let origin = Origin( path: currentPath, slug: currentSlug ) logger.debug( "Raw content item found with index.", metadata: [ "contentsURL": .string(contentsURL.absoluteString), "path": .string(currentPath.value), "slug": .string(currentSlug), ] ) result.append(origin) } let list = fileManager.listDirectory(at: currentURL) for item in list { var newSlug = slug let newPath = path + [item] let childURL = currentURL.appendingPathIfPresent(item) if !hasNoIndex(item: item, at: childURL) { logger.trace( "Raw content item has no index file or bracket.", metadata: [ "contentsURL": .string(contentsURL.absoluteString), "path": .string(currentPath.value), "slug": .string(currentSlug), ] ) newSlug += [item] } result += locateRawContentsOrigins( at: contentsURL, slug: newSlug, path: newPath ) } return result } // MARK: - index helpers /// Finds index files within a directory. /// /// - Parameter url: The directory URL to search in. /// - Returns: A list of index filenames matching supported extensions. private func getIndexes( at url: URL ) -> [String] { fileManager.find( name: "index", extensions: ["yaml", "yml", "markdown", "md"], at: url ) } /// Checks whether a given directory contains index files. /// /// - Parameter url: The directory URL to check. /// - Returns: `true` if index files exist, `false` otherwise. private func hasIndex( at url: URL ) -> Bool { !getIndexes(at: url).isEmpty } /// Determines if a directory should be excluded based on a noindex marker or name brackets. /// /// - Parameters: /// - item: The name of the directory. /// - url: The URL of the directory. /// - Returns: `true` if the directory should be skipped, `false` otherwise. private func hasNoIndex( item: String, at url: URL ) -> Bool { // Skip folders that have a noindex file or bracket marker let noindexFilePaths = fileManager.find( name: "noindex", extensions: ["yaml", "yml"], at: url ) let decodedItem = item.removingPercentEncoding ?? "" let skip = decodedItem.hasPrefix("[") && decodedItem.hasSuffix("]") return skip || !noindexFilePaths.isEmpty } // MARK: - file load /// Loads the contents of a file as a UTF-8 string. /// /// - Parameter url: The URL of the file to read. /// - Returns: The file content as a string. /// - Throws: An error if the file cannot be read. private func loadContentsOfFile( at url: URL ) throws -> String { try String(contentsOf: url, encoding: .utf8) } /// Loads and parses a Markdown file, extracting front matter and content. /// /// - Parameter url: The URL of the Markdown file. /// - Returns: A `Markdown` object with parsed content. /// - Throws: An error if the file cannot be read or parsed. private func loadMarkdownFile( at url: URL ) throws -> Markdown { let rawMarkdown = try loadContentsOfFile(at: url) return try markdownParser.parse(rawMarkdown) } /// Loads and decodes a YAML file into a dictionary. /// /// - Parameter url: The URL of the YAML file. /// - Returns: A dictionary of key-value pairs from the YAML content. /// - Throws: An error if the file cannot be read or decoded. private func loadYAMLFile( at url: URL ) throws -> [String: AnyCodable] { let rawContents = try loadContentsOfFile(at: url) return try decoder.decode([String: AnyCodable].self, from: rawContents) } // MARK: - locate /// Locates all raw content entries under a specified base URL. /// /// Each entry is derived from a folder containing one or more valid index files (Markdown/YAML). /// Subdirectories marked with `noindex.yaml|yml` are skipped. /// /// - Returns: A list of `Origin` objects, sorted by slug. func locateOrigins() -> [Origin] { locateRawContentsOrigins( at: contentsURL ) .sorted { $0.slug < $1.slug } } /// Loads a single raw content item from the specified origin. /// /// - Parameter origin: The origin metadata from which to load the content. /// - Returns: A populated `RawContent` instance. /// - Throws: An error if the content cannot be loaded or parsed. func loadRawContent( at origin: Origin ) throws -> RawContent { var frontMatter: [String: AnyCodable] = [:] var contents = "" var lastModificationDate: Date? let currentURL = contentsURL.appendingPathIfPresent( origin.path.value ) let indexFiles = getIndexes(at: currentURL).sorted() for indexFile in indexFiles { let indexURL = currentURL.appendingPathIfPresent(indexFile) if let existingDate = lastModificationDate { lastModificationDate = try max( existingDate, fileManager.modificationDate(at: indexURL) ) } else { lastModificationDate = try fileManager.modificationDate( at: indexURL ) } switch true { case indexFile.hasSuffix("markdown"), indexFile.hasSuffix("md"): logger.trace( "Loading index Markdown file", metadata: [ "path": .string(origin.path.value), "slug": .string(origin.slug), "file": .string(indexFile), ] ) let markdown = try loadMarkdownFile(at: indexURL) frontMatter = frontMatter.recursivelyMerged( with: markdown.frontMatter ) contents = markdown.contents case indexFile.hasSuffix("yaml"), indexFile.hasSuffix("yml"): logger.trace( "Loading index YAML file", metadata: [ "path": .string(origin.path.value), "slug": .string(origin.slug), "file": .string(indexFile), ] ) let yaml = try loadYAMLFile(at: indexURL) frontMatter = frontMatter.recursivelyMerged(with: yaml) default: logger.warning( "The content has no index file.", metadata: [ "path": .string(origin.path.value), "slug": .string(origin.slug), ] ) continue } } let modificationDate = lastModificationDate ?? Date() let assetsURL = currentURL.appendingPathIfPresent(assetsPath) let assets = locateAssets(at: assetsURL) return RawContent( origin: origin, markdown: .init( frontMatter: frontMatter, contents: contents ), lastModificationDate: modificationDate.timeIntervalSince1970, assetsPath: assetsPath, assets: assets.sorted() ) } /// Loads raw content items from a set of predefined locations. /// /// This function iterates over a collection of locations, resolves each into a `RawContent` item, /// and collects them into an array. /// /// - Returns: An array of `RawContent` objects representing the loaded items. /// - Throws: An error if any of the content items cannot be resolved. public func load() throws -> [RawContent] { logger.debug( "Loading raw contents.", metadata: [ "path": .string(contentsURL.path()) ] ) let origins = locateOrigins() return try origins.map { return try loadRawContent(at: $0) } } } ================================================ FILE: Sources/ToucanSource/Loaders/TemplateLoader.swift ================================================ // // TemplateLoader.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import FileManagerKit import struct Foundation.URL import ToucanSerialization import Logging import ToucanCore /// A loader responsible for building a `Template` by collecting assets and templates from various locations. public struct TemplateLoader { /// The file system locations relevant to the template loading process. let locations: BuiltTargetSourceLocations /// The list of file extensions considered as templates. let extensions: [String] /// The file manager utility used to search and retrieve files. let fileManager: FileManagerKit let encoder: ToucanEncoder let decoder: ToucanDecoder let logger: Logger /// Initializes a new instance for handling template rendering with the given configuration. /// /// - Parameters: /// - locations: A set of built target source locations where templates are located. /// - extensions: An optional list of file extensions to be considered as templates. Defaults to ["mustache", "html"]. /// - fileManager: File manager instance to access the file system. /// - encoder: Encoder used to serialize data for templates. /// - decoder: Decoder used to deserialize data for templates. /// - logger: Logger instance for logging template operations. public init( locations: BuiltTargetSourceLocations, extensions: [String] = ["mustache", "html"], fileManager: FileManagerKit, encoder: ToucanEncoder, decoder: ToucanDecoder, logger: Logger = .subsystem("template-loader") ) { self.locations = locations self.extensions = extensions self.fileManager = fileManager self.encoder = encoder self.decoder = decoder self.logger = logger } func loadView( at url: URL, path: String, isContentOverride: Bool = false ) throws -> View { let basePath = path .split(separator: ".") .dropLast() .joined(separator: ".") let id = if isContentOverride { basePath .split(separator: "/") .last.map(String.init) ?? "" } else { basePath.replacing("/", with: ".") } let contents = try String( contentsOf: url.appendingPathIfPresent(path), encoding: .utf8 ) return .init( id: id, path: path, contents: contents ) } func loadTemplateMetadata( at url: URL ) throws(TemplateLoaderError) -> Template.Metadata { do { return try ObjectLoader( url: url, locations: fileManager.find( name: "template", extensions: ["yaml", "yml"], at: url ), encoder: encoder, decoder: decoder ) .load(Template.Metadata.self) } catch { throw .init(type: "\(Template.Metadata.self)", error: error) } } /// /// Loads and builds a `Template` by collecting assets and templates from predefined locations. /// /// - Returns: A fully constructed `Template` instance. /// - Throws: An error if file discovery fails. public func load() throws -> Template { let assets = fileManager.find( recursively: true, at: locations.currentTemplateAssetsURL ) let templates = fileManager.find( extensions: extensions, recursively: true, at: locations.currentTemplateViewsURL ) let assetOverrides = fileManager.find( recursively: true, at: locations.currentTemplateAssetOverridesURL ) let templateOverrides = fileManager.find( extensions: extensions, recursively: true, at: locations.currentTemplateViewsOverridesURL ) let contentAssetOverrides = fileManager.find( recursively: true, at: locations.siteAssetsURL ) let contentTemplateOverrides = fileManager.find( extensions: extensions, recursively: true, at: locations.contentsURL ) let metadata = try loadTemplateMetadata( at: locations.currentTemplateURL ) let template = try Template( metadata: metadata, components: .init( assets: assets, views: templates.map { try loadView( at: locations.currentTemplateViewsURL, path: $0 ) } ), overrides: .init( assets: assetOverrides, views: templateOverrides.map { try loadView( at: locations.currentTemplateViewsOverridesURL, path: $0 ) } ), content: .init( assets: contentAssetOverrides, views: contentTemplateOverrides.map { try loadView( at: locations.contentsURL, path: $0, isContentOverride: true ) } ) ) return template } } ================================================ FILE: Sources/ToucanSource/MarkdownParser.swift ================================================ // // MarkdownParser.swift // Toucan // // Created by Tibor Bödecs on 2025. 04. 17.. // import Logging import ToucanCore import ToucanSerialization /// A parser for Markdown content that extracts front matter metadata and body content. /// /// Utilizes a configurable separator, a decoder conforming to `ToucanDecoder`, and a logger. public struct MarkdownParser { /// The string used to separate front matter from content in the markdown input. var separator: String /// A decoder used to parse front matter into a typed dictionary. var decoder: ToucanDecoder /// A logger used to emit parsing-related debug messages. var logger: Logger /// Creates a new `MarkdownParser` instance. /// /// - Parameters: /// - separator: A string that separates front matter from content. Defaults to `"---"`. /// - decoder: A decoder conforming to `ToucanDecoder` for parsing front matter. /// - logger: A logger instance for emitting debug information. Defaults to a subsystem logger. public init( separator: String = "---", decoder: ToucanDecoder, logger: Logger = .subsystem("markdown-parser") ) { self.separator = separator self.decoder = decoder self.logger = logger } /// Removes the front matter section from the given markdown string. /// /// - Parameter markdown: The markdown string containing optional front matter. /// - Returns: The markdown string without front matter. func dropFrontMatter( _ markdown: String ) -> String { if markdown.starts(with: separator) { return markdown .split(separator: separator) .dropFirst() .joined(separator: separator) } return markdown } /// Parses the markdown string into front matter and body content. /// /// - Parameter markdown: A markdown string possibly containing front matter. /// - Returns: A `Markdown` instance containing parsed front matter and content. /// - Throws: An error if front matter decoding fails. public func parse( _ markdown: String ) throws -> Markdown { guard markdown.starts(with: separator) else { logger.debug("The markdown string has no front matter.") return .init( frontMatter: [:], contents: markdown ) } let parts = markdown.split( separator: separator, maxSplits: 1, omittingEmptySubsequences: true ) let rawFrontMatter = String(parts.first ?? "") let frontMatter = try decoder.decode( [String: AnyCodable].self, from: rawFrontMatter ) return .init( frontMatter: frontMatter, contents: dropFrontMatter(markdown) .trimmingCharacters( in: .whitespacesAndNewlines ) ) } } ================================================ FILE: Sources/ToucanSource/Models/BuildTargetSource.swift ================================================ // // BuildTargetSource.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 31.. // import struct Foundation.URL /// A complete in-memory representation of a content source bundle, /// including its configuration, content, pipelines, templates, and more. /// /// Typically, this structure is built after parsing a content directory /// and used as input to render or transform content. public struct BuildTargetSource { /// The root location of the source on the filesystem. public var locations: BuiltTargetSourceLocations /// The target to use to build the site. public var target: Target /// Global configuration for the project, often loaded from `config.yml`. public var config: Config /// Site-wide settings, often defined in `site.yml`. public var settings: Settings /// List of content pipelines. public var pipelines: [Pipeline] /// Definitions for content types, typically used to classify and validate content entries. public var types: [ContentType] /// A list of raw content items parsed from the source directory. public var rawContents: [RawContent] /// A list of custom block directives used in Markdown rendering. public var blocks: [Block] /// Initializes a fully populated `BuildTargetSource` from its constituent components. /// /// - Parameters: /// - locations: Filesystem URLs of the source contents. /// - target: The target to use to build the site. /// - config: The main configuration for the site/project. /// - settings: Site-level metadata like title, language, etc. /// - pipelines: Any content transformation pipelines to apply. /// - types: Definitions for content types in this source. /// - rawContents: Parsed content entries from the source. /// - blockDirectives: Definitions of custom Markdown block directives. public init( locations: BuiltTargetSourceLocations, target: Target = .standard, config: Config = .defaults, settings: Settings = .defaults, pipelines: [Pipeline] = [], types: [ContentType] = [], rawContents: [RawContent] = [], blockDirectives: [Block] = [] ) { self.locations = locations self.target = target self.config = config self.settings = settings self.pipelines = pipelines self.types = types self.rawContents = rawContents self.blocks = blockDirectives } } ================================================ FILE: Sources/ToucanSource/Models/BuiltTargetSourceLocations.swift ================================================ // // BuiltTargetSourceLocations.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 01.. // import struct Foundation.URL import Logging import ToucanCore /// A computed mapping of project-relative URLs based on the loaded configuration and project root. public struct BuiltTargetSourceLocations { /// The base URL of the source directory. public var baseURL: URL /// The URL where content files are located. public var contentsURL: URL /// The URL of the site settings configuration file. public var siteSettingsURL: URL /// The URL pointing to site-wide asset resources. public var siteAssetsURL: URL /// The URL containing content type definitions. public var typesURL: URL /// The URL containing block directive definitions. public var blocksURL: URL /// The URL pointing to the pipeline configuration files. public var pipelinesURL: URL /// The URL where template definitions are located. public var templatesURL: URL /// The URL of the currently active template. public var currentTemplateURL: URL /// The URL containing assets for the current template. public var currentTemplateAssetsURL: URL /// The URL pointing to views for the current template. public var currentTemplateViewsURL: URL /// The URL pointing to the override directory of the current template. public var currentTemplateOverridesURL: URL /// The URL for overridden assets in the current template. public var currentTemplateAssetOverridesURL: URL /// The URL for overridden views in the current template. public var currentTemplateViewsOverridesURL: URL /// Creates a new `BuiltTargetSourceLocations` instance by computing file paths based on the project configuration. /// /// - Parameters: /// - sourceURL: The base URL of the source directory. /// - config: The configuration object describing relative paths for various components. public init( sourceURL: URL, config: Config ) { let base = sourceURL let contents = base .appendingPathIfPresent(config.contents.path) let settings = base .appendingPathIfPresent(config.site.settings.path) let assets = base .appendingPathIfPresent(config.site.assets.path) let types = base .appendingPathIfPresent(config.types.path) let blocks = base .appendingPathIfPresent(config.blocks.path) let pipelines = base .appendingPathIfPresent(config.pipelines.path) let templates = base .appendingPathIfPresent(config.templates.location.path) let currentTemplate = templates .appendingPathIfPresent(config.templates.current.path) let currentTemplateAssets = currentTemplate .appendingPathIfPresent(config.templates.assets.path) let currentTemplateViews = currentTemplate .appendingPathIfPresent(config.templates.views.path) let currentTemplateOverrides = templates .appendingPathIfPresent(config.templates.overrides.path) .appendingPathIfPresent(config.templates.current.path) let currentTemplateAssetOverrides = currentTemplateOverrides .appendingPathIfPresent(config.templates.assets.path) let currentTemplateViewsOverrides = currentTemplateOverrides .appendingPathIfPresent(config.templates.views.path) self.baseURL = base self.contentsURL = contents self.siteSettingsURL = settings self.siteAssetsURL = assets self.typesURL = types self.blocksURL = blocks self.pipelinesURL = pipelines self.templatesURL = templates self.currentTemplateURL = currentTemplate self.currentTemplateAssetsURL = currentTemplateAssets self.currentTemplateViewsURL = currentTemplateViews self.currentTemplateOverridesURL = currentTemplateOverrides self.currentTemplateAssetOverridesURL = currentTemplateAssetOverrides self.currentTemplateViewsOverridesURL = currentTemplateViewsOverrides } } extension BuiltTargetSourceLocations: LoggerMetadataRepresentable { /// This metadata can be used to provide additional context in log output. public var logMetadata: [String: Logger.MetadataValue] { [ "baseUrl": .string(baseURL.absoluteString) ] } } ================================================ FILE: Sources/ToucanSource/Models/Markdown.swift ================================================ // // Markdown.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // /// A representation of a Markdown document that includes front matter metadata and raw content. /// /// This model is useful for parsing, transforming, and rendering Markdown files. public struct Markdown: Equatable { /// A dictionary containing parsed front matter metadata. /// /// Typically includes key-value pairs defined at the top of the Markdown file (e.g., `title`, `author`, `date`). public var frontMatter: [String: AnyCodable] /// The body content of the Markdown file, excluding front matter. public var contents: String /// Initializes a new `Markdown` instance with front matter and Markdown content. /// /// - Parameters: /// - frontMatter: A dictionary of metadata parsed from the front matter section. /// - contents: The Markdown body as a string. public init( frontMatter: [String: AnyCodable] = [:], contents: String = "" ) { self.frontMatter = frontMatter self.contents = contents } } ================================================ FILE: Sources/ToucanSource/Models/Origin.swift ================================================ // // Origin.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 30.. // /// Represents the source origin of a content item. public struct Origin: Equatable { /// The original path of the page bundle directory. /// /// This also acts as a unique identifier for the content within the file system. public var path: Path /// The slug, typically derived from the path and influenced by noindex files or directory structure. /// /// This slug is used to generate URLs, permalinks, or unique identifiers in the rendered site. public var slug: String /// Initializes a new `Origin` instance with the given path and slug. /// /// - Parameters: /// - path: The source directory of the content. /// - slug: The derived slug based on the path and metadata. public init( path: Path, slug: String ) { self.path = path self.slug = slug } } ================================================ FILE: Sources/ToucanSource/Models/Path.swift ================================================ // // Path.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 04.. // import Foundation /// A value type representing a path for a raw content item. public struct Path: Equatable { /// The raw value as a string. public var value: String /// Initializes a new path. /// /// - Parameter value: The raw path value string. public init( _ value: String ) { self.value = value } } extension Path: Codable { /// Creates a new instance by decoding from the given decoder. /// /// This initializer attempts to decode the value as a single string. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if reading from the decoder fails, or if the data is not a single string. public init( from decoder: Decoder ) throws { let container = try decoder.singleValueContainer() self.value = try container.decode(String.self) } /// Encodes this value into the given encoder. /// /// This method encodes the value as a single string. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if encoding fails. public func encode( to encoder: Encoder ) throws { var container = encoder.singleValueContainer() try container.encode(value) } } public extension Path { /// Returns a new `Path` instance with the last component removed. /// /// Useful for extracting the base directory of a given path. /// /// - Returns: A `Path` instance without the final path component. func basePath() -> Path { let rawPath = value .split(separator: "/") .dropLast() .joined(separator: "/") return .init(rawPath) } /// Returns a string with all content inside brackets removed. /// /// Optionally removes percent encoding before processing. /// /// - Parameter shouldRemovePercentEncoding: A Boolean value that indicates whether to remove percent encoding. /// - Returns: A string without the content inside square brackets. func trimmingBracketsContent( shouldRemovePercentEncoding: Bool = true ) -> String { var result = "" var insideBrackets = false let finalValue = if shouldRemovePercentEncoding { value.removingPercentEncoding ?? value } else { value } for char in finalValue { if char == "[" { insideBrackets = true } else if char == "]" { insideBrackets = false } else if !insideBrackets { result.append(char) } } return result } } ================================================ FILE: Sources/ToucanSource/Models/RawContent.swift ================================================ // // RawContent.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 30.. // /// Represents the raw, unprocessed state of a content file, typically sourced from a page bundle. /// /// Includes both the Markdown body and its front matter metadata, along with file origin and assets. public struct RawContent: Equatable { /// The origin of the content file, including its path and slug. public var origin: Origin /// The raw Markdown content body. public var markdown: Markdown /// The last modification timestamp (e.g., from file metadata), in Unix epoch format. public var lastModificationDate: Double /// The location of the assets folder relative from the origin path. public var assetsPath: String /// A list of asset paths associated with this content (e.g., images, attachments). public var assets: [String] /// Initializes a new `RawContent` instance. /// /// - Parameters: /// - origin: The origin information of the content file. /// - markdown: The contents using the `Markdown` type. /// - lastModificationDate: The file's last modification time (Unix timestamp). /// - assetsPath: The location of the assets folder relative from the origin path. /// - assets: List of asset file paths linked with this content. public init( origin: Origin, markdown: Markdown = .init(), lastModificationDate: Double, assetsPath: String, assets: [String] ) { self.origin = origin self.markdown = markdown self.lastModificationDate = lastModificationDate self.assetsPath = assetsPath self.assets = assets } } ================================================ FILE: Sources/ToucanSource/Models/Template.swift ================================================ // // Template.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import struct Foundation.URL import Version /** Templates directory structure: ``` templates default assets views overrides default assets views ``` */ /// Represents a template used by the Toucan system, including paths to assets and templates for both base and override components. public struct Template { /// Metadata associated with the template, such as author, version, and tags. public var metadata: Metadata /// The primary components of the template. public var components: Components /// Override components that can replace or augment the default components. public var overrides: Components /// Content-specific components such as assets and templates used within the template. public var content: Components /// Creates a new instance. /// /// - Parameters: /// - metadata: Metadata associated with the template. /// - components: The primary components of the template. /// - overrides: Override components that can replace or augment the default components. /// - content: Content-specific components such as assets and templates used within the template. public init( metadata: Metadata, components: Components, overrides: Components, content: Components ) { self.metadata = metadata self.components = components self.overrides = overrides self.content = content } } public extension Template { /// A group of assets and templates that make up a template component. struct Components { /// A list of asset file paths associated with the component. public var assets: [String] /// A list of templates associated with the component. public var views: [View] /// Creates a new `Components` instance. /// /// - Parameters: /// - assets: A list of asset file paths. /// - views: A list of views. public init( assets: [String], views: [View] ) { self.assets = assets self.views = views } } } extension Template { /// Returns a dictionary of template IDs and their contents. /// /// - Returns: A dictionary where the keys are template IDs and the values are their contents. public func getViewIDsWithContents() -> [String: String] { let views = components.views + overrides.views + content.views let result = views.reduce(into: [String: String]()) { $0[$1.id] = $1.contents } return .init(uniqueKeysWithValues: result.sorted { $0.key < $1.key }) } } public extension Template { /// Metadata describing a template, such as name, version, license, and author. struct Metadata: Codable { /// The name of the template. public var name: String /// A short description of the template. public var description: String /// The URL where the template can be found or referenced. public var url: String? /// The version of the template. public var version: String? /// The versions of the generator this template is compatible with. public var generatorVersion: GeneratorVersion /// Licensing information for the template. public var license: License? /// Author information for the template. public var authors: [Author]? /// A demo link showing the template in action. public var demo: Demo? /// A list of tags to classify or describe the template. public var tags: [String] } } public extension Template.Metadata { struct GeneratorVersion: Codable, Sendable { private enum CodingKeys: CodingKey, CaseIterable { case value case type } /// The base version value that the template supports. public let value: Version /// The version comparison method used during validation. public let type: ComparisonType /// Initializes a new instance with the specified version and comparison type. /// - Parameters: /// - value: The version to be used for comparison. /// - type: The type of comparison to perform. Defaults to `.upNextMajor`. public init( value: Version, type: ComparisonType = .upNextMajor ) { self.value = value self.type = type } /// Initializes a new instance of the model from the given decoder. /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if decoding fails or if unknown keys are present. /// - Note: Validates unknown keys using `CodingKeys`. The `type` property is defaulting to `.upNextMajor` if not present. public init(from decoder: any Decoder) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(Version.self, forKey: .value) self.type = try container.decodeIfPresent( ComparisonType.self, forKey: .type ) ?? .upNextMajor } } } public extension Template.Metadata.GeneratorVersion { enum ComparisonType: String, Codable, Sendable { case upNextMajor case upNextMinor case exact } } public extension Template.Metadata { /// Licensing details for the template. struct License: Codable { /// The name of the license. let name: String /// The URL to the license text or information. let url: String? } } public extension Template.Metadata { /// Author details for the template. struct Author: Codable { /// The author's name. let name: String /// A URL to the author's website or profile. let url: String? } } public extension Template.Metadata { /// Demo resource reference for the template. struct Demo: Codable { /// A URL to the live demo or preview of the template. let url: String } } ================================================ FILE: Sources/ToucanSource/Models/View.swift ================================================ // // View.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 31.. // import Foundation /// Represents the physical location of a Mustache file, identified by a logical ID. public struct View: Equatable { /// A unique identifier for the template public var id: String /// The file system path to the template file relative from the selected template directory. public var path: String /// The contents of the template file. public var contents: String /// Creates a new template instance. /// /// - Parameters: /// - id: A unique identifier for the template. /// - path: The relative file system path of the template file. /// - contents: The full contents of the template file. public init( id: String, path: String, contents: String ) { self.id = id self.path = path self.contents = contents } } ================================================ FILE: Sources/ToucanSource/Objects/AnyCodable.swift ================================================ // // AnyCodable.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import Foundation // public protocol AnySendable: Sendable { // // } /// A type-erased wrapper for any `Codable` value, allowing serialization of /// heterogeneous data structures (e.g., JSON-like dictionaries or YAML trees). /// /// Supports dynamic type resolution during encoding/decoding, /// literal initialization, value extraction, and hashing. public struct AnyCodable: Codable { /// The wrapped value (may be `nil`, scalar, array, dictionary, etc.). public var value: Any? /// Initializes with any optional value. public init(_ value: (some Any)?) { self.value = value } /// Decodes a value from the given decoder and stores it in a type-erased wrapper. /// /// Automatically handles null, scalars, arrays, and dictionaries. /// /// - Parameter decoder: The decoder providing the data. /// - Throws: `DecodingError.dataCorruptedError` if the value cannot be decoded. public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self.init(nil as Any?) } else if let bool = try? container.decode(Bool.self) { self.init(bool) } else if let int = try? container.decode(Int.self) { self.init(int) } else if let double = try? container.decode(Double.self) { self.init(double) } else if let string = try? container.decode(String.self) { self.init(string) } else if let array = try? container.decode([AnyCodable].self) { self.init(array.map(\.value)) } else if let dictionary = try? container.decode( [String: AnyCodable].self ) { self.init(dictionary.mapValues { $0 }) } else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "AnyCodable value cannot be decoded" ) } } /// Attempts to cast the internal value to a concrete type. /// /// - Parameter _: The target type. /// - Returns: The casted value, or `nil` if the cast fails. public func value(as _: T.Type) -> T? { value as? T } /// Encodes the wrapped value using the provided encoder. /// /// Supports scalars, arrays, dictionaries, and any `Encodable` object. /// Throws an error for unsupported types. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: `EncodingError.invalidValue` if the value cannot be encoded. public func encode( to encoder: Encoder ) throws { var container = encoder.singleValueContainer() switch value { case nil: try container.encodeNil() case let bool as Bool: try container.encode(bool) case let int as Int: try container.encode(int) case let double as Double: try container.encode(double) case let string as String: try container.encode(string) case let array as [Any?]: try container.encode(array.map { AnyCodable($0) }) case let dictionary as [String: Any?]: try container.encode(dictionary.mapValues { AnyCodable($0) }) case let encodable as Encodable: try encodable.encode(to: encoder) case _ as NSNull: try container.encodeNil() default: throw EncodingError.invalidValue( value!, .init( codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded" ) ) } } } public extension AnyCodable { func boolValue() -> Bool? { value(as: Bool.self) } func intValue() -> Int? { value(as: Int.self) } func doubleValue() -> Double? { value(as: Double.self) } func stringValue() -> String? { value(as: String.self) } func arrayValue(as _: T.Type) -> [T] { value(as: [T].self) ?? [] } func dictValue() -> [String: AnyCodable] { value(as: [String: AnyCodable].self) ?? [:] } } extension AnyCodable: Equatable { /// Compares two `AnyCodable` values for equality, including nested structures. /// /// Only compares supported primitive and collection types (Bool, Int, Double, String, /// arrays, and dictionaries). Other types will return `false`. public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { switch (lhs.value, rhs.value) { case (nil, nil): true case let (lhs as Bool, rhs as Bool): lhs == rhs case let (lhs as Int, rhs as Int): lhs == rhs case let (lhs as Double, rhs as Double): lhs == rhs case let (lhs as String, rhs as String): lhs == rhs case let (lhs as [AnyCodable], rhs as [AnyCodable]): lhs == rhs case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]): lhs == rhs default: false } } } extension AnyCodable: CustomStringConvertible { /// Returns a human-readable description of the wrapped value. /// /// Falls back to `String(describing:)` if the value does not conform to `CustomStringConvertible`. public var description: String { switch value { case let value as CustomStringConvertible: value.description default: String(describing: value) } } } extension AnyCodable: CustomDebugStringConvertible { /// Returns a debug-friendly string representation of the wrapped value. /// /// Prefixes the output with `"AnyCodable(...)"` for easy identification. public var debugDescription: String { switch value { case let value as CustomDebugStringConvertible: "AnyCodable(\(value.debugDescription))" default: "AnyCodable(\(description))" } } } extension AnyCodable: ExpressibleByNilLiteral { /// Initializes an `AnyCodable` with `nil`. public init(nilLiteral _: ()) { self.init(nil as Any?) } } extension AnyCodable: ExpressibleByBooleanLiteral { /// Initializes an `AnyCodable` with a boolean literal. public init(booleanLiteral value: Bool) { self.init(value) } } extension AnyCodable: ExpressibleByIntegerLiteral { /// Initializes an `AnyCodable` with an integer literal. public init(integerLiteral value: Int) { self.init(value) } } extension AnyCodable: ExpressibleByFloatLiteral { /// Initializes an `AnyCodable` with a floating-point literal. public init(floatLiteral value: Double) { self.init(value) } } extension AnyCodable: ExpressibleByStringLiteral { /// Initializes an `AnyCodable` with a string literal. public init(stringLiteral value: String) { self.init(value) } /// Required for extended grapheme cluster literals. public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } extension AnyCodable: ExpressibleByStringInterpolation {} extension AnyCodable: ExpressibleByArrayLiteral { /// Initializes an `AnyCodable` with an array literal. public init(arrayLiteral elements: Any...) { self.init(elements) } } extension AnyCodable: ExpressibleByDictionaryLiteral { /// Initializes an `AnyCodable` with a dictionary literal. /// /// Also recursively wraps nested dictionaries and arrays. public init(dictionaryLiteral elements: (AnyHashable, Any)...) { var dict: [String: AnyCodable] = [:] for (key, value) in elements { let converted: AnyCodable if let childDict = value as? [AnyHashable: Any] { var newDict: [String: AnyCodable] = [:] for (childKey, childValue) in childDict { newDict[String(describing: childKey)] = AnyCodable( childValue ) } converted = AnyCodable(newDict) } else if let arrayValue = value as? [Any] { let newArray = arrayValue.map { element -> AnyCodable in if let dictElement = element as? [AnyHashable: Any] { var newDict: [String: AnyCodable] = [:] for (childKey, childValue) in dictElement { newDict[String(describing: childKey)] = AnyCodable( childValue ) } return AnyCodable(newDict) } return AnyCodable(element) } converted = AnyCodable(newArray) } else { converted = AnyCodable(value) } dict[String(describing: key)] = converted } self.init(dict) } } extension AnyCodable: Hashable { /// Computes a hash based on the value type. /// /// Only values of supported types will be hashed. public func hash(into hasher: inout Hasher) { switch value { case let value as Bool: hasher.combine(value) case let value as Int: hasher.combine(value) case let value as Double: hasher.combine(value) case let value as String: hasher.combine(value) case let value as [String: AnyCodable]: hasher.combine(value) case let value as [AnyCodable]: hasher.combine(value) default: break // Non-hashable values are ignored } } } ================================================ FILE: Sources/ToucanSource/Objects/Blocks/Block+Attribute.swift ================================================ // // Block+Attribute.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // public extension Block { /// Represents a static HTML attribute that will be rendered on the directive's HTML tag. struct Attribute: Sendable, Codable, Equatable { /// The name of the HTML attribute (e.g., `class`, `id`). public var name: String /// The corresponding value of the attribute. public var value: String /// Initializes an `Attribute` for the rendered directive HTML tag. /// /// - Parameters: /// - name: The attribute key. /// - value: The attribute value. public init( name: String, value: String ) { self.name = name self.value = value } } } ================================================ FILE: Sources/ToucanSource/Objects/Blocks/Block+Parameter.swift ================================================ // // Block+Parameter.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // public extension Block { /// Defines a configurable parameter for a directive, which may be required and have a default value. struct Parameter: Sendable, Codable, Equatable { /// The label of the parameter. public var label: String /// Indicates whether the parameter is required. Defaults to `nil` (optional). public var isRequired: Bool? /// A default value for the parameter, used if it is not explicitly specified in the directive. public var defaultValue: String? /// Initializes a `Parameter` for a directive. /// /// - Parameters: /// - label: The name of the parameter. /// - isRequired: Indicates if the parameter must be provided. /// - defaultValue: A fallback value if none is provided. public init( label: String, isRequired: Bool? = nil, defaultValue: String? = nil ) { self.label = label self.isRequired = isRequired self.defaultValue = defaultValue } } } ================================================ FILE: Sources/ToucanSource/Objects/Blocks/Block.swift ================================================ // // Block.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 17.. // /// A representation of a custom block directive in Markdown, used for extending Markdown syntax with special tags or behaviors. public struct Block: Sendable, Codable, Equatable { /// The name of the directive. public var name: String /// A list of supported parameters for the directive. public var parameters: [Parameter]? /// If specified, this directive must appear within another directive of the given name. public var requiresParentDirective: String? /// Indicates whether child paragraphs should be removed from the HTML output. Defaults to `nil`. public var removesChildParagraph: Bool? /// The HTML tag to render (e.g., `"div"`, `"section"`). public var tag: String? /// Static attributes to apply to the rendered HTML tag. public var attributes: [Attribute]? /// Custom output HTML string that overrides default rendering behavior, if provided. public var output: String? /// Initializes a `MarkdownBlockDirective`. /// /// - Parameters: /// - name: The directive's name. /// - parameters: Optional list of accepted parameters. /// - requiresParentDirective: Name of a parent directive this one must reside within. /// - removesChildParagraph: Whether to exclude child `

` tags during rendering. /// - tag: HTML tag to be generated. /// - attributes: HTML attributes to apply. /// - output: Optional custom HTML output template. public init( name: String, parameters: [Parameter]? = nil, requiresParentDirective: String? = nil, removesChildParagraph: Bool? = nil, tag: String? = nil, attributes: [Attribute]? = nil, output: String? = nil ) { self.name = name self.parameters = parameters self.requiresParentDirective = requiresParentDirective self.removesChildParagraph = removesChildParagraph self.tag = tag self.attributes = attributes self.output = output } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Blocks.swift ================================================ // // Config+Blocks.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 04. 18.. // public extension Config { /// Represents the location of block configuration files. struct Blocks: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case path } /// Provides a default `Blocks` configuration pointing to `"blocks"`. public static var defaults: Self { .init(path: "blocks") } /// The relative or absolute path to the folder containing block configuration files. /// /// Example: `"blocks"` (default), or `"config/blocks"` public var path: String /// Initializes a new blocks configuration. /// /// - Parameter path: The directory where blocks configuration files are stored. public init(path: String) { self.path = path } /// Decodes the `Pipelines` configuration from a structured source. /// /// Falls back to `.defaults` if no container is available or the field is missing. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults let container = try? decoder.container(keyedBy: CodingKeys.self) guard let container else { self = defaults return } self.path = try container.decodeIfPresent(String.self, forKey: .path) ?? defaults.path } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Contents.swift ================================================ // // Config+Contents.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // public extension Config { /// Defines file system paths for locating raw content and its associated assets. struct Contents: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case path case assets } /// Provides a default content configuration using `contents` for source files /// and `assets` for media or supporting files. public static var defaults: Self { .init( path: "contents", assets: .init(path: "assets") ) } /// The root directory path where raw content files (e.g., Markdown, YAML) are located. /// /// Example: `"contents"` or `"src/content"` public var path: String /// The location configuration for assets (e.g., images, attachments) linked to the content. public var assets: Location /// Initializes a custom `Contents` configuration. /// /// - Parameters: /// - path: The content folder path. /// - assets: The associated assets folder configuration. public init( path: String, assets: Location ) { self.path = path self.assets = assets } /// Decodes a `Contents` configuration from a serialized format. /// /// If values are missing, falls back to sensible defaults. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { self = defaults return } self.path = try container.decodeIfPresent( String.self, forKey: .path ) ?? defaults.path self.assets = try container.decodeIfPresent( Location.self, forKey: .assets ) ?? defaults.assets } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+DataTypes+Date.swift ================================================ // // Config+DataTypes+Date.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // public extension Config.DataTypes { /// Provides a configuration for parsing and formatting dates across the site or contents. struct Date: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case input case output case formats } /// Returns a default configuration using ISO 8601 parsing and no predefined output formats. public static var defaults: Self { .init( input: .defaults, output: .defaults, formats: [:] ) } /// The expected format for parsing date input strings (typically from front matter or JSON). /// /// Example: `"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"` (ISO-8601 with milliseconds) public var input: DateFormatterConfig /// A custom date localization for the standard localized output formats. public var output: DateLocalization /// A dictionary of named output formats for rendering dates in different contexts. /// /// Example: /// ```yaml /// formats: /// short: { format: "MMM d" } /// full: { format: "MMMM d, yyyy" } /// ``` public var formats: [String: DateFormatterConfig] /// Initializes a custom date format configuration. /// /// - Parameters: /// - input: Format used to parse raw date values. /// - output: The date localization config for the standard date outputs. /// - formats: Named formats for rendering parsed dates. public init( input: DateFormatterConfig, output: DateLocalization, formats: [String: DateFormatterConfig] ) { self.input = input self.output = output self.formats = formats } /// Decodes the configuration from a serialized source, /// applying default values for missing fields. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { self = defaults return } self.input = try container.decodeIfPresent( DateFormatterConfig.self, forKey: .input ) ?? defaults.input self.output = try container.decodeIfPresent( DateLocalization.self, forKey: .output ) ?? defaults.output self.formats = try container.decodeIfPresent( [String: DateFormatterConfig].self, forKey: .formats ) ?? defaults.formats } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+DataTypes.swift ================================================ // // Config+DataTypes.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 16.. // public extension Config { /// Defines how core data types—like date formats—should be interpreted or rendered within a pipeline. /// /// `DataTypes` is a configuration layer that allows pipelines to specify /// localized or project-specific formatting and handling logic for structured data. struct DataTypes: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case date } /// Returns the default `DataTypes` configuration, using `.defaults` for date formatting. public static var defaults: Self { .init(date: .defaults) } /// The configuration used to handle and format date values. public var date: Date /// Initializes a new `DataTypes` instance. /// /// - Parameter date: Date format configuration to apply. public init( date: Date ) { self.date = date } /// Decodes a `DataTypes` configuration from serialized input. /// /// Defaults to `.defaults` if the `date` field is missing. /// /// - Parameter decoder: The decoder to parse configuration from. /// - Throws: A decoding error if any value is invalid. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let date = try container.decodeIfPresent( Date.self, forKey: .date ) ?? .defaults self.init(date: date) } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Location.swift ================================================ // // Config+Location.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // public extension Config { /// Represents a named location within the file system. struct Location: Codable, Equatable { /// The file system path for this location (e.g., `"assets"`, `"public/images"`). public var path: String /// Initializes a new `Location` with a given path. /// /// - Parameter path: A relative or absolute path in the project. public init(path: String) { self.path = path } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Pipelines.swift ================================================ // // Config+Pipelines.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // public extension Config { /// Represents the location of pipeline configuration files. struct Pipelines: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case path } /// Provides a default `Pipelines` configuration pointing to `"pipelines"`. public static var defaults: Self { .init(path: "pipelines") } /// The relative or absolute path to the folder containing pipeline configuration files. /// /// Example: `"pipelines"` (default), or `"config/pipelines"` public var path: String /// Initializes a new pipelines configuration. /// /// - Parameter path: The directory where pipeline configuration files are stored. public init(path: String) { self.path = path } /// Decodes the `Pipelines` configuration from a structured source. /// /// Falls back to `.defaults` if no container is available or the field is missing. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults let container = try? decoder.container(keyedBy: CodingKeys.self) guard let container else { self = defaults return } self.path = try container.decodeIfPresent(String.self, forKey: .path) ?? defaults.path } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Renderer+ParagraphStyles.swift ================================================ // // Config+Renderer+ParagraphStyles.swift // Toucan // // Created by gerp83 on 2025. 04. 17.. // public extension Config.RendererConfig { /// Defines paragraph style aliases for block-level directives struct ParagraphStyles: Codable, Equatable { /// Returns a standard `ParagraphStyles` configuration with common alias values. public static var defaults: Self { .init( styles: [ "note": ["note"], "warning": ["warn", "warning"], "tip": ["tip"], "important": ["important"], "error": ["error", "caution"], ] ) } /// A dictionary mapping style group names to arrays of individual paragraph styles. public var styles: [String: [String]] /// Initializes a new object with custom style mappings. /// /// - Parameter styles: A style group representing the paragraph styles. public init( styles: [String: [String]] ) { self.styles = styles } /// Initializes a new instance by decoding from the given decoder. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: Only throws if the underlying decoding attempt throws unexpectedly; /// otherwise silently falls back to defaults. public init( from decoder: Decoder ) throws { guard let container = try? decoder.singleValueContainer(), let styles = try? container.decode([String: [String]].self) else { self.styles = Self.defaults.styles return } self.styles = styles } /// Encodes this instance into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any value is invalid for the given encoder’s format. public func encode( to encoder: Encoder ) throws { var container = encoder.singleValueContainer() try container.encode(styles) } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+RendererConfig.swift ================================================ // // Config+RendererConfig.swift // Toucan // // Created by gerp83 on 2025. 03. 28.. // public extension Config { /// Defines default configurations used when rendering content, /// including reading time settings, outline parsing depth, and /// paragraph styling rules for directive blocks. struct RendererConfig: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case wordsPerMinute case outlineLevels case paragraphStyles } /// Returns a `ContentConfigurations` instance with sensible default values. public static var defaults: Self { .init( wordsPerMinute: 238, outlineLevels: [2, 3], paragraphStyles: .defaults ) } /// The average reading speed used to estimate reading time (words per minute). /// /// Common default is 238 wpm, based on tested averages for fluent readers. public var wordsPerMinute: Int /// The heading levels to extract for outlines (e.g., `[2, 3]` means `##` and `###` in Markdown). /// /// These levels are used when generating tables of contents or section overviews. public var outlineLevels: [Int] /// Aliases for styled paragraph blocks (e.g., "note", "tip", "error"). public var paragraphStyles: ParagraphStyles /// Initializes a custom `ContentConfigurations` instance. /// /// - Parameters: /// - wordsPerMinute: The average reading speed for estimating read time. /// - outlineLevels: Heading levels to extract for outline/toc generation. /// - paragraphStyles: Mappings for styled block directives. public init( wordsPerMinute: Int, outlineLevels: [Int], paragraphStyles: ParagraphStyles ) { self.wordsPerMinute = wordsPerMinute self.outlineLevels = outlineLevels self.paragraphStyles = paragraphStyles } /// Decodes a `ContentConfigurations` instance, applying defaults for missing fields. /// /// Gracefully falls back to `.defaults` if the decoding container is missing or incomplete. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { self = defaults return } self.wordsPerMinute = try container.decodeIfPresent(Int.self, forKey: .wordsPerMinute) ?? defaults.wordsPerMinute self.outlineLevels = try container.decodeIfPresent( [Int].self, forKey: .outlineLevels ) ?? defaults.outlineLevels self.paragraphStyles = try container.decodeIfPresent( ParagraphStyles.self, forKey: .paragraphStyles ) ?? defaults.paragraphStyles } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Site.swift ================================================ // // Config+Site.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // public extension Config { /// Defines file system paths for locating site related resources. struct Site: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case assets case settings } /// Provides a default content configuration public static var defaults: Self { .init( assets: .init(path: "assets"), settings: .init(path: "") ) } /// The location of the global site assets. public var assets: Location /// The location of the site settings. public var settings: Location /// Initializes a custom `Site` configuration. /// /// - Parameters: /// - assets: The assets folder location. /// - settings: The settings (site.yml) file location. public init( assets: Location, settings: Location ) { self.assets = assets self.settings = settings } /// Decodes a `Site` configuration from a serialized format. /// /// If values are missing, falls back to default values. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults guard let container = try? decoder.container( keyedBy: CodingKeys.self ) else { self = defaults return } self.assets = try container.decodeIfPresent( Location.self, forKey: .assets ) ?? defaults.assets self.settings = try container.decodeIfPresent( Location.self, forKey: .settings ) ?? defaults.settings } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Templates.swift ================================================ // // Config+Templates.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 01.. // import Foundation public extension Config { /// Defines the structure and paths for working with templates in the system. struct Templates: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case location case current case assets case views case overrides } /// Returns the default template configuration with all folders under the `"templates"` base. public static var defaults: Self { .init( location: .init(path: "templates"), current: .init(path: "default"), assets: .init(path: "assets"), views: .init(path: "views"), overrides: .init(path: "overrides") ) } /// The base folder where all templates are stored (e.g., `"templates"`). public var location: Location /// The subfolder or identifier of the currently selected template (e.g., `"default"`, `"dark"`). public var current: Location /// The path inside the template where static assets (e.g., CSS, JS, images) are stored. public var assets: Location /// The path to the folder containing template views (e.g., HTML or markup layouts). public var views: Location /// A folder for override files that replace core behavior or template (optional). public var overrides: Location /// Initializes a configuration. /// /// - Parameters: /// - location: The base path containing all template folders. /// - current: The name or path of the active template. /// - assets: Folder path for template assets. /// - views: Folder path for views. /// - overrides: Folder path for template overrides. public init( location: Location, current: Location, assets: Location, views: Location, overrides: Location ) { self.location = location self.current = current self.assets = assets self.views = views self.overrides = overrides } /// Decodes a configuration from serialized input, falling back to default values when missing. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults let container = try? decoder.container(keyedBy: CodingKeys.self) guard let container else { self = defaults return } self.location = try container.decodeIfPresent(Location.self, forKey: .location) ?? defaults.location self.current = try container.decodeIfPresent(Location.self, forKey: .current) ?? defaults.current self.assets = try container.decodeIfPresent(Location.self, forKey: .assets) ?? defaults.assets self.views = try container.decodeIfPresent(Location.self, forKey: .views) ?? defaults.views self.overrides = try container.decodeIfPresent(Location.self, forKey: .overrides) ?? defaults.overrides } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config+Types.swift ================================================ // // Config+Types.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 04. 18.. // public extension Config { /// Represents the location of type configuration files. struct Types: Sendable, Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case path } /// Provides a default `Types` configuration pointing to `"types"`. public static var defaults: Self { .init(path: "types") } /// The relative or absolute path to the folder containing type configuration files. /// /// Example: `"types"` (default), or `"config/types"` public var path: String /// Initializes a new types configuration. /// /// - Parameter path: The directory where type configuration files are stored. public init( path: String ) { self.path = path } /// Decodes the `Types` configuration from a structured source. /// /// Falls back to `.defaults` if no container is available or the field is missing. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults let container = try? decoder.container(keyedBy: CodingKeys.self) guard let container else { self = defaults return } self.path = try container.decodeIfPresent(String.self, forKey: .path) ?? defaults.path } } } ================================================ FILE: Sources/ToucanSource/Objects/Config/Config.swift ================================================ // // Config.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 29.. // import Foundation /// Represents the top-level configuration for a content rendering system. public struct Config: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case site case pipelines case contents case types case blocks case templates case dataTypes case renderer } /// Provides a default `Config` instance using defaults from all subcomponents. /// /// This is used when configuration fields are missing or omitted. public static var defaults: Self { .init( site: .defaults, pipelines: .defaults, contents: .defaults, types: .defaults, blocks: .defaults, templates: .defaults, dataTypes: .defaults, renderer: .defaults ) } /// Global site configuration. public var site: Site /// Pipeline configuration used to transform and render content. public var pipelines: Pipelines /// Configuration for mapping and locating raw content files. public var contents: Contents /// The folder where type-specific templates or definitions reside. public var types: Types /// A folder for reusable UI block components (e.g., hero, footer, card). public var blocks: Blocks /// Template-related configuration, including layout templates and style resources. public var templates: Templates /// Global date format settings for rendering and parsing dates. public var dataTypes: DataTypes /// Additional content-specific overrides or configuration extensions. public var renderer: RendererConfig /// Initializes a full `Config` instance. /// /// - Parameters: /// - site: Site configuration. /// - pipelines: Pipeline configurations. /// - contents: Content mapping configuration. /// - types: Folder path for type definitions. /// - blocks: Folder path for reusable block templates. /// - templates: Template layout and styling definitions. /// - dataTypes: Data type related configurations. /// - renderer: Fine-grained control for specific content types. public init( site: Site = .defaults, pipelines: Pipelines = .defaults, contents: Contents = .defaults, types: Types = .defaults, blocks: Blocks = .defaults, templates: Templates = .defaults, dataTypes: DataTypes = .defaults, renderer: RendererConfig = .defaults ) { self.site = site self.pipelines = pipelines self.contents = contents self.types = types self.blocks = blocks self.templates = templates self.dataTypes = dataTypes self.renderer = renderer } /// Decodes the `Config` from a structured data source (e.g., YAML or JSON), /// applying defaults to any missing fields for robust deserialization. /// /// - Parameter decoder: The decoder used to load configuration. /// - Throws: A decoding error if required structures are malformed. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults let container = try? decoder.container(keyedBy: CodingKeys.self) guard let container else { self = defaults return } self.site = try container.decodeIfPresent( Site.self, forKey: .site ) ?? defaults.site self.pipelines = try container.decodeIfPresent( Pipelines.self, forKey: .pipelines ) ?? defaults.pipelines self.contents = try container.decodeIfPresent( Contents.self, forKey: .contents ) ?? defaults.contents self.types = try container.decodeIfPresent( Types.self, forKey: .types ) ?? defaults.types self.blocks = try container.decodeIfPresent( Blocks.self, forKey: .blocks ) ?? defaults.blocks self.templates = try container.decodeIfPresent( Templates.self, forKey: .templates ) ?? defaults.templates self.dataTypes = try container.decodeIfPresent( DataTypes.self, forKey: .dataTypes ) ?? defaults.dataTypes self.renderer = try container.decodeIfPresent( RendererConfig.self, forKey: .renderer ) ?? defaults.renderer } } ================================================ FILE: Sources/ToucanSource/Objects/Date/DateFormatterConfig.swift ================================================ // // DateFormatterConfig.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 28.. // /// A configuration for formatting dates. /// /// This type holds both localization options and a format string, allowing /// dates to be formatted according to locale, time zone, and pattern. public struct DateFormatterConfig: Sendable, Codable, Equatable { /// The keys used for encoding and decoding top-level date formatter properties. private enum CodingKeys: String, CodingKey, CaseIterable { case format // NOTE: Multiple types are parsed from the same container. The keys listed below help make validation easier. Refer to `DateLocalization` for a related implementation. case locale case timeZone } /// Returns a default configuration using ISO 8601 parsing and no predefined output formats. public static var defaults: Self { .init( localization: .defaults, format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" ) } /// The locale and time zone options to apply when formatting dates. public var localization: DateLocalization /// The date format string (e.g., `"yyyy-MM-dd"`, `"MMMM d, yyyy"`). public var format: String /// Creates a new date formatter options instance. /// /// - Parameters: /// - localization: The locale and time zone options to apply. /// - format: A date format string (for example, `"yyyy-MM-dd"` or `"MMMM d, yyyy"`). public init( localization: DateLocalization, format: String ) { self.localization = localization self.format = format } /// Initializes a new `DateFormatterOptions` by decoding from the given decoder. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if reading from the decoder fails, or if the data is corrupted or invalid. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) self.localization = try DateLocalization(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) let format = try container.decode(String.self, forKey: .format) guard !format.isEmpty else { throw DecodingError.dataCorrupted( .init( codingPath: container.codingPath, debugDescription: "Empty date format value." ) ) } self.format = format } /// Encodes this `DateFormatterOptions` into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any values are invalid for the given encoder’s format. public func encode( to encoder: Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) try localization.encode(to: encoder) try container.encode(format, forKey: .format) } } ================================================ FILE: Sources/ToucanSource/Objects/Date/DateLocalization.swift ================================================ // // DateLocalization.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 28.. // import struct Foundation.Locale import struct Foundation.TimeZone /// A set of locale and time zone identifiers used when formatting dates. /// /// This type holds the locale and time zone identifiers that will be used /// by a date formatter to localize its output. public struct DateLocalization: Sendable, Codable, Equatable { /// The keys used for encoding and decoding top-level date formatter properties. enum CodingKeys: CodingKey, CaseIterable { case locale case timeZone // NOTE: Multiple types are parsed from the same container. The keys listed below help make validation easier. Refer to `DateFormatterConfig` for a related implementation. case format } /// The default date localization options using the system’s default locale /// (`"en-US"`) and time zone (`"GMT"`). public static var defaults: Self { .init( locale: "en-US", timeZone: "GMT" ) } /// The locale identifier used for formatting (e.g., `"en_US"`, `"fr_FR"`). /// If `nil`, the system’s default locale will be used. public var locale: String /// The time zone identifier (e.g., `"UTC"`, `"Europe/Budapest"`). /// If `nil`, the system’s default time zone will be used. public var timeZone: String /// Creates a new date localization options instance. /// /// - Parameters: /// - locale: A locale identifier (for example, `"en_US"` or `"fr_FR"`). /// - timeZone: A time zone identifier (for example, `"UTC"` or `"Europe/Budapest"`). public init( locale: String, timeZone: String ) { self.locale = locale self.timeZone = timeZone } /// Creates a new instance by decoding from the given decoder. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if decoding fails, or if the locale or time zone identifier is invalid. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let defaults = Self.defaults let locale = try container.decodeIfPresent( String.self, forKey: .locale ) ?? defaults.locale let timeZone = try container.decodeIfPresent( String.self, forKey: .timeZone ) ?? defaults.timeZone let id = Locale.identifier(.icu, from: locale) guard Locale.availableIdentifiers.contains(id) else { throw DecodingError.dataCorrupted( .init( codingPath: container.codingPath, debugDescription: "Invalid locale identifier." ) ) } guard TimeZone(identifier: timeZone) != nil else { throw DecodingError.dataCorrupted( .init( codingPath: container.codingPath, debugDescription: "Invalid time zone identifier." ) ) } self.locale = locale self.timeZone = timeZone } /// Encodes this `DateFormatterOptions` into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any values are invalid for the given encoder’s format. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) let defaults = DateLocalization.defaults if locale != defaults.locale { try container.encode(locale, forKey: .locale) } if timeZone != defaults.timeZone { try container.encode(timeZone, forKey: .timeZone) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Assets.swift ================================================ // // Pipeline+Assets.swift // Toucan // // Created by Tibor Bödecs on 2025. 04. 19.. // public extension Pipeline { /// Represents a collection of asset declarations used during content rendering. /// /// Assets include static files like JavaScript, CSS, and images that are attached /// to the output content, either by setting paths, loading files, or parsing content. struct Assets: Codable { private enum CodingKeys: CodingKey, CaseIterable { case behaviors case properties } /// Describes the file location for the asset. public struct Location: Codable { /// An optional path to the asset file. public var path: String? /// The base name of the file (without extension). public var name: String /// The file extension (e.g., `"css"`, `"js"`). public var ext: String // /// Initializes a new `Input` describing an asset file. /// /// - Parameters: /// - path: Optional path to the file. /// - name: The file name without extension. /// - ext: The file extension. public init( path: String? = nil, name: String, ext: String ) { self.path = path self.name = name self.ext = ext } } /// Describes a transformation between two asset locations, typically used for converting input files to a desired output format. /// /// The `Behavior` struct is useful in defining how assets should be handled during processing, for example, /// converting a SCSS file to a CSS file or minifying JavaScript. /// /// - Properties: /// - id: A unique identifier for the behavior. /// - input: The source location of the asset. /// - output: The destination location for the processed asset. public struct Behavior: Codable { private enum CodingKeys: CodingKey, CaseIterable { case id case input case output } /// The unique identifier for the behavior. public var id: String /// The input location for the behavior. public var input: Location /// The output location for the behavior. public var output: Location /// Initializes a behavior /// /// - Properties: /// - id: A unique identifier for the behavior. /// - input: The source location of the asset. /// - output: The destination location for the processed asset. public init( id: String, input: Location, output: Location ) { self.id = id self.input = input self.output = output } /// Decodes a `Behavior` instance from a configuration source (e.g., JSON/YAML). /// /// Missing fields default public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let id = try container.decode(String.self, forKey: .id) let input = try container.decodeIfPresent( Location.self, forKey: .input ) ?? .init( name: "*", ext: "*" ) let output = try container.decodeIfPresent( Location.self, forKey: .output ) ?? .init( name: "*", ext: "*" ) self.init( id: id, input: input, output: output ) } } /// Represents a single asset manipulation instruction within the `Assets` configuration. public struct Property: Codable { /// Defines how the asset should be applied or processed. public enum Action: String, Codable { /// Add the asset to an existing list or collection. case add /// Overwrite or explicitly set the asset value. case set /// Load the asset from a specified path or resource. case load /// Parse the asset, typically used for dynamic formats (e.g., JSON, YAML). case parse } /// The action to perform for this asset. public var action: Action /// The logical asset key or category (e.g., `"js"`, `"image"`). public var property: String /// Indicates whether the path to the file should be automatically resolved. public var resolvePath: Bool /// Describes the input file for the asset. public var input: Location /// Initializes a new `Property` describing an asset manipulation. /// /// - Parameters: /// - action: The type of action to perform (e.g., `.set`, `.add`). /// - property: The logical key or name for the asset (e.g., `"css"`). /// - resolvePath: Whether to resolve the input file path dynamically. /// - input: The input file descriptor. public init( action: Action, property: String, resolvePath: Bool, input: Location ) { self.action = action self.property = property self.resolvePath = resolvePath self.input = input } } /// Returns a default asset configuration commonly used for HTML pipelines. public static var defaults: Self { .init( behaviors: [], properties: [] ) } /// A list of asset behaviors public var behaviors: [Behavior] /// A list of asset manipulation rules. public var properties: [Property] /// Initializes an `Assets` instance with a given set of properties. /// /// - Parameters: /// - behaviors: The array of asset behaviors. /// - properties: The array of asset properties to include. public init( behaviors: [Behavior] = [], properties: [Property] = [] ) { self.behaviors = behaviors self.properties = properties } /// Decodes the `Assets` instance from a decoder, applying empty defaults if necessary. /// /// - Parameter decoder: The decoder to use for deserialization. /// - Throws: An error if decoding fails. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let defaults = Self.defaults let behaviors = try container.decodeIfPresent( [Behavior].self, forKey: .behaviors ) ?? defaults.behaviors let properties = try container.decodeIfPresent( [Property].self, forKey: .properties ) ?? defaults.properties self.init( behaviors: behaviors, properties: properties ) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+ContentTypes.swift ================================================ // // Pipeline+ContentTypes.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 03.. // public extension Pipeline { /// Defines rules for selecting and filtering content types used in a pipeline. /// /// `ContentTypes` allows explicit inclusion or exclusion of types, as well as /// optional tracking for last modification timestamps. struct ContentTypes: Codable { private enum CodingKeys: CodingKey, CaseIterable { case include case exclude case lastUpdate case filterRules } /// Default configuration with no filtering or update tracking. public static var defaults: Self { .init( include: [], exclude: [], lastUpdate: [], filterRules: [:] ) } /// A list of content types to explicitly include. /// /// If this list is empty, all content types are included unless excluded. public var include: [String] /// A list of content types to explicitly exclude. /// /// These override entries in `include` and are always filtered out. public var exclude: [String] /// A list of content types that should be tracked for last update timestamps. public var lastUpdate: [String] /// A mapping of content type keys to filtering conditions. /// /// Each key represents a content type (e.g., `"post"`, `"author"`), and its value /// defines a condition that must be met for the content to be included in the pipeline. /// This enables fine-grained control over which specific content items are published. /// /// If a content type is not listed in `filterRules`, it is not subject to condition-based filtering. public var filterRules: [String: Condition] /// Initializes a new `ContentTypes` filter configuration. /// /// - Parameters: /// - include: List of explicitly allowed content types. /// - exclude: List of content types to exclude from processing. /// - lastUpdate: List of content types to monitor for timestamp changes. /// - filterRules: Mapping of content type keys to conditions used to filter content items. public init( include: [String], exclude: [String], lastUpdate: [String], filterRules: [String: Condition] ) { self.include = include self.exclude = exclude self.lastUpdate = lastUpdate self.filterRules = filterRules } /// Decodes a `ContentTypes` instance from a configuration source (e.g., JSON/YAML). /// /// Missing fields default to empty arrays. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let include = try container.decodeIfPresent([String].self, forKey: .include) ?? [] let exclude = try container.decodeIfPresent([String].self, forKey: .exclude) ?? [] let lastUpdate = try container.decodeIfPresent( [String].self, forKey: .lastUpdate ) ?? [] let filterRules: [String: Condition] = try container.decodeIfPresent( [String: Condition].self, forKey: .filterRules ) ?? [:] self.init( include: include, exclude: exclude, lastUpdate: lastUpdate, filterRules: filterRules ) } /// Determines whether a given content type should be processed based on inclusion and exclusion rules. /// /// - Parameter contentType: The content type key (e.g., `"blog"`, `"author"`). /// - Returns: `true` if the content type is allowed, `false` otherwise. public func isAllowed(contentType: String) -> Bool { if exclude.contains(contentType) { return false } if include.isEmpty { return true } return include.contains(contentType) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+DataTypes+Date.swift ================================================ // // Pipeline+DataTypes+Date.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 30.. // public extension Pipeline.DataTypes { /// Provides a configuration for parsing and formatting dates across the site or contents. struct Date: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case output case formats } /// Returns a default configuration using ISO 8601 parsing and no predefined output formats. public static var defaults: Self { .init( output: nil, formats: [:] ) } /// A custom date localization for the standard localized output formats. public var output: DateLocalization? /// A dictionary of named output formats for rendering dates in different contexts. /// /// Example: /// ```yaml /// formats: /// short: { format: "MMM d" } /// full: { format: "MMMM d, yyyy" } /// ``` public var formats: [String: DateFormatterConfig] /// Initializes a custom date format configuration. /// /// - Parameters: /// - output: The date localization config for the standard date outputs. /// - formats: Named formats for rendering parsed dates. public init( output: DateLocalization?, formats: [String: DateFormatterConfig] ) { self.output = output self.formats = formats } /// Decodes the configuration from a serialized source, /// applying default values for missing fields. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let defaults = Self.defaults guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { self = defaults return } self.output = try container.decodeIfPresent( DateLocalization.self, forKey: .output ) ?? defaults.output self.formats = try container.decodeIfPresent( [String: DateFormatterConfig].self, forKey: .formats ) ?? defaults.formats } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+DataTypes.swift ================================================ // // Pipeline+DataTypes.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 30.. // public extension Pipeline { /// Defines how core data types—like date formats—should be interpreted or rendered within a pipeline. /// /// `DataTypes` is a configuration layer that allows pipelines to specify /// localized or project-specific formatting and handling logic for structured data. struct DataTypes: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case date } /// Returns the default `DataTypes` configuration, using `.defaults` for date formatting. public static var defaults: Self { .init(date: .defaults) } /// The configuration used to handle and format date values. public var date: Date /// Initializes a new `DataTypes` instance. /// /// - Parameter date: Date format configuration to apply. public init( date: Date ) { self.date = date } /// Decodes a `DataTypes` configuration from serialized input. /// /// Defaults to `.defaults` if the `date` field is missing. /// /// - Parameter decoder: The decoder to parse configuration from. /// - Throws: A decoding error if any value is invalid. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let date = try container.decodeIfPresent( Date.self, forKey: .date ) ?? .defaults self.init(date: date) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Engine.swift ================================================ // // Pipeline+Engine.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 03.. // public extension Pipeline { /// Represents the rendering engine configuration used in a content pipeline. struct Engine: Codable { private enum CodingKeys: CodingKey, CaseIterable { case id case options } /// A unique identifier for the engine (e.g., `"html"`, `"api"`, `"rss"`). public var id: String /// A map of engine-specific configuration options. /// /// These options are engine-dependent and may define things like layout names, /// file extensions, or custom behaviors. public var options: [String: AnyCodable] /// Initializes a new engine configuration. /// /// - Parameters: /// - id: The unique identifier of the engine type. /// - options: A dictionary of custom configuration options. public init( id: String, options: [String: AnyCodable] = [:] ) { self.id = id self.options = options } /// Decodes an `Engine` instance from a configuration source. /// /// If `options` is not defined, it defaults to an empty dictionary. /// /// - Throws: A decoding error if required fields are missing or malformed. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let id = try container.decode(String.self, forKey: .id) let options = try container.decodeIfPresent( [String: AnyCodable].self, forKey: .options ) ?? [:] self.init(id: id, options: options) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Output.swift ================================================ // // Pipeline+Output.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 16.. // public extension Pipeline { /// Describes the output configuration for a content pipeline. struct Output: Codable { private enum CodingKeys: CodingKey, CaseIterable { case path case file case ext } /// The directory path where the output file should be written. /// /// This is relative to the site's output root (e.g., `"public/blog"`). public var path: String /// The base file name of the output file (without extension). /// /// Common values include `"index"`, `"feed"`, etc. public var file: String /// The file extension of the output file (e.g., `"html"`, `"json"`, `"xml"`). public var ext: String /// Initializes a new `Output` configuration. /// /// - Parameters: /// - path: The relative path to the output directory. /// - file: The base file name (e.g., `"index"`). /// - ext: The file extension (e.g., `"html"`). public init( path: String, file: String, ext: String ) { self.path = path self.file = file self.ext = ext } /// Decodes the `Output` configuration from a serialized format (e.g., JSON/YAML). /// /// - Throws: A decoding error if any required key is missing. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let path = try container.decode(String.self, forKey: .path) let file = try container.decode(String.self, forKey: .file) let ext = try container.decode(String.self, forKey: .ext) self.init( path: path, file: file, ext: ext ) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Scope+Context.swift ================================================ // // Pipeline+Scope+Context.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 03.. // public extension Pipeline.Scope { /// Represents the available data context for a rendering `Scope`. struct Context: OptionSet, Codable { /// Includes user-defined metadata and settings. public static var userDefined: Self { .init(rawValue: 1 << 0) } /// Includes all standard content properties (e.g., title, date). public static var properties: Self { .init(rawValue: 1 << 1) } /// Includes nested or inline contents (e.g., included markdown). public static var contents: Self { .init(rawValue: 1 << 2) } /// Includes resolved relations (e.g., related posts, authors). public static var relations: Self { .init(rawValue: 1 << 3) } /// Includes output from named or inline queries. public static var queries: Self { .init(rawValue: 1 << 4) } /// A context optimized for minimal, linked summaries. public static var reference: Self { [ .userDefined, .properties, .relations, .contents, .queries, ] } /// A context optimized for list or collection rendering. public static var list: Self { [ .userDefined, .properties, .relations, .contents, .queries, ] } /// A context optimized for detailed full-page rendering. public static var detail: Self { [ .userDefined, .properties, .relations, .contents, .queries, ] } /// The underlying raw bitmask value used to represent the context. public let rawValue: UInt /// Returns the mapping of context options to their string names. private var allOptions: [(Context, String)] { [ (.userDefined, Keys.userDefined.rawValue), (.properties, Keys.properties.rawValue), (.contents, Keys.contents.rawValue), (.relations, Keys.relations.rawValue), (.queries, Keys.queries.rawValue), (.reference, Keys.reference.rawValue), (.list, Keys.list.rawValue), (.detail, Keys.detail.rawValue), ] } /// Returns the string names of the options contained in the context. public var stringValues: [String] { allOptions.compactMap { contains($0.0) ? $0.1 : nil } } /// Initializes the context using a raw value. /// /// - Parameter rawValue: The UInt representation of the context. public init(rawValue: UInt) { self.rawValue = rawValue } /// Initializes the context using a string name (e.g., "properties", "detail"). /// /// - Parameter stringValue: The string representation of the context. public init(stringValue: String) { switch stringValue.lowercased() { case Keys.userDefined.rawValue: self = .userDefined case Keys.properties.rawValue: self = .properties case Keys.contents.rawValue: self = .contents case Keys.relations.rawValue: self = .relations case Keys.queries.rawValue: self = .queries case Keys.reference.rawValue: self = .reference case Keys.list.rawValue: self = .list case Keys.detail.rawValue: self = .detail default: self = [] } } /// Decodes the context from either a single string or an array of strings. /// /// Supports user-friendly formats like: /// ```yaml /// context: "detail" /// ``` /// or /// ```yaml /// context: ["properties", "relations"] /// ``` /// /// - Parameter decoder: The decoder to use. /// - Throws: A decoding error if format is not supported. public init( from decoder: any Decoder ) throws { let container = try decoder.singleValueContainer() if let stringValue = try? container.decode(String.self) { self.init(stringValue: stringValue) } else if let stringArray = try? container.decode([String].self) { self = stringArray.reduce(into: []) { $0.insert(.init(stringValue: $1)) } } else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Invalid context format." ) } } /// Encodes the context as a string or array of strings using the defined string values. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any values are invalid or encoding fails. public func encode( to encoder: any Encoder ) throws { var container = encoder.singleValueContainer() if let matched = allOptions.first(where: { self == $0.0 }) { try container.encode(matched.1) } else { let parts = allOptions.filter { contains($0.0) }.map(\.1) try container.encode(parts) } } } } extension Pipeline.Scope.Context { /// String keys used to identify pipeline scope contexts. public enum Keys: String, CaseIterable { case userDefined case properties case contents case relations case queries case reference case list case detail } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Scope.swift ================================================ // // Pipeline+Scope.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 03.. // public extension Pipeline { /// Describes a rendering scope within a content pipeline. struct Scope: Codable { private enum CodingKeys: CodingKey, CaseIterable { case id case context case fields } /// String keys used to identify pipeline scopes. public enum Keys: String, CaseIterable { case reference case list case detail case wildcard = "*" } /// A scope for rendering lightweight summaries or IDs for use in references. public static var reference: Scope { .init(context: .reference) } /// A scope for rendering content in a list format (e.g., previews, teasers). public static var list: Scope { .init(context: .list) } /// A scope for rendering full content in detail pages. public static var detail: Scope { .init(context: .detail) } /// A standard mapping of common context names to their default scopes. public static var standard: [String: Scope] { [ Keys.reference.rawValue: reference, Keys.list.rawValue: list, Keys.detail.rawValue: detail, ] } /// The default fallback scope set, applied to all content types via the `*` wildcard. public static var `default`: [String: [String: Scope]] { [ Keys.wildcard.rawValue: standard ] } /// The rendering context this scope applies to (e.g., `.detail`, `.list`, `.reference`). public var context: Context /// The specific content fields to include when rendering in this scope. /// If empty, all fields may be included by default. public var fields: [String] /// Initializes a `Scope` with a given context and set of fields. /// /// - Parameters: /// - context: The rendering context. /// - fields: The fields to expose in this scope. public init( context: Context = .detail, fields: [String] = [] ) { self.context = context self.fields = fields } /// Decodes a `Scope` from configuration data, with fallback defaults. /// /// If `context` is not specified, defaults to `.detail`. /// If `fields` are not specified, defaults to an empty list. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let context = try container.decodeIfPresent(Context.self, forKey: .context) ?? .detail let fields = try container.decodeIfPresent([String].self, forKey: .fields) ?? [] self.init(context: context, fields: fields) } /// Encodes this `Scope` instance into the given encoder. /// /// This method encodes the `context` and `fields` properties using keyed encoding. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any values are invalid for the encoder’s format. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(context, forKey: .context) try container.encode(fields, forKey: .fields) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Transformers+Transformer.swift ================================================ // // Pipeline+Transformers+Transformer.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 16.. // import Foundation public extension Pipeline.Transformers { /// Represents a content transformer command used in a transformation pipeline. struct Transformer: Codable { /// Coding keys for decoding path and name properties. private enum CodingKeys: String, CodingKey, CaseIterable { case path case name } /// The directory path where the executable is located. /// Defaults to `"/usr/local/bin"` if not explicitly specified. public var path: String /// The name of the executable or script to run. public var name: String /// Initializes a new `ContentTransformer` with an optional path and required name. /// /// - Parameters: /// - path: The directory path to the executable. Defaults to `"/usr/local/bin"`. /// - name: The name of the command-line executable or script. public init( path: String = "/usr/local/bin", name: String ) { self.path = path self.name = name } /// Decodes a `ContentTransformer` from a decoder, falling back to default path if missing. /// /// - Throws: A decoding error if the required `name` is not present. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) self.path = (try? container.decode(String.self, forKey: .path)) ?? "/usr/local/bin" self.name = try container.decode(String.self, forKey: .name) } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline+Transformers.swift ================================================ // // Pipeline+Transformers.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 21.. // public extension Pipeline { /// Represents a sequence of content transformers to run before rendering, /// along with an indicator of whether the final result is Markdown. struct Transformers: Codable { /// An ordered list of transformers (external commands or scripts) to execute. /// /// Each `ContentTransformer` represents an individual transformation step. public var run: [Transformer] /// Indicates whether the final output from this pipeline is expected to be Markdown. /// /// If `false`, the renderer may treat the output as already-formatted HTML or another format. public var isMarkdownResult: Bool /// Initializes a new `TransformerPipeline`. /// /// - Parameters: /// - run: An array of `ContentTransformer` instances to execute. /// - isMarkdownResult: A flag indicating whether the final output is Markdown. Defaults to `true`. public init( run: [Transformer] = [], isMarkdownResult: Bool = true ) { self.run = run self.isMarkdownResult = isMarkdownResult } } } ================================================ FILE: Sources/ToucanSource/Objects/Pipeline/Pipeline.swift ================================================ // // Pipeline.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 16.. // /// Represents a full content transformation pipeline, /// including scopes, queries, content types, engines, and outputs. /// /// A pipeline defines how data flows from content source to final rendered output. public struct Pipeline: Codable { private enum CodingKeys: CodingKey, CaseIterable { case id case definesType case scopes case queries case dataTypes case contentTypes case iterators case assets case transformers case engine case output } /// Unique identifier for the pipeline. public var id: String /// A Boolean value indicating whether the pipeline defines a virual type. public var definesType: Bool /// A nested map of content type → scope key → scope definition. /// /// This allows for per-content-type rendering rules (e.g., `detail`, `list`, `reference`). public var scopes: [String: [String: Scope]] /// Named query definitions that can be reused in scopes or iterators. public var queries: [String: Query] /// Definitions for global or scoped data types (e.g., formats, types). public var dataTypes: DataTypes /// Definitions for all known content types in the system. public var contentTypes: ContentTypes /// Static and external assets (e.g., JavaScript, CSS, images) used in rendering. public var assets: Assets /// Special iterator queries used for generating repeated content structures (e.g., pages in a list). public var iterators: [String: Query] /// Optional transformation pipelines, applied before rendering. public var transformers: [String: Transformers] /// The rendering engine to use (e.g., HTML, JSON, RSS). public var engine: Engine /// Output configuration for file generation and routing. public var output: Output /// Initializes a fully-defined `Pipeline` object. public init( id: String, definesType: Bool = false, scopes: [String: [String: Scope]] = [:], queries: [String: Query] = [:], dataTypes: DataTypes = .defaults, contentTypes: ContentTypes = .defaults, iterators: [String: Query] = [:], assets: Assets = .defaults, transformers: [String: Transformers] = [:], engine: Engine, output: Output ) { self.id = id self.definesType = definesType self.scopes = scopes self.queries = queries self.dataTypes = dataTypes self.contentTypes = contentTypes self.iterators = iterators self.assets = assets self.transformers = transformers self.engine = engine self.output = output } /// Decodes a pipeline from configuration, merging with defaults where applicable. /// /// Uses `Scope.default` as the baseline for scope resolution. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let id = try container.decode(String.self, forKey: .id) let definesType = try container.decodeIfPresent(Bool.self, forKey: .definesType) ?? false let defaultScopes = Scope.default let userScopes = try container.decodeIfPresent( [String: [String: Scope]].self, forKey: .scopes ) ?? [:] let scopes = defaultScopes.recursivelyMerged(with: userScopes) let queries = try container.decodeIfPresent( [String: Query].self, forKey: .queries ) ?? [:] let dataTypes = try container.decodeIfPresent( DataTypes.self, forKey: .dataTypes ) ?? .defaults let contentTypes = try container.decodeIfPresent( ContentTypes.self, forKey: .contentTypes ) ?? .defaults let iterators = try container.decodeIfPresent( [String: Query].self, forKey: .iterators ) ?? [:] let assets = try container.decodeIfPresent( Assets.self, forKey: .assets ) ?? .defaults let transformers = try container.decodeIfPresent( [String: Transformers].self, forKey: .transformers ) ?? [:] let engine = try container.decode(Engine.self, forKey: .engine) let output = try container.decode(Output.self, forKey: .output) self.init( id: id, definesType: definesType, scopes: scopes, queries: queries, dataTypes: dataTypes, contentTypes: contentTypes, iterators: iterators, assets: assets, transformers: transformers, engine: engine, output: output ) } /// Returns all scopes for a given content type. /// /// If no direct match is found, falls back to the wildcard `*` scopes. /// /// - Parameter contentType: The content type key (e.g., `"post"`). /// - Returns: A map of scope keys (e.g., `"list"`, `"detail"`) to `Scope` values. public func getScopes( for contentType: String ) -> [String: Scope] { if let scopes = scopes[contentType] { return scopes } return scopes[Scope.Keys.wildcard.rawValue] ?? [:] } /// Returns a single scope for a given content type and scope key (e.g., `"list"`, `"detail"`). /// /// Defaults to `.detail` if no specific match is found. /// /// - Parameters: /// - key: The scope key (e.g., `"detail"`, `"reference"`). /// - contentType: The content type key. /// - Returns: A `Scope` object. public func getScope( keyedBy key: String, for contentType: String ) -> Scope { let scopes = getScopes(for: contentType) return scopes[key] ?? .detail } } ================================================ FILE: Sources/ToucanSource/Objects/Property/Property.swift ================================================ // // Property.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Represents a single content property definition, including its type, /// whether it's required, and an optional default value. public struct Property: Codable, Equatable { /// Coding keys used for decoding optional metadata fields. enum CodingKeys: String, CodingKey, CaseIterable { case type case required case defaultValue // NOTE: Multiple types are parsed from the same container. The keys listed below help make validation easier. Refer to `PropertyType` for a related implementation. case config } /// The type of the property (e.g., string, number, boolean, etc.). public var type: PropertyType /// Whether the property is required in the content entry. /// /// Defaults to `true` if not explicitly provided in the definition. public var required: Bool /// An optional default value to use if the property is missing in the content. public var defaultValue: AnyCodable? /// Initializes a new `Property` definition. /// /// - Parameters: /// - propertyType: The declared type of the property. /// - isRequired: Whether the field must be present in content. Defaults to `true` if not specified during decoding. /// - defaultValue: An optional default value to use if the content omits this property. public init( propertyType: PropertyType, isRequired: Bool, defaultValue: AnyCodable? = nil ) { self.type = propertyType self.required = isRequired self.defaultValue = defaultValue } /// Decodes a `Property` from a serialized representation, handling both the /// core type and optional metadata (required flag and default value). /// /// If the `required` field is missing, it defaults to `true`. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let type = try decoder.singleValueContainer().decode(PropertyType.self) let container = try decoder.container(keyedBy: CodingKeys.self) let required = try container.decodeIfPresent(Bool.self, forKey: .required) ?? true let anyValue = try container.decodeIfPresent( AnyCodable.self, forKey: .defaultValue ) self.init( propertyType: type, isRequired: required, defaultValue: anyValue ) } /// Encodes the `Property` into a keyed container /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if encoding fails. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) try type.encode(to: encoder) try container.encode(required, forKey: .required) try container.encodeIfPresent(self.defaultValue, forKey: .defaultValue) } } ================================================ FILE: Sources/ToucanSource/Objects/Property/PropertyType.swift ================================================ // // PropertyType.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Represents the type of a content property. /// /// Used in defining content schemas or type-safe metadata fields. /// Supports primitive types (`bool`, `int`, `double`, `string`, `date`) /// and complex structures like arrays of types. public indirect enum PropertyType: Sendable, Codable, Equatable { /// Boolean type (`true` or `false`). case bool /// Integer type (`Int`). case int /// Floating-point number type (`Double`). case double /// Text/string type (`String`). case string /// Asset reference stored as a string value case asset /// Date type with optional localized formatting. case date(config: DateFormatterConfig?) /// Array type with elements of a consistent `PropertyType`. case array(of: PropertyType) /// Coding keys used for encoding and decoding `PropertyType`. private enum CodingKeys: String, CodingKey { case type // date input config case config // type of array elements case of } /// Type discriminator used during encoding and decoding. private enum TypeKey: String, Sendable, Codable, Equatable, CaseIterable { case bool case int case double case string case asset case date case array } /// Creates a new instance by decoding from the given decoder. /// /// Supports primitive and nested types with optional date formatting. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if decoding fails. public init( from decoder: Decoder ) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(TypeKey.self, forKey: .type) switch type { case .bool: self = .bool case .int: self = .int case .double: self = .double case .string: self = .string case .asset: self = .asset case .date: let config = try container.decodeIfPresent( DateFormatterConfig.self, forKey: .config ) self = .date(config: config) case .array: let itemType = try container.decode(PropertyType.self, forKey: .of) self = .array(of: itemType) } } /// Encodes this value into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if encoding fails. public func encode( to encoder: Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .bool: try container.encode(TypeKey.bool, forKey: .type) case .int: try container.encode(TypeKey.int, forKey: .type) case .double: try container.encode(TypeKey.double, forKey: .type) case .string: try container.encode(TypeKey.string, forKey: .type) case .asset: try container.encode(TypeKey.asset, forKey: .type) case let .date(config): try container.encode(TypeKey.date, forKey: .type) try container.encodeIfPresent(config, forKey: .config) case let .array(of): try container.encode(TypeKey.array, forKey: .type) try container.encode(of, forKey: .of) } } } ================================================ FILE: Sources/ToucanSource/Objects/Property/SystemPropertyKeys.swift ================================================ // // SystemPropertyKeys.swift // Toucan // // Created by Ferenc Viasz-Kadi on 2025. 08. 22.. // /// Represents predefined system property keys used throughout Toucan. public enum SystemPropertyKeys: String, CaseIterable { /// Unique identifier for the object. case id /// Timestamp indicating the last modification date of the object. case lastUpdate /// URL-friendly identifier (slug) for the object. case slug /// The type or category of the object. case type } ================================================ FILE: Sources/ToucanSource/Objects/Query/Condition.swift ================================================ // // Condition.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Represents a logical condition used to filter content during a query. /// /// `Condition` supports both field-based comparisons and compound logic (AND/OR), /// and can be resolved dynamically with parameters at runtime. public enum Condition: Codable, Equatable { /// A condition that compares a content field to a value using an operator. case field(key: String, operator: Operator, value: AnyCodable) /// A logical AND of multiple conditions (all must be true). case and([Condition]) /// A logical OR of multiple conditions (at least one must be true). case or([Condition]) /// Internal keys used for encoding and decoding `Condition` enum cases. private enum CodingKeys: CodingKey, CaseIterable { case key case `operator` case value case and case or } /// Decodes a `Condition` from a decoder, supporting `.field`, `.and`, and `.or` branches. /// /// Throws a decoding error if none of the known variants are valid in the input. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) if let key = try? container.decode(String.self, forKey: .key), let op = try? container.decode(Operator.self, forKey: .operator), let anyValue = try? container.decode( AnyCodable.self, forKey: .value ) { self = .field(key: key, operator: op, value: anyValue) } else if let values = try? container.decode( [Condition].self, forKey: .and ) { self = .and(values) } else if let values = try? container.decode( [Condition].self, forKey: .or ) { self = .or(values) } else { throw DecodingError.dataCorrupted( .init( codingPath: decoder.codingPath, debugDescription: "Invalid data for the Condition type." ) ) } } /// Encodes this `Condition` instance into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if encoding fails. public func encode( to encoder: Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case let .field(key, op, value): try container.encode(key, forKey: .key) try container.encode(op, forKey: .operator) try container.encode(value, forKey: .value) case let .and(conditions): try container.encode(conditions, forKey: .and) case let .or(conditions): try container.encode(conditions, forKey: .or) } } } ================================================ FILE: Sources/ToucanSource/Objects/Query/Direction.swift ================================================ // // Direction.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Represents the direction for sorting query results: ascending or descending. public enum Direction: String, Sendable, Codable, Equatable, CaseIterable { /// Sort in ascending order (e.g., A–Z, 1–9). case asc /// Sort in descending order (e.g., Z–A, 9–1). case desc /// The default sorting direction. Defaults to `.asc`. public static var defaults: Self { .asc } } ================================================ FILE: Sources/ToucanSource/Objects/Query/Operator.swift ================================================ // // Operator.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Represents a comparison or filtering operator used in queries. public enum Operator: String, Sendable, Codable, Equatable, CaseIterable { // bool, int, double, string case equals // bool, int, double, string case notEquals // int, double case lessThan // int, double case lessThanOrEquals // int, double case greaterThan // int, double case greaterThanOrEquals // string case like // string case caseInsensitiveLike // field is a single value check is in array of values // array of int, double, string case `in` // field is an array check contains single value // single value int, double, string case contains // field is an array check intersection with array value // array values both int, double, string case matching } ================================================ FILE: Sources/ToucanSource/Objects/Query/Order.swift ================================================ // // Order.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 15.. // /// Represents a sorting rule for ordering content query results. /// /// Each `Order` defines a content field to sort by and the direction of sorting. public struct Order: Sendable, Codable, Equatable { /// Internal keys used for encoding and decoding `Order` instances. /// Keys used for decoding an `Order` from external sources (e.g., YAML, JSON). enum CodingKeys: CodingKey, CaseIterable { case key case direction } /// The name of the field to sort by (e.g., `"date"`, `"title"`, `"priority"`). public var key: String /// The direction to sort the field (`asc` or `desc`). public var direction: Direction /// Creates a new `Order` instance. /// /// - Parameters: /// - key: The field name to sort by. /// - direction: The sorting direction. Defaults to `.asc`. public init( key: String, direction: Direction = .asc ) { self.key = key self.direction = direction } /// Decodes an `Order` from a decoder. /// /// If the `direction` field is missing, it defaults to `.asc`. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let key = try container.decode(String.self, forKey: .key) let direction = try container.decodeIfPresent(Direction.self, forKey: .direction) ?? .defaults self.init( key: key, direction: direction ) } /// Encodes this `Order` instance into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if encoding fails. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(key, forKey: .key) try container.encode(direction, forKey: .direction) } } ================================================ FILE: Sources/ToucanSource/Objects/Query/Query.swift ================================================ // // Query.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 15.. // /// Represents a content query used to fetch or filter content entries /// based on content type, pagination, sorting, and filtering criteria. public struct Query: Codable, Equatable { /// Keys used to decode the query from a structured format like YAML or JSON. enum CodingKeys: String, CodingKey, CaseIterable { case contentType case scope case limit case offset case filter case orderBy } /// The content type this query targets (e.g., `"blog"`, `"author"`, `"product"`). public var contentType: String /// An optional named scope to apply custom context (e.g., `"homepage"`, `"featured"`). public var scope: String? /// Optional limit for how many items to return. public var limit: Int? /// Optional offset for pagination, defining how many items to skip. public var offset: Int? /// An optional filter condition to narrow results (e.g., field comparison, boolean logic). public var filter: Condition? /// A list of fields and directions for ordering results. public var orderBy: [Order] /// Initializes a `Query` with specified properties. /// /// - Parameters: /// - contentType: The name of the content type being queried. /// - scope: An optional named context or scope for this query. /// - limit: The number of results to limit to. /// - offset: The number of results to skip (for pagination). /// - filter: A filter condition to apply to the results. /// - orderBy: Sorting rules for the query results. public init( contentType: String, scope: String? = nil, limit: Int? = nil, offset: Int? = nil, filter: Condition? = nil, orderBy: [Order] = [] ) { self.contentType = contentType self.scope = scope self.limit = limit self.offset = offset self.filter = filter self.orderBy = orderBy } /// Decodes a `Query` instance from a decoder, applying defaults for optional values. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let contentType = try container.decode( String.self, forKey: .contentType ) let scope = try container.decodeIfPresent(String.self, forKey: .scope) let limit = try container.decodeIfPresent(Int.self, forKey: .limit) let offset = try container.decodeIfPresent(Int.self, forKey: .offset) let filter = try container.decodeIfPresent( Condition.self, forKey: .filter ) let orderBy = try container.decodeIfPresent([Order].self, forKey: .orderBy) ?? [] self.init( contentType: contentType, scope: scope, limit: limit, offset: offset, filter: filter, orderBy: orderBy ) } } ================================================ FILE: Sources/ToucanSource/Objects/Relation/Relation.swift ================================================ // // Relation.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Represents a relationship between a content item and one or more other content items. /// /// A `Relation` defines how content items are connected, such as linking a blog post /// to its author or related articles. It includes the type of relation, /// reference key(s), and optional ordering rules. public struct Relation: Codable, Equatable { /// Keys used to decode the relation from serialized formats like JSON or YAML. enum CodingKeys: CodingKey, CaseIterable { case references case type case order } /// The key or query string that identifies the related content. /// /// This might represent a single ID, a tag filter, or a content type to resolve. public var references: String /// The type of relation, describing how the content is linked (e.g., one-to-one, many-to-one). public var type: RelationType /// Optional sorting logic to apply to related content (e.g., by date or title). public var order: Order? /// Creates a new `Relation` instance with required and optional properties. /// /// - Parameters: /// - references: A string identifying the target or criteria for the relation. /// - relationType: The relation type (e.g., `.single`, `.collection`). /// - order: Optional sorting rules for related content. public init( references: String, relationType: RelationType, order: Order? = nil ) { self.references = references self.type = relationType self.order = order } /// Decodes a `Relation` from a decoder, applying custom key mapping and optional logic. /// /// This ensures that all fields are safely extracted and defaults applied if necessary. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let references = try container.decode(String.self, forKey: .references) let type = try container.decode(RelationType.self, forKey: .type) let order = try container.decodeIfPresent(Order.self, forKey: .order) self.init( references: references, relationType: type, order: order ) } } ================================================ FILE: Sources/ToucanSource/Objects/Relation/RelationType.swift ================================================ // // RelationType.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 21.. // /// Defines the cardinality of a content relation, indicating whether it links to one or multiple items. public enum RelationType: String, Codable, Equatable, CaseIterable { /// A one-to-one relation. The relation targets a single content item. case one /// A one-to-many relation. The relation targets a collection of content items. case many } ================================================ FILE: Sources/ToucanSource/Objects/Settings/Settings.swift ================================================ // // Settings.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 11.. // /// A custom coding key type for encoding and decoding dynamic keys. private struct DynamicCodingKeys: CodingKey { var stringValue: String var intValue: Int? { nil } init?(stringValue: String) { self.stringValue = stringValue } init?(intValue _: Int) { nil } } /// Represents site-wide configuration settings, allowing for dynamic, user-defined values. public struct Settings: Codable, Equatable { /// The default, empty settings instance. public static var defaults: Self { .init([:]) } /// A dictionary holding arbitrary user-defined settings keyed by strings. public var values: [String: AnyCodable] /// Creates a new `Settings` instance with the specified key-value pairs. /// /// - Parameter values: A dictionary of custom settings. public init( _ values: [String: AnyCodable] ) { self.values = values } /// Initializes a `Settings` instance by decoding from the given decoder. /// /// If decoding fails, initializes with default empty settings. /// /// - Parameter decoder: The decoder to read data from. /// - Throws: An error if decoding fails unexpectedly. public init( from decoder: any Decoder ) throws { guard let container = try? decoder.singleValueContainer(), let value = try? container.decode([String: AnyCodable].self) else { self.values = Self.defaults.values return } self.values = value } /// Encodes the `Settings` instance into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any value fails to encode. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: DynamicCodingKeys.self) for (key, value) in values { guard let codingKey = DynamicCodingKeys(stringValue: key) else { continue } try container.encode(value, forKey: codingKey) } } } ================================================ FILE: Sources/ToucanSource/Objects/Target/Target.swift ================================================ // // Target.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 15.. // /// Represents a deployment target configuration for a Toucan project. public struct Target: Codable, Equatable { /// Keys explicitly defined for decoding known fields from the input source. enum CodingKeys: CodingKey, CaseIterable { case name case config case url case input case output case `default` } /// Base values used when decoding fails or fields are missing. private static var base: Self { .init( name: "dev", config: "", url: "http://localhost:3000", input: ".", output: "dist", isDefault: false ) } /// Standard target value public static var standard: Self { var target = Self.base target.isDefault = true return target } /// The unique name of the target. public var name: String /// The path to the configuration file. public var config: String /// The base URL of the site or project without a trailing slash (e.g., `"https://example.com"`). public var url: String /// The input path for the source files. public var input: String /// The output path for generated files. public var output: String /// A flag indicating if this is the default target. public var isDefault: Bool /// Creates a new target configuration. /// - Parameters: /// - name: The unique name of the target. /// - config: The path to the configuration file. /// - url: The base URL for the target. /// - input: The input path for the source files. /// - output: The output path for generated files. /// - isDefault: A flag indicating if this is the default target. public init( name: String, config: String, url: String, input: String, output: String, isDefault: Bool ) { self.name = name self.config = config self.url = url self.input = input self.output = output self.isDefault = isDefault } /// Custom decoder with fallback values. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let base = Self.base let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decodeIfPresent( String.self, forKey: .name ) ?? base.name self.config = try container.decodeIfPresent( String.self, forKey: .config ) ?? base.config self.url = try container.decodeIfPresent( String.self, forKey: .url ) ?? base.url self.input = try container.decodeIfPresent( String.self, forKey: .input ) ?? base.input self.output = try container.decodeIfPresent( String.self, forKey: .output ) ?? base.output self.isDefault = try container.decodeIfPresent( Bool.self, forKey: .default ) ?? base.isDefault } /// Encodes this instance into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any values are invalid for the given encoder’s format. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(name, forKey: .name) try container.encode(config, forKey: .config) try container.encode(url, forKey: .url) try container.encode(input, forKey: .input) try container.encode(output, forKey: .output) try container.encode(isDefault, forKey: .default) } } ================================================ FILE: Sources/ToucanSource/Objects/Target/TargetConfig.swift ================================================ // // TargetConfig.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 15.. // /// A structure that holds a list of deployment targets and resolves the default one. public struct TargetConfig: Codable, Equatable { /// Keys explicitly defined for decoding known fields from the input source. enum CodingKeys: CodingKey, CaseIterable { case targets } /// Default values used when decoding fails or fields are missing. private static var base: Self { .init(targets: [Target.standard]) } /// All defined targets. public var targets: [Target] /// The default target (first one with `isDefault == true`, or first in the list, or fallback). public var `default`: Target { targets.first(where: { $0.isDefault }) ?? targets[0] } /// Creates a new `Targets` object. /// - Parameter targets: An array of deployment targets. /// - Precondition: Only one target may have `isDefault == true`. public init( targets: [Target] ) { let defaultCount = targets.filter(\.isDefault).count precondition( defaultCount <= 1, "Only one target can be marked as default." ) var all = targets if !all.isEmpty, defaultCount == 0 { all[0].isDefault = true } self.targets = all.isEmpty ? Self.base.targets : all } /// Custom decoder with fallback values and default validation. /// /// - Parameter decoder: The decoer used to decode values. /// - Throws: An error if any values are invalid for the given encoder’s format. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try? decoder.container(keyedBy: CodingKeys.self) let all = try container? .decodeIfPresent( [Target].self, forKey: .targets ) ?? [] let defaultCount = all.filter(\.isDefault).count guard defaultCount <= 1 else { throw DecodingError.dataCorrupted( .init( codingPath: container?.codingPath ?? [], debugDescription: "Only one target can be marked as default." ) ) } self.init(targets: all) } /// Encodes this instance into the given encoder. /// /// - Parameter encoder: The encoder to write data to. /// - Throws: An error if any values are invalid for the given encoder’s format. public func encode( to encoder: any Encoder ) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(targets, forKey: .targets) } } ================================================ FILE: Sources/ToucanSource/Objects/Types/ContentType.swift ================================================ // // ContentType.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 15.. // /// Describes a content type definition including schema, relations, and associated queries. /// /// `ContentType` is used to declare how a particular content type (e.g., blog, project, product) /// should be parsed, validated, and queried in the pipeline. public struct ContentType: Codable, Equatable { private enum CodingKeys: CodingKey, CaseIterable { case id case `default` case paths case properties case relations case queries } /// A unique identifier for this content type (e.g., `"blog"`, `"author"`). public var id: String /// Indicates whether this is the default content type fallback. /// /// If `true`, this type will be used only when the content does not explicitly declare its type, /// and no matching `paths` from other types apply. /// /// ⚠️ Only one content type in the system may be marked as `default`; otherwise, an error will occur. public var `default`: Bool /// A list of file path patterns (globs or prefixes) used to associate source files with this content type. /// /// Example: `["posts/**", "blog/*.md"]` public var paths: [String] /// A map of property names to their type definitions. /// /// These represent structured, typed fields such as `title`, `published`, `authorId`, etc. public var properties: [String: Property] /// A map of relation names to their relationship configuration. /// /// These define links to other content (e.g., `author`, `relatedPosts`). public var relations: [String: Relation] /// Named queries that can be used within scopes or as reusable filters for rendering this type. public var queries: [String: Query] /// Creates a new instance. /// /// - Parameters: /// - id: Unique identifier for the content type. /// - default: Whether this is the fallback default type. /// - paths: Glob-like patterns that identify matching content files. /// - properties: Field definitions and types. /// - relations: Definitions of inter-content relationships. /// - queries: Reusable queries for list or scoped views. public init( id: String, default: Bool = false, paths: [String] = [], properties: [String: Property] = [:], relations: [String: Relation] = [:], queries: [String: Query] = [:] ) { self.id = id self.default = `default` self.paths = paths self.properties = properties self.relations = relations self.queries = queries } /// Decode from a structured format (e.g., YAML or JSON), /// applying defaults for missing optional fields. public init( from decoder: any Decoder ) throws { try decoder.validateUnknownKeys(keyType: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self) let id = try container.decode( String.self, forKey: .id ) let `default` = (try? container.decode( Bool.self, forKey: .default )) ?? false let paths = try container.decodeIfPresent( [String].self, forKey: .paths ) ?? [] var properties = try container.decodeIfPresent( [String: Property].self, forKey: .properties ) ?? [:] // Providing system properties properties[SystemPropertyKeys.id.rawValue] = .init( propertyType: .string, isRequired: true ) properties[SystemPropertyKeys.lastUpdate.rawValue] = .init( propertyType: .string, isRequired: true ) properties[SystemPropertyKeys.slug.rawValue] = .init( propertyType: .string, isRequired: true ) properties[SystemPropertyKeys.type.rawValue] = .init( propertyType: .string, isRequired: true ) let relations = try container.decodeIfPresent( [String: Relation].self, forKey: .relations ) ?? [:] let queries = try container.decodeIfPresent( [String: Query].self, forKey: .queries ) ?? [:] self.init( id: id, default: `default`, paths: paths, properties: properties, relations: relations, queries: queries ) } } ================================================ FILE: Sources/_GitCommitHash/git_commit_hash.c ================================================ #include "git_commit_hash.h" const char * git_commit_hash(void) { return GIT_COMMIT_HASH; } ================================================ FILE: Sources/_GitCommitHash/include/git_commit_hash.h ================================================ #if !defined(GIT_COMMIT_HASH_H) #define GIT_COMMIT_HASH_H extern const char * git_commit_hash(void); #endif ================================================ FILE: Sources/toucan/Entrypoint.swift ================================================ // // Entrypoint.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import ArgumentParser import Dispatch import Foundation import SwiftCommand import ToucanCore extension Array { mutating func popFirst() -> Element? { isEmpty ? nil : removeFirst() } } /// The main entry point for the command-line tool. @main struct Entrypoint: AsyncParsableCommand { /// Configuration for the command-line tool. static let configuration = CommandConfiguration( commandName: "toucan", abstract: """ Toucan """, discussion: """ A markdown-based Static Site Generator (SSG) written in Swift. """, version: GeneratorInfo.current.version, helpNames: [] ) @Argument(parsing: .allUnrecognized) var subcommand: [String] func run() async throws { var args = CommandLine.arguments guard args.count > 1, let path = args.popFirst(), let subcommand = args.popFirst() else { fatalError( "Missing arguments, at least one subcommand is required." ) } let base = URL(fileURLWithPath: path).lastPathComponent let toucanCmd = base + "-" + subcommand if subcommand.isEmpty || subcommand == "--help" || subcommand == "-h" { displayHelp() return } if subcommand == "--version" { displayVersion() return } guard let exe = Command.findInPath(withName: toucanCmd) else { fatalError("Subcommand not found: `\(toucanCmd)`.") } let cmd = exe .addArguments(args) .setStdin(.pipe(closeImplicitly: false)) .setStdout(.inherit) .setStderr(.inherit) let subprocess = try cmd.spawn() let signalSource = DispatchSource.makeSignalSource( signal: SIGINT, queue: .main ) signal(SIGINT, SIG_IGN) // Ignore default SIGINT behavior signalSource.setEventHandler { if subprocess.isRunning { subprocess.interrupt() } } signalSource.resume() try subprocess.wait() } private func displayVersion() { print(Self.configuration.version) } private func displayHelp() { print( """ OVERVIEW: \(Self.configuration.abstract) \(Self.configuration.discussion) USAGE: toucan SUBCOMMANDS: init Initializes a new Toucan project generate Build static files using configured targets watch Watch for changes and auto-regenerate output serve Start a local web server to preview the site OPTIONS: --version Show the version. -h, --help Show help information. """ ) } } ================================================ FILE: Sources/toucan-generate/Entrypoint.swift ================================================ // // Entrypoint.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import ArgumentParser import Logging import ToucanCore import ToucanSDK extension Logger.Level: @retroactive ExpressibleByArgument {} /// The main entry point for the command-line tool. @main struct Entrypoint: AsyncParsableCommand { /// Configuration for the command-line tool. static let configuration = CommandConfiguration( commandName: "toucan-generate", abstract: """ Toucan Generate Command """, discussion: """ Generates static files for your website using the selected build target. """, version: GeneratorInfo.current.release.description ) @Argument( help: """ The working directory to look for a `toucan.yml` file. Default: current working directory """ ) var workDir: String = "." @Option( name: .shortAndLong, help: "The target to build, if empty build all." ) var target: String? func run() async throws { let logger = Logger.subsystem("generate") var targetsToBuild: [String] = [] if let target, !target.isEmpty { targetsToBuild.append(target) } let generator = Toucan() if generator.generateAndLogErrors( workDir: workDir, targetsToBuild: targetsToBuild, now: .init() ) { let metadata: Logger.Metadata = [ "workDir": .string(workDir) ] logger.info("Site generated successfully.", metadata: metadata) } } } ================================================ FILE: Sources/toucan-init/Download.swift ================================================ // // Download.swift // Toucan // // Created by Binary Birds on 2025. 03. 31.. import FileManagerKit import Foundation import SwiftCommand struct Download { let id = UUID().uuidString let sourceURL: URL let targetDirURL: URL let fileManager: FileManager private var url: URL { fileManager.temporaryDirectory.appendingPathComponent(id) } private var zipURL: URL { url.appendingPathExtension("zip") } func resolve() async throws { /// Ensure working directory exists try fileManager.createDirectory( at: url, withIntermediateDirectories: true ) let zipURL = url.appendingPathExtension("zip") /// check if the target directory exists if !fileManager.fileExists(at: targetDirURL) { try fileManager.createDirectory( at: targetDirURL, withIntermediateDirectories: true ) } /// Find and run `curl` using SwiftCommand guard let curl = Command.findInPath(withName: "curl") else { fatalError("Command not found: 'curl'") } _ = try await curl .addArguments([ "-L", sourceURL.absoluteString, "-o", zipURL.path, ]) .output /// Find and run `unzip` using SwiftCommand guard let unzipExe = Command.findInPath(withName: "unzip") else { fatalError("Command not found 'unzip'") } _ = try await unzipExe .addArguments([zipURL.path, "-d", url.path]) .output /// Remove existing target directory try? fileManager.removeItem(at: targetDirURL) /// Finding the root directory URL. let items = fileManager.listDirectory(at: url) guard let rootDirName = items.first else { throw URLError(.cannotParseResponse) } let rootDirURL = url.appendingPathComponent(rootDirName) /// Moving files to the target directory. try fileManager.moveItem(at: rootDirURL, to: targetDirURL) /// Cleaning up unnecessary files. try? fileManager.delete(at: zipURL) try? fileManager.delete(at: url) } } ================================================ FILE: Sources/toucan-init/Entrypoint.swift ================================================ // // Entrypoint.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import ArgumentParser import FileManagerKit import Foundation import Logging import ToucanCore import ToucanSource extension Logger.Level: @retroactive ExpressibleByArgument {} /// The main entry point for the command-line tool. @main struct Entrypoint: AsyncParsableCommand { /// Configuration for the command-line tool. static let configuration = CommandConfiguration( commandName: "toucan-init", abstract: """ Toucan Init Command """, discussion: """ Initializes a new Toucan project. Creates required folders and files in the specified directory. """, version: GeneratorInfo.current.release.description ) @Argument(help: "The name of the site directory (default: site).") var siteDirectory: String = "site" @Option( name: .shortAndLong, help: "Specifies a URL to a remote zip file containing a demo project to use as the starting point. If not specified, a minimal setup will be used." ) var demoSourceZipURL: String? func run() async throws { let logger = Logger.subsystem("init") let siteExists = fileManager.directoryExists(at: siteDirectoryURL) guard !siteExists else { logger.error("Folder already exists: \(siteDirectoryURL)") return } do { let sourceUrl = demoSourceZipURL.flatMap { URL(string: $0) } let source = Download( sourceURL: sourceUrl ?? minimalSourceURL, targetDirURL: siteDirectoryURL, fileManager: fileManager ) logger.trace("Preparing files.") try await source.resolve() logger.trace("'\(siteDirectory)' was prepared successfully.") } catch { logger.error("\(String(describing: error))") } } } extension Entrypoint { var fileManager: FileManager { .default } var currentDirectoryURL: URL { URL(fileURLWithPath: fileManager.currentDirectoryPath) } var siteDirectoryURL: URL { currentDirectoryURL.appendingPathComponent(siteDirectory) } var minimalSourceURL: URL { .init( string: "https://github.com/toucansites/minimal-template-demo/archive/refs/heads/main.zip" )! } } ================================================ FILE: Sources/toucan-serve/Entrypoint.swift ================================================ // // Entrypoint.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import ArgumentParser import Foundation import Hummingbird import Logging import ToucanCore extension Logger.Level: @retroactive ExpressibleByArgument {} /// The main entry point for the command-line tool. @main struct Entrypoint: AsyncParsableCommand { /// Configuration for the command-line tool. static let configuration = CommandConfiguration( commandName: "toucan-serve", abstract: """ Toucan Serve Command """, discussion: """ Starts a local web server to serve a specified directory. """, version: GeneratorInfo.current.release.description ) @Argument(help: "The root directory (default: dist).") var root: String = "./dist" @Option(name: .shortAndLong) var address: String = "0.0.0.0" @Option(name: .shortAndLong) var port: Int = 3000 func run() async throws { let home = FileManager.default.homeDirectoryForCurrentUser.path var rootPath = root.replacing("~", with: home) if rootPath.hasPrefix(".") { rootPath = FileManager.default.currentDirectoryPath + "/" + rootPath } let router = Router() let logger = Logger.subsystem("serve") router.addMiddleware { NotFoundMiddleware() FileMiddleware( rootPath, searchForIndexHtml: true, logger: logger ) } let app = Application( router: router, configuration: .init( address: .hostname(address, port: port), serverName: "toucan-server" ), logger: logger ) try await app.runService() } } ================================================ FILE: Sources/toucan-serve/NotFoundMiddleware.swift ================================================ // // NotFoundMiddleware.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 23.. // import Hummingbird struct NotFoundMiddleware: RouterMiddleware { func handle( _ request: Request, context: Context, next: ( Request, Context ) async throws -> Response ) async throws -> Response { do { return try await next(request, context) } catch let error as HTTPError { if error.status == .notFound { return Response( status: .seeOther, headers: [ .location: "/404.html" ] ) } throw error } } } ================================================ FILE: Sources/toucan-watch/Entrypoint.swift ================================================ // // Entrypoint.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import ArgumentParser import FileMonitor import Foundation import Logging import SwiftCommand import ToucanCore extension Logger.Level: @retroactive ExpressibleByArgument {} /// The main entry point for the command-line tool. @main struct Entrypoint: AsyncParsableCommand { /// Configuration for the command-line tool. static let configuration = CommandConfiguration( commandName: "toucan-watch", abstract: """ Toucan Watch Command """, discussion: """ Watches your project directory for changes and automatically rebuilds output files when content is updated. """, version: GeneratorInfo.current.release.description ) @Argument(help: "The input directory (default: current working directory).") var input: String = "." @Option( name: .shortAndLong, help: "The directory to ignore, relative to the working directory (default: dist)." ) var ignore: String = "dist" @Option( name: .shortAndLong, help: "The target to build, if empty build all." ) var target: String? @Option( name: .shortAndLong, help: "The treshold to watch for changes in seconds (default: 3)." ) var seconds: Int = 3 var arguments: [String] { [input] + options } var options: [String] { var options: [String] = [] if let target, !target.isEmpty { options.append("--target") options.append(target) } return options } func run() async throws { let logger = Logger.subsystem("watch") let metadata: Logger.Metadata = [ "input": .string(input), "target": .string(target ?? "(default)"), "ignore": .string(ignore), "treshold": .string(String(seconds)), ] logger.info( "👀 Watching Toucan site.", metadata: metadata ) // // NOTE: To test this feature // // 1. Make sure Toucan is installed somwehere. // 2. Edit scheme in Xcode or use `setenv`, e.g.: // `setenv("PATH", "/usr/local/bin", 1)` // 3. Set a `PATH` environment variable: // `PATH=/usr/local/bin` // let currentToucanCommand = Command.findInPath(withName: "toucan") let toucanCommandUrl = currentToucanCommand?.executablePath.string guard let toucan = toucanCommandUrl, FileManager.default.isExecutableFile(atPath: toucan) else { logger.error( "Toucan is not installed.", metadata: metadata ) return } let inputURL = safeURL(for: input) let commandURL = URL(fileURLWithPath: toucan) let command = Command( executablePath: .init(commandURL.path() + "-generate") ) .addArguments(arguments) let generate = try await command.output.stdout if !generate.isEmpty { logger.debug( .init(stringLiteral: generate), metadata: metadata ) return } let ignoreURL = inputURL.appendingPathIfPresent(ignore) let monitor = try FileMonitor(directory: inputURL) try monitor.start() for await event in monitor.stream.debounce(for: .seconds(seconds)) { let eventPath = event.url.path() guard !eventPath.hasPrefix(ignoreURL.path()) else { logger.trace( "Skipping generation due to ignore path.", metadata: metadata ) continue } logger.info( "Generating site.", metadata: metadata ) let generate = try await command.output.stdout if !generate.isEmpty { logger.debug( .init(stringLiteral: generate), metadata: metadata ) return } } } func safeURL(for path: String) -> URL { let home = FileManager.default.homeDirectoryForCurrentUser.path let replaced = path.replacing("~", with: home) return .init(fileURLWithPath: replaced).standardized } } ================================================ FILE: Tests/ToucanCoreTests/Extensions/StringExtensionsTestSuite.swift ================================================ // // StringExtensionsTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 17.. // import Testing @testable import ToucanCore @Suite struct StringExtensionsTestSuite { // MARK: - URL (slug) validation @Test func specifiedCharactersPass() { let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" let numerics = "0123456789" let special = "-._~{}%" let reserved = ":/?#[]@!$&'()*+,;=" #expect(alphabet.containsOnlyValidURLCharacters()) #expect(numerics.containsOnlyValidURLCharacters()) #expect(special.containsOnlyValidURLCharacters()) #expect(reserved.containsOnlyValidURLCharacters()) } @Test func percentEncodingAllowed() { #expect("%".containsOnlyValidURLCharacters()) #expect("hello%20world".containsOnlyValidURLCharacters()) } @Test func mixedValidStringPasses() { #expect( "https://example.com/a-b_c~d.e?x=1&y=2#frag" .containsOnlyValidURLCharacters() ) } @Test func spaceFails() { #expect(!"hello world".containsOnlyValidURLCharacters()) #expect(!" ".containsOnlyValidURLCharacters()) } @Test func nonASCIIFails() { #expect(!"café".containsOnlyValidURLCharacters()) #expect(!"東京".containsOnlyValidURLCharacters()) #expect(!"🙂".containsOnlyValidURLCharacters()) } @Test func punctuationOutsideSpecFails() { #expect(!"\"".containsOnlyValidURLCharacters()) #expect(!"<".containsOnlyValidURLCharacters()) #expect(!"\\".containsOnlyValidURLCharacters()) #expect(!"{|}".containsOnlyValidURLCharacters()) } @Test func emptyURLStringIsValid() { #expect("".containsOnlyValidURLCharacters()) } @Test func percentTripletStructureNotEnforced() { #expect("%2G".containsOnlyValidURLCharacters()) #expect("%ZZ".containsOnlyValidURLCharacters()) } // MARK: - path validation @Test func validPathCharactersPass() { #expect("documents/swift-guide.txt".containsOnlyValidPathCharacters()) #expect("images/profile_photo-01.png".containsOnlyValidPathCharacters()) #expect("{{page.iterator}}".containsOnlyValidPathCharacters()) #expect("[01](fo_o)-bar:special".containsOnlyValidPathCharacters()) } @Test func disallowedPercentFails() { #expect(!"hello%20world".containsOnlyValidPathCharacters()) } @Test func disallowedQuestionMarkFails() { #expect(!"file?name.txt".containsOnlyValidPathCharacters()) } @Test func disallowedHashFails() { #expect(!"docs#section".containsOnlyValidPathCharacters()) } @Test func disallowedAmpersandFails() { #expect(!"a&b.txt".containsOnlyValidPathCharacters()) } @Test func disallowedEqualsFails() { #expect(!"key=value".containsOnlyValidPathCharacters()) } @Test func emptyPathStringIsValid() { #expect("".containsOnlyValidPathCharacters()) } @Test func mixedValidCharactersPass() { #expect( "folder/sub-folder/file_name-123.txt" .containsOnlyValidPathCharacters() ) } } ================================================ FILE: Tests/ToucanCoreTests/Extensions/URLExtensionsTestSuite.swift ================================================ // // URLExtensionsTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 17.. // import Foundation import Testing @testable import ToucanCore @Suite struct URLExtensionsTestSuite { @Test func appendingValidPath() { let base = URL(string: "https://example.com")! let result = base.appendingPathIfPresent("users") #expect(result.absoluteString == "https://example.com/users") } @Test func appendingEmptyPath() { let base = URL(string: "https://example.com")! let result = base.appendingPathIfPresent("") #expect(result.absoluteString == "https://example.com") } @Test func appendingNilPath() { let base = URL(string: "https://example.com")! let result = base.appendingPathIfPresent(nil) #expect(result.absoluteString == "https://example.com") } } ================================================ FILE: Tests/ToucanCoreTests/ToucanCoreTestSuite.swift ================================================ // // ToucanCoreTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 17.. // import Testing @testable import ToucanCore @Suite struct ToucanCoreTestSuite { @Test() func currentRelease() async throws { // Make sure to update the target release let targetRelease = GeneratorInfo.v1_0_0.release let currentRelease = GeneratorInfo.current.release #expect(targetRelease == currentRelease) } } ================================================ FILE: Tests/ToucanMarkdownTests/ContentRendererTestSuite.swift ================================================ // // ContentRendererTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import Foundation import Logging import Testing @testable import ToucanMarkdown @Suite struct MarkdownRendererTestSuite { @Test func basicRendering() throws { let logger = Logger(label: "ContentRendererTestSuite") let renderer = MarkdownRenderer( configuration: .init( markdown: .init( customBlockDirectives: [ MarkdownBlockDirective.Mocks.faq() ] ), outline: .init( levels: [2, 3] ), readingTime: .init( wordsPerMinute: 238 ), transformerPipeline: nil, paragraphStyles: [:] ), logger: logger ) let input = #""" @FAQ { ## test Lorem ipsum } """# let contents = renderer.render( content: input, typeAwareID: "", slug: "", assetsPath: "", baseURL: "" ) let html = #"""

test

Lorem ipsum

"""# #expect(contents.html == html) #expect( contents.outline == [ .init( level: 2, text: "test", fragment: "test" ) ] ) #expect(contents.readingTime == 1) } } ================================================ FILE: Tests/ToucanMarkdownTests/HTMLVisitorTestSuite.swift ================================================ // // HTMLVisitorTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import Logging import Markdown import Testing @testable import ToucanMarkdown @Suite struct HTMLVisitorTestSuite { func renderHTML( markdown: String, baseURL: String? = nil ) -> String { let document = Document( parsing: markdown, options: [] ) var visitor = HTMLVisitor( blockDirectives: [], paragraphStyles: [ "note": ["note"], "warning": ["warn", "warning"], "tip": ["tip"], "important": ["important"], "error": ["error", "caution"], ], slug: "slug", assetsPath: "assets", baseURL: baseURL ?? "http://localhost:3000" ) return visitor.visit(document) } // MARK: - standard elements @Test func rawHTML() { let input = #"""

https://swift.org

"""# let output = renderHTML(markdown: input) .trimmingCharacters(in: .whitespacesAndNewlines) let expectation = #"""

https://swift.org

"""# .trimmingCharacters(in: .whitespacesAndNewlines) // // with escaping // let expectation = #""" // <p><b>https://swift.org</b></p> // """# // .trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == expectation) } @Test func inlineHTML() { let input = #""" lorem https://swift.org ipsum """# let output = renderHTML(markdown: input) let expectation = #"""

lorem <b>https://swift.org</b> ipsum

"""# #expect(output == expectation) } @Test func paragraph() { let input = #""" Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func softBreak() { let input = #""" This is the first line. And this is the second line. """# let output = renderHTML(markdown: input) let expectation = #"""

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

"""# #expect(output == expectation) } @Test func lineBreak() { let input = #""" a\ b """# let output = renderHTML(markdown: input) let expectation = #"""

a
b

"""# #expect(output == expectation) } @Test func thematicBreak() { let input = #""" Lorem ipsum *** dolor --- sit _________________ amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum


dolor

sit


amet.

"""# #expect(output == expectation) } @Test func strong() { let input = #""" Lorem **ipsum** dolor __sit__ amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func striketrough() { let input = #""" Lorem ipsum ~~dolor sit amet~~. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func blockquote() { let input = #""" > Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func blockquoteNote() { let input = #""" > NOTE: Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func blockquoteWarn() { let input = #""" > WARN: Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func blockquoteWarning() { let input = #""" > warning: Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func nestedBlockquote() { let input = #""" > Lorem ipsum > >> dolor __sit__ amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum

dolor sit amet.

"""# #expect(output == expectation) } @Test func emphasis() { let input = #""" Lorem *ipsum* dolor _sit_ amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } // MARK: - headings @Test func h1() { let input = #""" # Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func h2() { let input = #""" ## Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func h3() { let input = #""" ### Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func h4() { let input = #""" #### Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func h5() { let input = #""" ##### Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""
Lorem ipsum dolor sit amet.
"""# #expect(output == expectation) } @Test func h6() { let input = #""" ###### Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""
Lorem ipsum dolor sit amet.
"""# #expect(output == expectation) } @Test func invalidHeading() { /// NOTE: this should be treated as a paragraph let input = #""" ####### Lorem ipsum dolor sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

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

"""# #expect(output == expectation) } @Test func headingWithCode() { let input = #""" # Lorem ipsum **dolor** `sit` amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem <b>ipsum</b> dolor sit amet.

"""# #expect(output == expectation) } // MARK: - lists @Test func unorderedList() { let input = #""" - foo - bar - baz """# let output = renderHTML(markdown: input) let expectation = #"""
  • foo
  • bar
  • baz
"""# #expect(output == expectation) } @Test func orderedList() { let input = #""" 1. foo 2. bar 3. baz """# let output = renderHTML(markdown: input) let expectation = #"""
  1. foo
  2. bar
  3. baz
"""# #expect(output == expectation) } @Test func orderedListWithStartIndex() { let input = #""" 2. foo 3. bar 4. baz """# let output = renderHTML(markdown: input) let expectation = #"""
  1. foo
  2. bar
  3. baz
"""# #expect(output == expectation) } @Test func listWithCode() { let input = #""" - foo `aaa` - bar - baz """# let output = renderHTML(markdown: input) let expectation = #"""
  • foo aaa
  • bar
  • baz
"""# #expect(output == expectation) } // MARK: - other elements @Test func inlineCode() { let input = #""" Lorem `ipsum dolor` sit amet. """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem ipsum dolor sit amet.

"""# #expect(output == expectation) } @Test func link() { let input = #""" [Swift](https://swift.org/) """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func emptyLink() { let input = #""" [Swift]() """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func dotLink() { let input = #""" [Swift](./foo) """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func slashLink() { let input = #""" [Swift](/foo) """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func externalLink() { let input = #""" [Swift](foo/bar) """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func anchorLink() { let input = #""" [Swift](#anchor) """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func anchorName() { let input = #""" [Swift](#[name]anchor) """# let output = renderHTML(markdown: input) let expectation = #"""

Swift

"""# #expect(output == expectation) } @Test func image() { let input = #""" ![Lorem](lorem.jpg) """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem

"""# #expect(output == expectation) } @Test func imageAssetsPrefix() { let input = #""" ![Lorem](./assets/lorem.jpg) """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem

"""# #expect(output == expectation) } @Test func imageEmptySource() { let input = #""" ![Lorem]() """# let output = renderHTML(markdown: input) let expectation = #"""

"""# #expect(output == expectation) } @Test func imageWithTitle() { let input = #""" ![Lorem](lorem.jpg "Image title") """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem

"""# #expect(output == expectation) } @Test func imageWithEmptyBaseURL() { let input = #""" ![Lorem](/lorem.jpg "Image title") """# let output = renderHTML(markdown: input, baseURL: "") let expectation = #"""

Lorem

"""# #expect(output == expectation) } @Test func imageWithBaseURLMarkdownValue() { let input = #""" ![Lorem](/lorem.jpg) """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem

"""# #expect(output == expectation) } @Test func imageWithBaseURLMarkdownValueNoTraling() { let input = #""" ![Lorem](/lorem.jpg) """# let output = renderHTML(markdown: input) let expectation = #"""

Lorem

"""# #expect(output == expectation) } @Test func codeBlock() { let input = #""" ```js Lorem ipsum dolor sit amet ``` """# let output = renderHTML(markdown: input) let expectation = #"""
Lorem
            ipsum
            dolor
            sit
            amet
            
"""# #expect(output == expectation) } @Test func codeBlockWithHighlight() { let input = #""" ```css Lorem /*!*/ ipsum /*.*/ dolor sit amet ``` """# let output = renderHTML(markdown: input) let expectation = #"""
Lorem
            
                ipsum
            
            dolor
            sit
            amet
            
"""# #expect(output == expectation) } @Test func codeBlockWithHighlightSwift() { let input = #""" ```swift /*!*/func main() -> String/*.*/ { dump("Hello world") return /*!*/"foo"/*.*/ } ``` """# let output = renderHTML(markdown: input) let expectation = #"""
func main() -> String {
                dump("Hello world")
                return "foo"
            }
            
"""# #expect(output == expectation) } @Test func table() { let input = #""" | Item | In Stock | Price | | :---------------- | :------: | ----: | | Python Hat | True | 23.99 | | SQL Hat | True | 23.99 | | Codecademy Tee | False | 19.99 | | Codecademy Hoodie | False | 42.99 | """# let output = renderHTML(markdown: input) let expectation = #"""
ItemIn StockPrice
Python HatTrue23.99
SQL HatTrue23.99
Codecademy TeeFalse19.99
Codecademy HoodieFalse42.99
"""# #expect(output == expectation) } @Test func headingWithAngleBracket() { let input = #""" ## This bracket """# let output = renderHTML(markdown: input) let expectation = #"""

This <is a> bracket

"""# #expect(output == expectation) } @Test func codeWithAngleBracket() { let input = #""" See the `` tag. """# let output = renderHTML(markdown: input) let expectation = #"""

See the <head> tag.

"""# #expect(output == expectation) } } ================================================ FILE: Tests/ToucanMarkdownTests/MarkdownBlockDirective+Mock.swift ================================================ // // MarkdownBlockDirective+Mock.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import Foundation import ToucanMarkdown public extension MarkdownBlockDirective { enum Mocks {} } public extension MarkdownBlockDirective.Mocks { static func highlightedTexts( max: Int = 10 ) -> [MarkdownBlockDirective] { (1...max) .map { i in .init( name: "HighlightedText-\(i)", parameters: nil, requiresParentDirective: nil, removesChildParagraph: nil, tag: "div", attributes: [ .init( name: "class", value: "highlighted-text" ) ], output: nil ) } } static func faq() -> MarkdownBlockDirective { .init( name: "FAQ", parameters: nil, requiresParentDirective: nil, removesChildParagraph: nil, tag: "div", attributes: [ .init(name: "class", value: "faq") ], output: nil ) } static func badDirective() -> MarkdownBlockDirective { .init( name: "BAD", parameters: [ .init( label: "label", isRequired: true ) ], requiresParentDirective: "true", removesChildParagraph: nil, tag: "div", attributes: [ .init(name: "att", value: "none") ], output: nil ) } } ================================================ FILE: Tests/ToucanMarkdownTests/MarkdownBlockDirectiveTestSuite.swift ================================================ // // MarkdownBlockDirectiveTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import Logging import Testing @testable import ToucanMarkdown @Suite struct MarkdownBlockDirectiveTestSuite { @Test func simpleCustomBlockDirective() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ MarkdownBlockDirective.Mocks.faq() ], paragraphStyles: [:] ) let input = #""" @FAQ { Lorem ipsum } """# let output = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) let expectation = #"""

Lorem ipsum

"""# #expect(output == expectation) } @Test func simpleCustomBlockDirectiveUsingOutput() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ .init( name: "FAQ", parameters: nil, requiresParentDirective: nil, removesChildParagraph: nil, tag: nil, attributes: nil, output: #"
{{contents}}
"# ) ], paragraphStyles: [:] ) let input = #""" @FAQ { Lorem ipsum } """# let output = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) let expectation = #"""

Lorem ipsum

"""# #expect(output == expectation) } @Test func customBlockDirectiveParameters() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ .init( name: "Grid", parameters: [ .init( label: "columns", isRequired: true, defaultValue: nil ) ], requiresParentDirective: nil, removesChildParagraph: nil, tag: "div", attributes: [ .init(name: "columns", value: "grid-{{columns}}") ], output: nil ) ], paragraphStyles: [:] ) let input = #""" @Grid(columns: 3) { Lorem ipsum } """# let output = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) let expectation = #"""

Lorem ipsum

"""# #expect(output == expectation) } @Test func customBlockDirectiveParametersUsingOutput() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ .init( name: "Grid", parameters: [ .init( label: "columns", isRequired: true, defaultValue: nil ) ], requiresParentDirective: nil, removesChildParagraph: nil, tag: nil, attributes: nil, output: #"
{{contents}}
"# ) ], paragraphStyles: [:] ) let input = #""" @Grid(columns: 3) { Lorem ipsum } """# let output = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) let expectation = #"""

Lorem ipsum

"""# #expect(output == expectation) } @Test func unrecognizedDirective() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ MarkdownBlockDirective.Mocks.faq() ] ) let input = #""" @unrecognized { Lorem ipsum } """# let output = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) #expect(output == "") } @Test func parseError() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ MarkdownBlockDirective.Mocks.badDirective() ] ) let input = #""" @BAD(columns: bad, columns: bad) { Lorem ipsum } """# _ = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) } @Test func requiredParameterErrors() throws { let renderer = MarkdownToHTMLRenderer( customBlockDirectives: [ MarkdownBlockDirective.Mocks.badDirective() ] ) let input = #""" @BAD() { Lorem ipsum } """# _ = renderer.renderHTML( markdown: input, slug: "", assetsPath: "", baseURL: "" ) } } ================================================ FILE: Tests/ToucanMarkdownTests/OutlineTestSuite.swift ================================================ // // OutlineTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 02. 20.. import Testing @testable import ToucanMarkdown @Suite struct ToucanToCTestSuite { @Test func withoutFragments() async throws { let html = #"""

Lorem ipsum

lorem ipsum dolor sit amet

Dolor sit

lorem ipsum dolor sit amet

Amet

lorem ipsum dolor sit amet

Hello world

lorem ipsum dolor sit amet

foo, bar, baz

lorem ipsum dolor sit amet

"""# let parser = OutlineParser(levels: [2, 3]) let toc = parser.parseHTML(html) #expect(toc.count == 4) } @Test func example() async throws { let html = #"""

Lorem ipsum

lorem ipsum dolor sit amet

Dolor sit

lorem ipsum dolor sit amet

Amet

lorem ipsum dolor sit amet

Hello world

lorem ipsum dolor sit amet

foo, bar, baz

lorem ipsum dolor sit amet

"""# let parser = OutlineParser() let toc = parser.parseHTML(html) #expect(toc.count == 5) } } ================================================ FILE: Tests/ToucanSDKTests/BuildTargetSource/BuildTargetSourceRendererTestSuite.swift ================================================ // // BuildTargetSourceRendererTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 09.. // import Foundation import Logging import Testing import ToucanCore @testable import ToucanSDK import ToucanSource import ToucanSerialization @Suite struct BuildTargetSourceRendererTestSuite { func renderBlock( pipeline: Pipeline, contextBundles: [ContextBundle] ) throws -> [PipelineResult] { let logger = Logger(label: "test") switch pipeline.engine.id { case "json": let renderer = ContextBundleToJSONRenderer( pipeline: pipeline, logger: logger ) return renderer.render(contextBundles) case "mustache": let templateLoader = TemplateLoader( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), fileManager: FileManager.default, encoder: ToucanYAMLEncoder(), decoder: ToucanYAMLDecoder(), logger: logger ) let template = try templateLoader.load() let templateValidator = try TemplateValidator( generatorInfo: .current ) try templateValidator.validate(template) let renderer = try ContextBundleToHTMLRenderer( pipeline: pipeline, templates: template.getViewIDsWithContents(), logger: logger ) return renderer.render(contextBundles) default: throw BuildTargetSourceRendererError.invalidEngine( pipeline.engine.id ) } } // MARK: - api private func getMockAPIBuildTargetSource( now: Date, options: [String: AnyCodable] ) -> BuildTargetSource { let pipelines: [Pipeline] = [ .init( id: "api", definesType: true, scopes: [:], queries: [ "posts": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: "publication", direction: .desc ) ] ) ], dataTypes: .defaults, contentTypes: .init( include: ["api"], exclude: [], lastUpdate: [], filterRules: [:] ), iterators: [ "api.posts.pagination": .init( contentType: "post", limit: 2 ) ], assets: .defaults, transformers: [:], engine: .init( id: "json", options: options ), output: .init( path: "", file: "{{slug}}", ext: "json" ) ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init("api"), slug: "api" ), markdown: .init( frontMatter: [ "type": "api" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ), .init( origin: .init( path: .init("api/posts/{{api.posts.pagination}}"), slug: "api/posts/{{api.posts.pagination}}" ), markdown: .init( frontMatter: [ "type": "api" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ), ] var buildTargetSource = Mocks.buildTargetSource(now: now) // keep only api pipeline, exclude sitemap & rss xml contents buildTargetSource.pipelines = pipelines buildTargetSource.rawContents = buildTargetSource.rawContents.filter { !$0.origin.path.value.hasSuffix("xml") && !$0.origin.path.value.contains("404") } + rawContents return buildTargetSource } @Test func emptyContentTypes() throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ) ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) _ = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } } @Test func wrongRendererEngine() throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), pipelines: [ .init( id: "test", definesType: false, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .defaults, iterators: [:], assets: .defaults, transformers: [:], engine: .init(id: "wrong", options: [:]), output: .init(path: "", file: "context", ext: "json") ) ] ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) do { _ = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } } catch let error as BuildTargetSourceRendererError { switch error { case let .invalidEngine(id): #expect(id == "wrong") default: Issue.record("\(error.logMessage)") } } } @Test() func generatorMetadata() async throws { let now = Date() let config = Config.defaults let target = Target.standard let settings = Settings.defaults let dateFormatter = ToucanOutputDateFormatter( dateConfig: config.dataTypes.date ) let nowISO8601String = dateFormatter.format(now).iso8601 let pipelines: [Pipeline] = [ .init( id: "test", definesType: false, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .defaults, iterators: [:], assets: .defaults, transformers: [:], engine: .init(id: "json", options: [:]), output: .init(path: "", file: "context", ext: "json") ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init(""), slug: "" ), markdown: .init( frontMatter: [ "title": "Home", "description": "Home description", "foo": [ "bar": "baz" ], ], contents: """ # Home Lorem ipsum dolor sit amet """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: config ), target: target, config: config, settings: settings, pipelines: pipelines, types: [ Mocks.ContentTypes.page() ], rawContents: rawContents, blockDirectives: [] ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 1) guard case let .content(value) = results[0].source else { Issue.record("Source type is not a valid content.") return } let decoder = JSONDecoder() struct Exp: Decodable { struct Site: Codable {} let site: Site let generation: DateContext let generator: GeneratorInfo } let data = try #require(value.data(using: .utf8)) let exp = try decoder.decode(Exp.self, from: data) let info = GeneratorInfo.current #expect(exp.generator.name == info.name) #expect(exp.generator.release == info.release) #expect(exp.generation.iso8601 == nowISO8601String) } @Test() func pipelineContentFilter() async throws { let now = Date() let pipelines: [Pipeline] = [ .init( id: "test", definesType: false, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .init( include: [ "test" ], exclude: [], lastUpdate: [], filterRules: [ "*": .field( key: "title", operator: .equals, value: "foo" ) ] ), iterators: [:], assets: .defaults, transformers: [:], engine: .init(id: "json", options: [:]), output: .init(path: "", file: "context", ext: "json") ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init(""), slug: "" ), markdown: .init( frontMatter: [ "type": "test", "title": "Home", "description": "Home description", "foo": [ "bar": "baz" ], ], contents: """ # Home Lorem ipsum dolor sit amet """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] let types: [ContentType] = [ .init( id: "test", default: true, paths: [], properties: [ "title": .init( propertyType: .string, isRequired: true ) ], relations: [:], queries: [:] ) ] var buildTargetSource = Mocks.buildTargetSource(now: now) buildTargetSource.pipelines = pipelines buildTargetSource.rawContents = rawContents buildTargetSource.types = types var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.isEmpty) } @Test() func renderAPIBasics() async throws { let now = Date() let buildTargetSource = getMockAPIBuildTargetSource( now: now, options: [:] ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 3) let contents = results .filter(\.source.isContent) .filter { $0.destination.file == "api" } #expect(contents.count == 1) guard case let .content(value) = contents[0].source, let data = value.data(using: .utf8) else { Issue.record("Source type is not a valid content.") return } struct Expected: Decodable { struct Item: Decodable { let title: String let slug: Slug } struct Context: Decodable { let posts: [Item] } let context: Context } let decoder = JSONDecoder() let result = try decoder.decode(Expected.self, from: data) #expect(result.context.posts.count == 3) } @Test() func renderAPIPagination() async throws { let now = Date() let buildTargetSource = getMockAPIBuildTargetSource( now: now, options: [:] ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 3) let contents = results.filter(\.source.isContent) .filter { $0.destination.file != "api" } #expect(contents.count == 2) for content in contents { guard case let .content(value) = content.source, let data = value.data(using: .utf8) else { Issue.record("Source type is not a valid content.") return } struct Expected: Decodable { struct Item: Decodable { let title: String let slug: Slug } struct Iterator: Decodable { let current: Int let items: [Item] } let iterator: Iterator } let decoder = JSONDecoder() let result = try decoder.decode(Expected.self, from: data) switch result.iterator.current { case 1: #expect(result.iterator.items.count == 2) case 2: #expect(result.iterator.items.count == 1) default: Issue.record("Invalid iterator page.") } } } @Test() func renderAPIWithEngineOptionsKeyPath() async throws { let now = Date() let buildTargetSource = getMockAPIBuildTargetSource( now: now, options: [ "keyPath": "context.posts" ] ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 3) let contents = results.filter(\.source.isContent) .filter { $0.destination.file == "api" } #expect(contents.count == 1) guard case let .content(value) = contents[0].source, let data = value.data(using: .utf8) else { Issue.record("Source type is not a valid content.") return } struct Expected: Decodable { let title: String let slug: Slug } let decoder = JSONDecoder() let result = try decoder.decode([Expected].self, from: data) #expect(result.count == 3) } @Test() func renderAPIWithEngineOptionsMultipleKeyPaths() async throws { let now = Date() let buildTargetSource = getMockAPIBuildTargetSource( now: now, options: [ "keyPaths": [ "context.posts": "items", "generator": "info", ] ] ) var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } let contents = results.filter(\.source.isContent) .filter { $0.destination.file == "api" } #expect(contents.count == 1) guard case let .content(value) = contents[0].source, let data = value.data(using: .utf8) else { Issue.record("Source type is not a valid content.") return } struct Expected: Decodable { struct Item: Decodable { let title: String let slug: Slug } struct Info: Decodable { let name: String let release: String } let items: [Item] let info: Info } let decoder = JSONDecoder() let result = try decoder.decode(Expected.self, from: data) #expect(result.items.count == 3) #expect(result.info.name == "Toucan") #expect( result.info.release == GeneratorInfo.current.release.description ) } // MARK: - contents @Test() func renderAuthor() async throws { let now = Date() var buildTargetSource = Mocks.buildTargetSource(now: now) // keep only html pipeline & one author buildTargetSource.pipelines = buildTargetSource.pipelines.filter { $0.id == "html" } buildTargetSource.rawContents = buildTargetSource.rawContents.filter { $0.origin.slug == "blog/authors/author-1" } var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { // try renderBlock(pipeline: $0, contextBundles: $1) /// We provide the template manually for this test. Skipping parsing and validation. let template = Mocks.Templates.example() let renderer = try ContextBundleToHTMLRenderer( pipeline: $0, templates: template.getViewIDsWithContents(), logger: .init(label: "test") ) return renderer.render($1) } #expect(results.count == 2) let contents = results.filter(\.source.isContent) #expect(contents.count == 1) let assets = results.filter(\.source.isAsset) #expect(assets.count == 1) guard case let .content(value) = contents[0].source else { Issue.record("Source type is not a valid content.") return } #expect(!value.contains("./assets")) guard case let .assetFile(path) = assets[0].source else { Issue.record("Source type is not a valid asset file.") return } #expect(path == "blog/authors/author-1/assets/author-1.jpg") } // MARK: - assets @Test() func assetPropertyAddOne() async throws { let now = Date() let pipelines: [Pipeline] = [ .init( id: "test", definesType: true, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .defaults, iterators: [:], assets: .init( behaviors: [], properties: [ .init( action: .add, property: "css", resolvePath: true, input: .init( path: nil, name: "style", ext: "css" ) ) ] ), transformers: [:], engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init(path: "", file: "context", ext: "json") ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "type": "test", "css": [ "https://test.css" ], ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "style.css" ] ) ] let types: [ContentType] = [] var buildTargetSource = Mocks.buildTargetSource(now: now) buildTargetSource.pipelines = pipelines buildTargetSource.rawContents = rawContents buildTargetSource.types = types var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 1) let contents = results.filter(\.source.isContent) #expect(contents.count == 1) guard case let .content(value) = contents[0].source else { Issue.record("Source type is not a valid content.") return } let decoder = JSONDecoder() struct Exp: Decodable { let css: [String] } let data = try #require(value.data(using: .utf8)) let exp = try decoder.decode(Exp.self, from: data) #expect( exp.css.sorted() == [ "https://test.css", "http://localhost:3000/assets/test/style.css", ] .sorted() ) } @Test() func assetPropertyAddMultiple() async throws { let now = Date() let pipelines: [Pipeline] = [ .init( id: "test", definesType: true, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .defaults, iterators: [:], assets: .init( behaviors: [], properties: [ .init( action: .add, property: "css", resolvePath: false, input: .init( path: nil, name: "*", ext: "css" ) ) ] ), transformers: [:], engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init(path: "", file: "context", ext: "json") ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "type": "test", "css": [ "https://test.css" ], ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "foo.css", "bar.css", ] ) ] let types: [ContentType] = [] var buildTargetSource = Mocks.buildTargetSource(now: now) buildTargetSource.pipelines = pipelines buildTargetSource.rawContents = rawContents buildTargetSource.types = types var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 1) let contents = results.filter(\.source.isContent) #expect(contents.count == 1) guard case let .content(value) = contents[0].source else { Issue.record("Source type is not a valid content.") return } let decoder = JSONDecoder() struct Exp: Decodable { let css: [String] } let data = try #require(value.data(using: .utf8)) let exp = try decoder.decode(Exp.self, from: data) #expect( exp.css.sorted() == [ "https://test.css", "foo.css", "bar.css", ] .sorted() ) } @Test() func assetPropertySetOne() async throws { let now = Date() let pipelines: [Pipeline] = [ .init( id: "test", definesType: true, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .defaults, iterators: [:], assets: .init( behaviors: [], properties: [ .init( action: .set, property: "image", resolvePath: true, input: .init( path: nil, name: "cover", ext: "jpg" ) ) ] ), transformers: [:], engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init(path: "", file: "context", ext: "json") ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "type": "test" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "cover.jpg" ] ) ] let types: [ContentType] = [] var buildTargetSource = Mocks.buildTargetSource(now: now) buildTargetSource.pipelines = pipelines buildTargetSource.rawContents = rawContents buildTargetSource.types = types var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 1) let contents = results.filter(\.source.isContent) #expect(contents.count == 1) guard case let .content(value) = contents[0].source else { Issue.record("Source type is not a valid content.") return } let decoder = JSONDecoder() struct Exp: Decodable { let image: String } let data = try #require(value.data(using: .utf8)) let exp = try decoder.decode(Exp.self, from: data) #expect(exp.image == "http://localhost:3000/assets/test/cover.jpg") } @Test() func assetPropertySetMultiple() async throws { let now = Date() let pipelines: [Pipeline] = [ .init( id: "test", definesType: true, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .defaults, iterators: [:], assets: .init( behaviors: [], properties: [ .init( action: .set, property: "images", resolvePath: true, input: .init( path: nil, name: "*", ext: "png" ) ) ] ), transformers: [:], engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init(path: "", file: "context", ext: "json") ) ] let rawContents: [RawContent] = [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "type": "test" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "cover.jpg", "foo.png", "bar.png", ] ) ] let types: [ContentType] = [] var buildTargetSource = Mocks.buildTargetSource(now: now) buildTargetSource.pipelines = pipelines buildTargetSource.rawContents = rawContents buildTargetSource.types = types var renderer = BuildTargetSourceRenderer( buildTargetSource: buildTargetSource ) let results = try renderer.render(now: now) { try renderBlock(pipeline: $0, contextBundles: $1) } #expect(results.count == 1) let contents = results.filter(\.source.isContent) #expect(contents.count == 1) guard case let .content(value) = contents[0].source else { Issue.record("Source type is not a valid content.") return } let decoder = JSONDecoder() struct Exp: Decodable { let images: [String: String] } let data = try #require(value.data(using: .utf8)) let exp = try decoder.decode(Exp.self, from: data) #expect(exp.images.keys.sorted() == ["foo", "bar"].sorted()) #expect( exp.images.values.sorted() == [ "http://localhost:3000/assets/test/foo.png", "http://localhost:3000/assets/test/bar.png", ] .sorted() ) } } ================================================ FILE: Tests/ToucanSDKTests/BuildTargetSource/BuildTargetSourceValidatorTestSuite.swift ================================================ // // BuildTargetSourceValidatorTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 23.. // // import Foundation import Logging import Testing import ToucanCore @testable import ToucanSDK import ToucanSource @Suite struct BuildTargetSourceValidatorTestSuite { @Test func emptyContentTypes() throws { let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ) ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) do { try validator.validate() Issue.record("Should trigger an error.") } catch { guard case .noDefaultContentType = error else { Issue.record("Invalid error.") return } } } @Test func noDefaultContentType() throws { let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init(id: "foo"), .init(id: "bar"), ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) do { try validator.validate() Issue.record("Should trigger an error.") } catch { guard case .noDefaultContentType = error else { Issue.record("Invalid error.") return } } } @Test func multipleDefaultContentTypes() throws { let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "foo", default: true ), .init( id: "bar", default: true ), ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) do { try validator.validate() Issue.record("Should trigger an error.") } catch { guard case let .multipleDefaultContentTypes(values) = error else { Issue.record("Invalid error.") return } #expect(values == ["foo", "bar"].sorted()) } } @Test func invalidOriginPath() throws { let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "page", default: true ) ], rawContents: [ .init( origin: .init( path: .init("foo?bar"), slug: "foo-bar" ), lastModificationDate: Date().timeIntervalSinceNow, assetsPath: "", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) do { try validator.validate() Issue.record("Should trigger an error.") } catch { guard case let .invalidRawContentOriginPath(path) = error else { Issue.record("Invalid error.") return } #expect(path == "foo?bar") } } } ================================================ FILE: Tests/ToucanSDKTests/Content/ContentQueryTestSuite.swift ================================================ // // ContentQueryTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. // import Foundation import Testing import ToucanCore @testable import ToucanSDK import ToucanSerialization import ToucanSource @Suite struct ContentQueryTestSuite { func getMockContents(now: Date) throws -> [Content] { let buildTargetSource = Mocks.buildTargetSource(now: now) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let converter = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) return try converter.convert( rawContents: buildTargetSource.rawContents ) } @Test func limitOffsetOne() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", limit: 1, offset: 1 ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #2" ) } @Test func limitOffsetTwo() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", limit: 2, offset: 1 ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #2" ) #expect( results[1].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func equalsFilterString() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .equals, value: .init("Author #3") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func filterInt() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "age", operator: .greaterThan, value: 22 ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func filterDouble() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: SystemPropertyKeys.lastUpdate.rawValue, operator: .lessThanOrEquals, value: .init(now.timeIntervalSince1970) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 3) } @Test func equalsFilterNoResults() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "age", operator: .equals, value: .init(666) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 0) } @Test func notEqualsFilter() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .notEquals, value: .init("Author #1") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #2" ) #expect( results[1].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func lessThanFilter() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "category", filter: .field( key: "order", operator: .lessThan, value: .init(3) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["title"]?.value(as: String.self) == "Category #1" ) #expect( results[1].properties["title"]?.value(as: String.self) == "Category #2" ) } @Test func lessThanOrEqualsFilterInt() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "category", filter: .field( key: "order", operator: .lessThanOrEquals, value: .init(3) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 3) #expect( results[0].properties["title"]?.value(as: String.self) == "Category #1" ) #expect( results[1].properties["title"]?.value(as: String.self) == "Category #2" ) #expect( results[2].properties["title"]?.value(as: String.self) == "Category #3" ) } @Test func lessThanOrEqualsFilterDouble() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "rating", operator: .lessThanOrEquals, value: .init(1.5) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["title"]?.value(as: String.self) == "Post #1" ) } @Test func lessThanOrEqualsFilterString() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .lessThanOrEquals, value: .init("Author #2") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #1" ) #expect( results[1].properties["name"]?.value(as: String.self) == "Author #2" ) } @Test func greaterThanFilter() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "category", filter: .field( key: "order", operator: .greaterThan, value: .init(2) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["title"]?.value(as: String.self) == "Category #3" ) } @Test func greaterThanOrEqualsFilterInt() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "category", filter: .field( key: "order", operator: .greaterThanOrEquals, value: .init(2) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["title"]?.value(as: String.self) == "Category #2" ) #expect( results[1].properties["title"]?.value(as: String.self) == "Category #3" ) } @Test func greaterThanOrEqualsFilterDouble() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "rating", operator: .greaterThanOrEquals, value: .init(2.0) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["title"]?.value(as: String.self) == "Post #2" ) #expect( results[1].properties["title"]?.value(as: String.self) == "Post #3" ) } @Test func greaterThanOrEqualsFilterString() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .greaterThanOrEquals, value: .init("Author #3") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func greaterThanNoResult() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "age", operator: .greaterThanOrEquals, value: .init("value") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) #expect(results.count == 0) } @Test func equalsFilterWithOrConditionAndOrderByDesc() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .or([ .field( key: "name", operator: .equals, value: .init("Author #1") ), .field( key: "name", operator: .equals, value: .init("Author #3") ), ]), orderBy: [ .init(key: "name", direction: .desc) ] ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #3" ) #expect( results[1].properties["name"]?.value(as: String.self) == "Author #1" ) } @Test func equalsFilterWithAndConditionEmptyresults() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .and([ .field( key: "name", operator: .equals, value: .init("Author 1") ), .field( key: "name", operator: .equals, value: .init("Author 3") ), ]), orderBy: [ .init(key: "name", direction: .desc) ] ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) #expect(results.isEmpty) } @Test func equalsFilterWithAndConditionMultipleProperties() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .and([ .field( key: "name", operator: .equals, value: .init("Author #2") ), .field( key: "description", operator: .like, value: .init("Author #2 desc") ), ]), orderBy: [ .init(key: "name", direction: .desc) ] ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #2" ) } @Test func equalsFilterWithInStringValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .in, value: .init(["Author #2", "Author #3"]) ), orderBy: [ .init(key: "name") ] ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #2" ) #expect( results[1].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func equalsFilterWithInIntValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "age", operator: .in, value: .init([21, 42]) ), orderBy: [ .init(key: "name") ] ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #2" ) #expect( results[1].properties["name"]?.value(as: String.self) == "Author #3" ) } @Test func equalsFilterWithIndoubleValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "rating", operator: .in, value: .init([1.0, 3.0]) ), orderBy: [ .init(key: "title") ] ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) #expect( results[0].properties["title"]?.value(as: String.self) == "Post #1" ) #expect( results[1].properties["title"]?.value(as: String.self) == "Post #3" ) } @Test func likeFilter() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .like, value: .init("Auth") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 3) } @Test func likeFilterWrongValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .like, value: .init(100) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 0) } @Test func caseInsensitiveLikeFilter() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .caseInsensitiveLike, value: .init("author #1") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) #expect( results[0].properties["name"]?.value(as: String.self) == "Author #1" ) } @Test func caseInsensitiveLikeFilterWrongValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "author", filter: .field( key: "name", operator: .caseInsensitiveLike, value: .init(100) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 0) } @Test func containsStringValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "authors", operator: .contains, value: .init("author-1") ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) } @Test func equalsDoubleValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "rating", operator: .equals, value: .init(1.0) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 1) } @Test func inDoubleValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "rating", operator: .in, value: [2.0, 3.0] ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) } @Test func containsNoValue() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "rating", operator: .contains, value: .init(666) ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 0) } @Test func matchingWithString() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "authors", operator: .matching, value: ["author-1", "author-2"] ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 3) } @Test func matching() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "authors", operator: .matching, value: ["author-1"] ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 2) } @Test func matchingWithNoResult() async throws { let now = Date() let contents = try getMockContents(now: now) let query = Query( contentType: "post", filter: .field( key: "authors", operator: .matching, value: ["author-4"] ) ) let results = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results.count == 0) } @Test func nextPost() async throws { let now = Date() let contents = try getMockContents(now: now) let pastDate = now .addingTimeInterval(-86400 * 2) .addingTimeInterval(-1) let query1 = Query( contentType: "post", filter: .field( key: "publication", operator: .greaterThan, value: .init(pastDate.timeIntervalSince1970) ), orderBy: [ .init( key: "publication", direction: .asc ) ] ) let results1 = contents.run( query: query1, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results1.count == 2) #expect( results1[0].properties["title"]?.value(as: String.self) == "Post #2" ) #expect( results1[1].properties["title"]?.value(as: String.self) == "Post #1" ) let query = Query( contentType: "post", limit: 1, filter: .field( key: "publication", operator: .greaterThan, value: .init(pastDate.timeIntervalSince1970) ), orderBy: [ .init( key: "publication", direction: .desc ) ] ) let results2 = contents.run( query: query, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results2.count == 1) #expect( results1[0].properties["title"]?.value(as: String.self) == "Post #2" ) } @Test func nextGuide() async throws { let now = Date() let contents = try getMockContents(now: now) let query1 = Query( contentType: "guide", filter: .and( [ .field( key: "order", operator: .greaterThan, value: 8 ), .field( key: "category", operator: .equals, value: "category-3" ), ] ), orderBy: [ .init( key: "order", direction: .asc ) ] ) let results1 = contents.run( query: query1, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results1.count == 1) } @Test func resolveFilterParametersUsingID() async throws { let now = Date() let contents = try getMockContents(now: now) let query1 = Query( contentType: "guide", filter: .field( key: "category", operator: .equals, value: "{{id}}" ), orderBy: [ .init( key: "order", direction: .asc ) ] ) .resolveFilterParameters( with: [ "id": "category-1" ] ) let results1 = contents.run( query: query1, now: now.timeIntervalSince1970, logger: .init(label: "ContentQueryTestSuite") ) try #require(results1.count == 3) } } ================================================ FILE: Tests/ToucanSDKTests/Content/ContentResolverTestSuite.swift ================================================ // // ContentResolverTestSuite.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 02. 20.. // // import Foundation import Logging import Testing import ToucanCore @testable import ToucanSDK import ToucanSerialization import ToucanSource @Suite struct ContentResolverTestSuite { // MARK: - private func getMockresolver( buildTargetSource: BuildTargetSource, now _: Date ) throws -> ContentResolver { let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let dateFormatter = ToucanInputDateFormatter( dateConfig: buildTargetSource.config.dataTypes.date ) return .init( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: dateFormatter ) } @Test func contentBasicConversion() throws { let now = Date() let buildTargetSource = Mocks.buildTargetSource(now: now) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let dateFormatter = ToucanInputDateFormatter( dateConfig: buildTargetSource.config.dataTypes.date ) let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: dateFormatter ) let targetContents = try resolver.convert( rawContents: buildTargetSource.rawContents ) #expect(!targetContents.isEmpty) #expect(buildTargetSource.rawContents.count == targetContents.count) for rawContent in buildTargetSource.rawContents { guard let item = targetContents.first( where: { $0.rawValue == rawContent } ) else { Issue.record("Missing content `\(rawContent.origin.slug)`.") return } #expect(!item.isIterator) let notFoundPages = ["404"] let specialPages = ["", "about", "context"] let redirectPages = ["home-old", "about-old"] // check type identifiers if !(specialPages + redirectPages + notFoundPages) .contains(item.rawValue.origin.slug) { #expect(item.rawValue.origin.slug.contains(item.type.id)) } else { if specialPages.contains(item.rawValue.origin.slug) { #expect(item.type.id == "page") } else if notFoundPages.contains(item.rawValue.origin.slug) { #expect(item.type.id == "not-found") } else { #expect(item.type.id == "redirect") } } } } // MARK: - content types @Test func defaultContentType() throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "page", default: true ), .init( id: "post" ), ], rawContents: [ .init( origin: .init( path: .init("hello"), slug: "hello" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) let targetContents = try resolver.convert( rawContents: buildTargetSource.rawContents ) #expect(targetContents.count == 1) let content = try #require(targetContents.first) #expect(content.type.id == "page") } @Test func explicitContentType() throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "page", default: true ), .init( id: "post", paths: [ "posts" ] ), ], rawContents: [ .init( origin: .init( path: .init("posts/hello"), slug: "posts/hello" ), markdown: .init( frontMatter: [ "type": "post" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) let targetContents = try resolver.convert( rawContents: buildTargetSource.rawContents ) #expect(targetContents.count == 1) let content = try #require(targetContents.first) #expect(content.type.id == "post") } @Test func pathBasedContentType() throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "page", default: true ), .init( id: "post", paths: [ "posts" ] ), ], rawContents: [ .init( origin: .init( path: .init("posts/hello"), slug: "posts/hello" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) let targetContents = try resolver.convert( rawContents: buildTargetSource.rawContents ) #expect(targetContents.count == 1) let content = try #require(targetContents.first) #expect(content.type.id == "post") } @Test() func missingContentType() async throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "page", default: true ) ], rawContents: [ .init( origin: .init( path: .init("foo/bar"), slug: "foo/bar" ), markdown: .init( frontMatter: [ "type": "foo" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) do { _ = try resolver.convert( rawContents: buildTargetSource.rawContents ) Issue.record("Should result in an missing content type error.") } catch { switch error { case let .contentType(typeError): switch typeError { case let .missingContentType(id, path): #expect(id == "foo") #expect(path == "foo/bar") default: Issue.record("Invalid content type error result.") } default: Issue.record("Invalid content resolver error result.") } } } @Test() func allPropertyTypeConversion() async throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "test", default: true, properties: [ "string": .init( propertyType: .string, isRequired: true, defaultValue: nil ), "bool": .init( propertyType: .bool, isRequired: true, defaultValue: .init(2) ), "int": .init( propertyType: .int, isRequired: true, defaultValue: .init(2) ), "double": .init( propertyType: .double, isRequired: true, defaultValue: nil ), "date": .init( propertyType: .date( config: nil ), isRequired: true, defaultValue: nil ), "array": .init( propertyType: .array( of: .string ), isRequired: true, defaultValue: nil ), ] ) ], rawContents: [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "string": .init("foo"), "bool": .init(true), "int": .init(42), "double": .init(3.14), "date": .init("2025-03-30T09:23:14.870Z"), "array": .init(["1", "2"]), ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) let targetContents = try resolver.convert( rawContents: buildTargetSource.rawContents ) #expect(targetContents.count == 1) let result = try #require(targetContents.first).properties #expect(result.count == 6) #expect(result["string"] == "foo") #expect(result["bool"] == true) #expect(result["int"] == 42) #expect(result["double"] == 3.14) #expect(result["date"] == 1_743_326_594.87) #expect(result["array"]?.value(as: [String].self) == ["1", "2"]) } @Test() func contentDatePropertyConversion() async throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "definition", default: true, properties: [ "defaultFormat": .init( propertyType: .date( config: nil ), isRequired: true ), "customFormat": .init( propertyType: .date( config: .init( localization: .defaults, format: "y-MM-d" ) ), isRequired: true ), "customFormatDefaultValue": .init( propertyType: .date( config: .init( localization: .defaults, format: "y-MM-d" ) ), isRequired: true, defaultValue: .init("2021-03-03") ), ] ) ], rawContents: [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "defaultFormat": "2025-03-30T09:23:14.870Z", "customFormat": "2021-03-05", ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) let targetContents = try resolver.convert( rawContents: buildTargetSource.rawContents ) #expect(targetContents.count == 1) let result = try #require(targetContents.first).properties #expect( result["customFormat"] == .init(1_614_902_400.0) ) #expect( result["customFormatDefaultValue"] == .init(1_614_729_600.0) ) #expect( result["defaultFormat"] == .init(1_743_326_594.87) ) } @Test() func contentDatePropertyConversionInvalidValue() async throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "test", default: true, properties: [ "monthAndDay": .init( propertyType: .date( config: .init( localization: .defaults, format: "MM-d" ) ), isRequired: true ) ] ) ], rawContents: [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "monthAndDay": .init("2021-03-05") ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) do { _ = try resolver.convert( rawContents: buildTargetSource.rawContents ) Issue.record("Should result in an invalid property error.") } catch { switch error { case let .invalidProperty(name, value, slug): #expect(name == "monthAndDay") #expect(value == "2021-03-05") #expect(slug == "test") default: Issue.record("Invalid error result.") } } } @Test() func contentDatePropertyConversionInvalidValueWithDefaultValue() async throws { let now = Date() let buildTargetSource = BuildTargetSource( locations: .init( sourceURL: .init(filePath: ""), config: .defaults ), types: [ .init( id: "test", default: true, properties: [ "monthAndDay": .init( propertyType: .date( config: .init( localization: .defaults, format: "MM-d" ) ), isRequired: true, defaultValue: .init("03-30") ) ] ) ], rawContents: [ .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "monthAndDay": .init("2021-03-05") ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) ] ) let validator = BuildTargetSourceValidator( buildTargetSource: buildTargetSource ) try validator.validate() let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let resolver = ContentResolver( contentTypeResolver: .init( types: buildTargetSource.types, pipelines: buildTargetSource.pipelines ), encoder: encoder, decoder: decoder, dateFormatter: .init( dateConfig: buildTargetSource.config.dataTypes.date ) ) do { _ = try resolver.convert( rawContents: buildTargetSource.rawContents ) Issue.record("Should result in an invalid property error.") } catch { switch error { case let .invalidProperty(name, value, slug): #expect(name == "monthAndDay") #expect(value == "2021-03-05") #expect(slug == "test") default: Issue.record("Invalid error result.") } } } @Test() func genericFilterRules() async throws { let now = Date() let buildTargetSource = Mocks.buildTargetSource(now: now) let resolver = try getMockresolver( buildTargetSource: buildTargetSource, now: now ) let contents = try resolver.convert( rawContents: buildTargetSource.rawContents ) let result = resolver.apply( filterRules: [ "*": .or( [ .field( key: "title", operator: .like, value: "1" ), .field( key: "name", operator: .like, value: "1" ), ] ) ], to: contents, now: now.timeIntervalSince1970 ) let expGroups = Dictionary( grouping: contents, by: { $0.type.id } ) let resGroups = Dictionary( grouping: result, by: { $0.type.id } ) #expect(result.count < contents.count) for key in expGroups.keys { let exp1 = expGroups[key]? .filter { $0.properties["title"]?.stringValue()?.hasSuffix("1") ?? $0.properties["name"]?.stringValue()?.hasSuffix("1") ?? false } ?? [] let res1 = resGroups[key]? .filter { $0.properties["title"]?.stringValue()?.hasSuffix("1") ?? $0.properties["name"]?.stringValue()? .hasSuffix("1") ?? false } ?? [] #expect(res1.count == exp1.count) for i in 0.. Test site name Test site description http://localhost:3000 en-US \#(nowString) \#(nowString) 250 http://localhost:3000/blog/posts/post-1/ <![CDATA[ Post #1 ]]> http://localhost:3000/blog/posts/post-1/ \#(post1date) http://localhost:3000/blog/posts/post-2/ <![CDATA[ Post #2 ]]> http://localhost:3000/blog/posts/post-2/ \#(post2date) http://localhost:3000/blog/posts/post-3/ <![CDATA[ Post #3 ]]> http://localhost:3000/blog/posts/post-3/ \#(post3date) """# #expect( rss.trimmingCharacters(in: .whitespacesAndNewlines) == expectation.trimmingCharacters( in: .whitespacesAndNewlines ) ) } } @Test func sitemap() throws { let now = Date() try FileManagerPlayground { Mocks.E2E.src(now: now) } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let sitemapXML = distURL.appendingPathIfPresent("sitemap.xml") let sitemap = try String(contentsOf: sitemapXML, encoding: .utf8) let formatter = ToucanOutputDateFormatter( dateConfig: Config.defaults.dataTypes.date, pipelineDateConfig: Mocks.Pipelines.sitemap().dataTypes.date ) let nowString = formatter.format(now).formats["sitemap"] ?? "" let expectation = #""" http://localhost:3000/blog/posts/pages/1/ \#(nowString) http://localhost:3000/blog/posts/pages/2/ \#(nowString) http://localhost:3000/pages/page-3/ \#(nowString) http://localhost:3000/pages/page-2/ \#(nowString) http://localhost:3000/pages/page-1/ \#(nowString) http://localhost:3000/context/ \#(nowString) http://localhost:3000/about/ \#(nowString) http://localhost:3000/ \#(nowString) http://localhost:3000/blog/posts/post-1/ \#(nowString) http://localhost:3000/blog/posts/post-2/ \#(nowString) http://localhost:3000/blog/posts/post-3/ \#(nowString) http://localhost:3000/blog/authors/author-3/ \#(nowString) http://localhost:3000/blog/authors/author-2/ \#(nowString) http://localhost:3000/blog/authors/author-1/ \#(nowString) http://localhost:3000/blog/tags/tag-3/ \#(nowString) http://localhost:3000/blog/tags/tag-2/ \#(nowString) http://localhost:3000/blog/tags/tag-1/ \#(nowString) """# #expect( sitemap.trimmingCharacters(in: .whitespacesAndNewlines) == expectation.trimmingCharacters( in: .whitespacesAndNewlines ) ) } } @Test func redirect() throws { let now = Date() try FileManagerPlayground { Mocks.E2E.src(now: now) } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let redirect1URL = distURL.appendingPathIfPresent( "redirects/home-old/index.html" ) let redirect1 = try String( contentsOf: redirect1URL, encoding: .utf8 ) let expectation1 = #""" Redirecting…

Redirecting…

Click here if you are not redirected. """# #expect( redirect1.trimmingCharacters(in: .whitespacesAndNewlines) == expectation1.trimmingCharacters( in: .whitespacesAndNewlines ) ) let redirect2URL = distURL.appendingPathIfPresent( "redirects/about-old/index.html" ) let redirect2 = try String( contentsOf: redirect2URL, encoding: .utf8 ) let expectation2 = #""" Redirecting…

Redirecting…

Click here if you are not redirected. """# #expect( redirect2.trimmingCharacters(in: .whitespacesAndNewlines) == expectation2.trimmingCharacters( in: .whitespacesAndNewlines ) ) } } // MARK: - other tests @Test func customContextViewForAllPipeline() throws { let now = Date() try FileManagerPlayground { Mocks.E2E.src( now: now, debugContext: #""" {{page.description}} """# ) } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let htmlURL = distURL.appendingPathIfPresent("context/index.html") let html = try String(contentsOf: htmlURL, encoding: .utf8) let exp = "Context page description" #expect(html.trimmingCharacters(in: .whitespacesAndNewlines) == exp) } } // MARK: - assets private func mockSiteYAMLFile() -> YAMLFile { .init( name: "site", contents: Settings( [ "name": "Test site name", "description": "Test site description", "language": "en-US", ] ) ) } private func mockTestTypes() -> Directory { Directory(name: "types") { YAMLFile( name: "test", contents: ContentType( id: "test", default: true ) ) } } @Test func loadOneSVGFile() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( properties: [ .init( action: .load, property: "icon", resolvePath: false, input: .init( path: nil, name: "icon", ext: "svg" ) ) ] ), engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { RawContentBundle( name: "test", rawContent: .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "type": "test" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "icon.svg", "foo.svg", "bar.svg", ] ) ) } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") // let context = try String(contentsOf: contextURL, encoding: .utf8) let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { let icon: String } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.icon == "icon.svg") } } @Test func loadMultipleSVGFiles() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( properties: [ .init( action: .load, property: "icons", resolvePath: false, input: .init( path: nil, name: "*", ext: "svg" ) ) ] ), transformers: [:], engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { RawContentBundle( name: "test", rawContent: .init( origin: .init( path: .init("test"), slug: "test" ), markdown: .init( frontMatter: [ "type": "test" ] ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "cover.jpg", "foo.svg", "bar.svg", ] ) ) } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") // let context = try String(contentsOf: contextURL, encoding: .utf8) let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { let icons: [String: String] } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.icons.keys.sorted() == ["foo", "bar"].sorted()) #expect( exp.icons.values.sorted() == [ "foo.svg", "bar.svg", ] .sorted() ) } } @Test func parseOneDataFile() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( properties: [ .init( action: .parse, property: "data", resolvePath: false, input: .init( path: nil, name: "data", ext: "yaml" ) ) ] ), engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { Directory(name: "assets") { File( name: "data.yaml", string: """ foo: value1 bar: value2 """ ) } MarkdownFile( name: "index", markdown: .init( frontMatter: [ "type": "test" ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") // let context = try String(contentsOf: contextURL, encoding: .utf8) let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { let data: [String: String] } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.data["foo"] == "value1") #expect(exp.data["bar"] == "value2") } } @Test func parseMultipleDataFile() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( properties: [ .init( action: .parse, property: "data", resolvePath: false, input: .init( path: nil, name: "*", ext: "yaml" ) ) ] ), engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { Directory(name: "assets") { File( name: "foo.yaml", string: """ foo: value1 """ ) File( name: "bar.yaml", string: """ bar: value2 """ ) } MarkdownFile( name: "index", markdown: .init( frontMatter: [ "type": "test" ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") // let context = try String(contentsOf: contextURL, encoding: .utf8) let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { let data: [String: [String: String]] } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.data["foo"]?["foo"] == "value1") #expect(exp.data["bar"]?["bar"] == "value2") } } // MARK: - asset behaviors @Test func minifyCSSAsset() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( behaviors: [ .init( id: "minify-css", input: .init( name: "style", ext: "css" ), output: .init( name: "style.min", ext: "css" ) ) ] ), engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { Directory(name: "assets") { File( name: "style.css", string: """ html { margin: 0; padding: 0; } body { background: red; } """ ) } MarkdownFile( name: "index", markdown: .init( frontMatter: [ "type": "test" ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let cssURL = distURL.appendingPathIfPresent( "assets/test/style.min.css" ) let css = try String(contentsOf: cssURL, encoding: .utf8) #expect( css.contains( "html{margin:0;padding:0}body{background:red}" ) ) } } @Test func sASSAsset() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( behaviors: [ .init( id: "compile-sass", input: .init( name: "style", ext: "sass" ), output: .init( name: "style", ext: "css" ) ) ] ), engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { Directory(name: "assets") { File( name: "style.sass", string: """ $font-stack: Helvetica, sans-serif $primary-color: #333 body font: 100% $font-stack color: $primary-color """ ) } MarkdownFile( name: "index", markdown: .init( frontMatter: [ "type": "test" ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let cssURL = distURL.appendingPathIfPresent( "assets/test/style.css" ) let css = try String(contentsOf: cssURL, encoding: .utf8) #expect( css.contains( """ body { font: 100% Helvetica, sans-serif; color: #333; } """ ) ) } } @Test func sCSSModuleLoader() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", assets: .init( behaviors: [ .init( id: "compile-sass", input: .init( name: "style", ext: "scss" ), output: .init( name: "style", ext: "css" ) ) ] ), engine: .init( id: "json", options: [ "keyPath": "page" ] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { Directory(name: "assets") { File( name: "_colors.scss", string: """ $primary: blue; """ ) File( name: "style.scss", string: """ @use "colors"; body { color: colors.$primary; } """ ) } MarkdownFile( name: "index", markdown: .init( frontMatter: [ "type": "test" ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let cssURL = distURL.appendingPathIfPresent( "assets/test/style.css" ) let css = try String(contentsOf: cssURL, encoding: .utf8) #expect( css.contains( """ body { color: blue; } """ ) ) } } // MARK: - custom view @Test func customView() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "html", contents: Pipeline( id: "html", engine: .init( id: "mustache", options: [ "contentTypes": [ "test": [ ViewFrontMatterKeys.view.rawValue: "foo" ] ] ] ), output: .init( path: "{{slug}}", file: "index", ext: "html" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { File( name: "index.yaml", string: """ views: html: bar """ ) } } Directory(name: "templates") { Directory(name: "default") { YAMLFile( name: "template", contents: Mocks.Templates.metadata() ) Directory(name: "views") { MustacheFile( name: "foo", contents: """ foo """ ) MustacheFile( name: "bar", contents: """ bar """ ) } } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let fileURL = distURL.appendingPathIfPresent("test/index.html") let html = try String(contentsOf: fileURL, encoding: .utf8) #expect(html.contains("bar")) } } @Test func optionalArrayItems() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "html", contents: Pipeline( id: "html", engine: .init( id: "mustache", options: [ "contentTypes": [ "test": [ "view": "foo" ] ] ] ), output: .init( path: "{{slug}}", file: "index", ext: "html" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { File( name: "index.yaml", string: """ foo: bar: baz: title: "asdf" bug: - this - is - not - ok """ ) } } Directory(name: "templates") { Directory(name: "default") { YAMLFile( name: "template", contents: Mocks.Templates.metadata() ) Directory(name: "views") { MustacheFile( name: "foo", contents: """ {{#page.foo.bar.baz.bug}}{{.}}{{/page.foo.bar.baz.bug}} """ ) } } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let fileURL = distURL.appendingPathIfPresent("test/index.html") let html = try String(contentsOf: fileURL, encoding: .utf8) #expect( html.trimmingCharacters( in: .whitespacesAndNewlines ) == "thisisnotok" ) } } // MARK: - transformers @Test func transformerExecution() async throws { let now = Date() let fileManager = FileManager.default let rootURL = FileManager.default.temporaryDirectory let rootName = "FileManagerPlayground_\(UUID().uuidString)" try FileManagerPlayground( rootUrl: rootURL, rootName: rootName, fileManager: fileManager ) { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", transformers: [ "test": .init( run: [ .init( path: "\(rootURL.path())/\(rootName)/src/transformers", name: "replace" ) ], isMarkdownResult: false ) ], engine: .init( id: "mustache", options: [ "contentTypes": [ "test": [ ViewFrontMatterKeys.view.rawValue: "test" ] ] ] ), output: .init( path: "{{slug}}", file: "index", ext: "html" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { File( name: "index.yaml", string: """ type: test description: Desc1 label: label1 """ ) File( name: "index.md", string: """ --- title: "First beta release" --- Character to replace => : """ ) } } Directory(name: "transformers") { File( name: "replace", attributes: [.posixPermissions: 0o777], string: """ #!/bin/bash # Replaces all colons `:` with dashes `-` in the given file. # Usage: replace-char --file UNKNOWN_ARGS=() while [[ $# -gt 0 ]]; do case $1 in --file) TOUCAN_FILE="$2" shift shift ;; -*|--*) UNKNOWN_ARGS+=("$1" "$2") shift shift ;; *) shift ;; esac done if [[ -z "${TOUCAN_FILE}" ]]; then echo "❌ No file specified with --file." exit 1 fi echo "📄 Processing file: ${TOUCAN_FILE}" if [[ ${#UNKNOWN_ARGS[@]} -gt 0 ]]; then echo "ℹ️ Ignored unknown options: ${UNKNOWN_ARGS[*]}" fi sed 's/:/-/g' "${TOUCAN_FILE}" > "${TOUCAN_FILE}.tmp" && mv "${TOUCAN_FILE}.tmp" "${TOUCAN_FILE}" echo "✅ Done replacing characters." """ ) } Mocks.E2E.templates(debugContext: "{{.}}") } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let fileURL = distURL.appendingPathIfPresent("test/index.html") let html = try String(contentsOf: fileURL, encoding: .utf8) #expect(html.contains("Character to replace => -")) } } @Test func paginationPages() throws { let now = Date() try FileManagerPlayground { Mocks.E2E.src(now: now) } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let fileURL1 = distURL.appendingPathIfPresent( "blog/posts/pages/1/index.html" ) let html1 = try String(contentsOf: fileURL1, encoding: .utf8) #expect(html1.contains("Post pagination page 1 / 2")) #expect(html1.contains("

Post pagination page 1 / 2

")) let fileURL2 = distURL.appendingPathIfPresent( "blog/posts/pages/2/index.html" ) let html2 = try String(contentsOf: fileURL2, encoding: .utf8) #expect(html2.contains("Post pagination page 2 / 2")) #expect(html2.contains("

Post pagination page 2 / 2

")) } } @Test func scopeBasics() async throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { mockSiteYAMLFile() Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", scopes: [ "test": [ "minimal": .init( context: .properties, fields: [ "slug" ] ), Pipeline.Scope.Keys.list.rawValue: .init( context: .detail, fields: [ "title", "slug", ] ), ] ], queries: [ "minimal": .init( contentType: "test", scope: "minimal" ) ], engine: .init( id: "json" ), output: .init( path: "", file: "context", ext: "json" ) ) ) } mockTestTypes() Directory(name: "contents") { Directory(name: "test") { MarkdownFile( name: "index", markdown: .init( frontMatter: [ "title": "Test", "type": "test", "foo": "bar", ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { struct Page: Decodable { let slug: String let title: String } struct Context: Decodable { struct Minimal: Decodable { let slug: String } let minimal: [Minimal] } let page: Page let context: Context } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.page.title == "Test") #expect(exp.page.slug == "test") #expect(exp.context.minimal.count == 1) let first = try #require(exp.context.minimal.first) #expect(first.slug == "test") } } @Test func localizedDateOutputConfig() throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { YAMLFile( name: "toucan", contents: TargetConfig( targets: [ .standard ] ) ) YAMLFile( name: "config", contents: Config( site: .defaults, pipelines: .defaults, contents: .defaults, types: .defaults, blocks: .defaults, templates: .defaults, dataTypes: .init( date: .init( input: .defaults, output: .init( locale: "de-DE", timeZone: "CET" ), formats: [:] ) ), renderer: .defaults ) ) YAMLFile( name: "site", contents: Settings( [ "name": "Test site name", "description": "Test site description", "language": "de-DE", ] ) ) Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", engine: .init( id: "json", options: [:] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } Directory(name: "types") { YAMLFile( name: "test", contents: ContentType( id: "test", default: true, properties: [ "publication": .init( propertyType: .date(config: nil), isRequired: true ) ] ) ) } Directory(name: "contents") { Directory(name: "test") { MarkdownFile( name: "index", markdown: .init( frontMatter: [ "title": "Test", "type": "test", "publication": "2025-03-30T09:23:14.870Z", ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { struct Page: Decodable { let slug: String let title: String let publication: DateContext } let page: Page } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.page.title == "Test") #expect(exp.page.slug == "test") #expect(exp.page.publication.date.full == "Sonntag, 30. März 2025") } } @Test func localizedDateOutputConfigPipelineOverride() throws { let now = Date() try FileManagerPlayground { Directory(name: "src") { YAMLFile( name: "toucan", contents: TargetConfig( targets: [ .standard ] ) ) YAMLFile( name: "config", contents: Config( dataTypes: .init( date: .init( input: .defaults, output: .init( locale: "de-DE", timeZone: "CET" ), formats: [:] ) ), renderer: .defaults ) ) YAMLFile( name: "site", contents: Settings( [ "name": "Test site name", "description": "Test site description", "language": "de-DE", ] ) ) Directory(name: "pipelines") { YAMLFile( name: "test", contents: Pipeline( id: "test", dataTypes: .init( date: .init( output: .init( locale: "hu-HU", timeZone: "CET" ), formats: [:] ) ), engine: .init( id: "json", options: [:] ), output: .init( path: "", file: "context", ext: "json" ) ) ) } Directory(name: "types") { YAMLFile( name: "test", contents: ContentType( id: "test", default: true, properties: [ "publication": .init( propertyType: .date(config: nil), isRequired: true ) ] ) ) } Directory(name: "contents") { Directory(name: "test") { MarkdownFile( name: "index", markdown: .init( frontMatter: [ "title": "Test", "type": "test", "publication": "2025-03-30T09:23:14.870Z", ] ) ) } } } } .test { let workDir = $1.appendingPathIfPresent("src") let toucan = Toucan() try toucan.generate( workDir: workDir.path(), now: now ) let distURL = workDir.appendingPathIfPresent("dist") let contextURL = distURL.appendingPathIfPresent("context.json") let data = try Data(contentsOf: contextURL) let decoder = JSONDecoder() struct Exp: Decodable { struct Page: Decodable { let slug: String let title: String let publication: DateContext } let page: Page } let exp = try decoder.decode(Exp.self, from: data) #expect(exp.page.title == "Test") #expect(exp.page.slug == "test") #expect( exp.page.publication.date.full == "2025. március 30., vasárnap" ) } } } ================================================ FILE: Tests/ToucanSDKTests/Files/MarkdownFile.swift ================================================ // // MarkdownFile.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 04.. // import FileManagerKit import FileManagerKitBuilder import ToucanSerialization import ToucanSource import Foundation struct MarkdownFile { var name: String var ext: String var markdown: Markdown var modificationDate: Date init( name: String, ext: String = "md", markdown: Markdown, modificationDate: Date = .now ) { self.name = name self.ext = ext self.markdown = markdown self.modificationDate = modificationDate } } extension MarkdownFile: BuildableItem { func buildItem() -> FileManagerPlayground.Item { let encoder = ToucanYAMLEncoder() let yml = try! encoder.encode(markdown.frontMatter) return .file( .init( name: name + "." + ext, attributes: [.modificationDate: modificationDate], string: """ --- \(yml) --- \(markdown.contents) """ ) ) } } ================================================ FILE: Tests/ToucanSDKTests/Files/MustacheFile.swift ================================================ // // MustacheFile.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 04.. // import FileManagerKit import FileManagerKitBuilder import ToucanSerialization struct MustacheFile { var name: String var ext: String var contents: String init( name: String, ext: String = "mustache", contents: String ) { self.name = name self.ext = ext self.contents = contents } } extension MustacheFile: BuildableItem { func buildItem() -> FileManagerPlayground.Item { .file( .init( name: name + "." + ext, string: contents ) ) } } ================================================ FILE: Tests/ToucanSDKTests/Files/RawContentBundle.swift ================================================ // // RawContentBundle.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 04.. // import FileManagerKit import FileManagerKitBuilder import ToucanSerialization import ToucanSource import Foundation struct RawContentBundle { var name: String var rawContent: RawContent var modificationDate: Date = Date() } extension RawContentBundle: BuildableItem { func buildItem() -> FileManagerPlayground.Item { .directory( Directory(name: name) { Directory(name: rawContent.assetsPath) { for asset in rawContent.assets { File(name: asset, string: asset) } } MarkdownFile( name: "index", markdown: rawContent.markdown, modificationDate: modificationDate ) } ) } } ================================================ FILE: Tests/ToucanSDKTests/Files/YAMLFile.swift ================================================ // // YAMLFile.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 04.. // import FileManagerKit import FileManagerKitBuilder import ToucanSerialization struct YAMLFile { var name: String var ext: String var contents: T init( name: String, ext: String = "yml", contents: T ) { self.name = name self.ext = ext self.contents = contents } } extension YAMLFile: BuildableItem { func buildItem() -> FileManagerPlayground.Item { let encoder = ToucanYAMLEncoder() return .file( .init( name: name + "." + ext, string: try! encoder.encode(contents) ) ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+Blocks.swift ================================================ // // Mocks+Blocks.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import ToucanSource extension Mocks.Blocks { static func link() -> Block { .init( name: "link", parameters: [ .init( label: "url", isRequired: true, defaultValue: "" ), .init( label: "class", isRequired: true, defaultValue: "" ), .init( label: "target", isRequired: true, defaultValue: "_blank" ), ], requiresParentDirective: nil, removesChildParagraph: true, tag: "a", attributes: [ .init(name: "href", value: "{{url}}"), .init(name: "target", value: "{{target}}"), .init(name: "class", value: "{{class}}"), ], output: nil ) } static func highlightedText( id: Int ) -> Block { .init( name: "HighlightedText-\(id)", parameters: nil, requiresParentDirective: nil, removesChildParagraph: nil, tag: "div", attributes: [ .init( name: "class", value: "highlighted-text" ) ], output: nil ) } static func faq() -> Block { .init( name: "FAQ", parameters: nil, requiresParentDirective: nil, removesChildParagraph: nil, tag: "div", attributes: [ .init(name: "class", value: "faq") ], output: nil ) } static func badDirective() -> Block { .init( name: "BAD", parameters: [ .init( label: "label", isRequired: true ) ], requiresParentDirective: "true", removesChildParagraph: nil, tag: "div", attributes: [ .init(name: "att", value: "none") ], output: nil ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+BuildTargetSources.swift ================================================ // // Mocks+BuildTargetSources.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import Foundation import Testing import ToucanSDK import ToucanSource extension Mocks { static func buildTargetSource( location: URL = .init(filePath: ""), now: Date, target: Target = .standard, config: Config = .defaults, settings: Settings = .defaults ) -> BuildTargetSource { let formatter = ToucanInputDateFormatter( dateConfig: config.dataTypes.date ) let postType = Mocks.ContentTypes.post() guard case let .date( publicationConfig ) = postType.properties["publication"]?.type else { fatalError( "Mock post type issue: publication is not a date property." ) } guard case let .date( expirationConfig ) = postType.properties["expiration"]?.type else { fatalError( "Mock post type issue: expiration is not a date property." ) } return .init( locations: .init( sourceURL: location, config: config ), target: target, config: config, settings: settings, pipelines: [ Mocks.Pipelines.html(), Mocks.Pipelines.notFound(), Mocks.Pipelines.redirect(), Mocks.Pipelines.sitemap(), Mocks.Pipelines.rss(), Mocks.Pipelines.api(), ], types: [ Mocks.ContentTypes.page(), postType, Mocks.ContentTypes.author(), Mocks.ContentTypes.tag(), Mocks.ContentTypes.category(), Mocks.ContentTypes.guide(), Mocks.ContentTypes.redirect(), ], rawContents: [ Mocks.RawContents.homePage(now: now), Mocks.RawContents.aboutPage(now: now), Mocks.RawContents.contextPage(now: now), Mocks.RawContents.notFoundPage(now: now), Mocks.RawContents.page(id: 1, now: now), Mocks.RawContents.page(id: 2, now: now), Mocks.RawContents.page(id: 3, now: now), Mocks.RawContents.redirectHome(now: now), Mocks.RawContents.redirectAbout(now: now), Mocks.RawContents.sitemapXML(now: now), Mocks.RawContents.rssXML(now: now), Mocks.RawContents.author(id: 1, age: 18, now: now), Mocks.RawContents.author(id: 2, age: 21, now: now), Mocks.RawContents.author(id: 3, age: 42, now: now), Mocks.RawContents.tag(id: 1, now: now), Mocks.RawContents.tag(id: 2, now: now), Mocks.RawContents.tag(id: 3, now: now), Mocks.RawContents.post( id: 1, now: now, // near past publication: formatter.string( from: now.addingTimeInterval(-86400), using: publicationConfig ), // near future expiration: formatter.string( from: now.addingTimeInterval(86400), using: expirationConfig ), featured: false, authorIDs: [1, 2], tagIDs: [1, 2] ), Mocks.RawContents.post( id: 2, now: now, // past publication: formatter.string( from: now.addingTimeInterval(-86400 * 2), using: publicationConfig ), // future expiration: formatter.string( from: now.addingTimeInterval(86400 * 2), using: expirationConfig ), featured: true, authorIDs: [1, 2, 3], tagIDs: [2] ), Mocks.RawContents.post( id: 3, now: now, // distant past publication: formatter.string( from: now.addingTimeInterval(-86400 * 3), using: publicationConfig ), // distant future expiration: formatter.string( from: now.addingTimeInterval(86400 * 3), using: expirationConfig ), featured: false, authorIDs: [2, 3], tagIDs: [2, 3] ), Mocks.RawContents.postPagination(now: now), Mocks.RawContents.category(id: 1, now: now), Mocks.RawContents.category(id: 2, now: now), Mocks.RawContents.category(id: 3, now: now), Mocks.RawContents.guide(id: 1, categoryID: 1, now: now), Mocks.RawContents.guide(id: 2, categoryID: 1, now: now), Mocks.RawContents.guide(id: 3, categoryID: 1, now: now), Mocks.RawContents.guide(id: 4, categoryID: 2, now: now), Mocks.RawContents.guide(id: 5, categoryID: 2, now: now), Mocks.RawContents.guide(id: 6, categoryID: 2, now: now), Mocks.RawContents.guide(id: 7, categoryID: 3, now: now), Mocks.RawContents.guide(id: 8, categoryID: 3, now: now), Mocks.RawContents.guide(id: 9, categoryID: 3, now: now), ], blockDirectives: [ Mocks.Blocks.link() ] ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+ContentTypes.swift ================================================ // // Mocks+ContentTypes.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import ToucanSource extension Mocks.ContentTypes { static func page() -> ContentType { .init( id: "page", default: true, paths: [ "pages" ], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "title": .init( propertyType: .string, isRequired: true ), "description": .init( propertyType: .string, isRequired: true ), ], relations: [:], queries: [:] ) } static func author() -> ContentType { .init( id: "author", paths: [ "blog/authors" ], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "name": .init( propertyType: .string, isRequired: true ), "description": .init( propertyType: .string, isRequired: false ), "image": .init( propertyType: .asset, isRequired: true ), "age": .init( propertyType: .int, isRequired: false ), "height": .init( propertyType: .double, isRequired: false ), ], relations: [:], queries: [ "posts": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, filter: .field( key: "authors", operator: .contains, value: .init("{{id}}") ), orderBy: [ .init( key: "publication", direction: .desc ) ] ) ] ) } static func tag() -> ContentType { .init( id: "tag", paths: [ "blog/tags" ], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "title": .init( propertyType: .string, isRequired: true ), "description": .init( propertyType: .string, isRequired: true ), ], relations: [:], queries: [ "posts": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, filter: .field( key: "tags", operator: .contains, value: .init("{{id}}") ), orderBy: [ .init( key: "publication", direction: .desc ) ] ) ] ) } static func post() -> ContentType { .init( id: "post", paths: [ "blog/posts" ], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "title": .init( propertyType: .string, isRequired: true ), "description": .init( propertyType: .string, isRequired: true ), "publication": .init( propertyType: .date( config: .init( localization: .defaults, format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" ) ), isRequired: true ), "expiration": .init( propertyType: .date( config: .init( localization: .defaults, format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" ) ), isRequired: true ), "featured": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "rating": .init( propertyType: .double, isRequired: false ), ], relations: [ "authors": .init( references: "author", relationType: .many, order: .init( key: "name", direction: .asc ) ), "tags": .init( references: "tag", relationType: .many, order: .init( key: "title", direction: .asc ) ), ], queries: [ "prev": .init( contentType: "post", limit: 1, filter: .field( key: "publication", operator: .lessThan, value: .init("{{publication}}") ), orderBy: [ .init( key: "publication", direction: .desc ) ] ), "next": .init( contentType: "post", limit: 1, filter: .field( key: "publication", operator: .greaterThan, value: .init("{{publication}}") ), orderBy: [ .init( key: "publication", direction: .asc ) ] ), "related": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, limit: 4, filter: .and( [ .field( key: "id", operator: .notEquals, value: .init("{{id}}") ), .field( key: "authors", operator: .matching, value: .init("{{authors}}") ), ] ), orderBy: [] ), "similar": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, limit: 4, filter: .and( [ .field( key: "id", operator: .notEquals, value: .init("{{id}}") ), .field( key: "tags", operator: .matching, value: .init("{{tags}}") ), ] ), orderBy: [] ), ] ) } static func category() -> ContentType { .init( id: "category", paths: [ "docs/categories" ], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "title": .init( propertyType: .string, isRequired: true ), "description": .init( propertyType: .string, isRequired: false ), "order": .init( propertyType: .int, isRequired: true, defaultValue: .init(100) ), ], relations: [:], queries: [ "guides": .init( contentType: "guide", scope: Pipeline.Scope.Keys.list.rawValue, filter: .field( key: "category", operator: .equals, value: .init("{{id}}") ), orderBy: [ .init( key: "order", direction: .asc ) ] ) ] ) } static func guide() -> ContentType { .init( id: "guide", paths: [ "docs/guides" ], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "title": .init( propertyType: .string, isRequired: true ), "description": .init( propertyType: .string, isRequired: false ), "order": .init( propertyType: .int, isRequired: true, defaultValue: .init(100) ), ], relations: [ "category": .init( references: "category", relationType: .one ) ], queries: [:] ) } static func redirect() -> ContentType { .init( id: "redirect", paths: [], properties: [ "draft": .init( propertyType: .bool, isRequired: true, defaultValue: false ), "to": .init( propertyType: .string, isRequired: true ), "code": .init( propertyType: .int, isRequired: true, defaultValue: .init(301) ), ], relations: [:], queries: [:] ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+E2E.swift ================================================ // // Mocks+E2E.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 08.. // import FileManagerKitBuilder import Foundation import ToucanSDK import ToucanSource extension Mocks.E2E { static func types( postType: ContentType ) -> Directory { Directory(name: "types") { YAMLFile( name: "page", contents: Mocks.ContentTypes.page() ) YAMLFile( name: "author", contents: Mocks.ContentTypes.author() ) YAMLFile( name: "tag", contents: Mocks.ContentTypes.tag() ) YAMLFile( name: "post", contents: postType ) YAMLFile( name: "category", contents: Mocks.ContentTypes.category() ) YAMLFile( name: "guide", contents: Mocks.ContentTypes.guide() ) } } static func pipelines() -> Directory { Directory(name: "pipelines") { YAMLFile( name: "html", contents: Mocks.Pipelines.html() ) YAMLFile( name: "not-found", contents: Mocks.Pipelines.notFound() ) YAMLFile( name: "redirect", contents: Mocks.Pipelines.redirect() ) YAMLFile( name: "sitemap", contents: Mocks.Pipelines.sitemap() ) YAMLFile( name: "rss", contents: Mocks.Pipelines.rss() ) YAMLFile( name: "api", contents: Mocks.Pipelines.api() ) } } static func blocks() -> Directory { Directory(name: "blocks") { YAMLFile( name: "faq", contents: Mocks.Blocks.faq() ) } } static func templates( debugContext: String ) -> Directory { Directory(name: "templates") { Directory(name: "default") { YAMLFile( name: "template", contents: Mocks.Templates.metadata() ) Directory(name: "assets") { Directory(name: "css") { File( name: "template.css", string: """ body { background: #000; } """ ) } } Directory(name: "views") { MustacheFile( name: "test", contents: Mocks.Views.page() ) Directory(name: "docs") { Directory(name: "category") { MustacheFile( name: "default", contents: Mocks.Views.category() ) } Directory(name: "guide") { MustacheFile( name: "default", contents: Mocks.Views.guide() ) } } Directory(name: "pages") { MustacheFile( name: "default", contents: Mocks.Views.page() ) MustacheFile( name: "404", contents: Mocks.Views.notFound() ) MustacheFile( name: "context", contents: Mocks.Views.context( value: debugContext ) ) } Directory(name: "blog") { Directory(name: "tag") { MustacheFile( name: "default", contents: Mocks.Views.tag() ) } Directory(name: "post") { MustacheFile( name: "default", contents: Mocks.Views.post() ) } Directory(name: "author") { MustacheFile( name: "default", contents: Mocks.Views.author() ) } } Directory(name: "partials") { Directory(name: "blog") { MustacheFile( name: "author", contents: Mocks.Views.partialAuthor() ) MustacheFile( name: "tag", contents: Mocks.Views.partialTag() ) MustacheFile( name: "post", contents: Mocks.Views.partialPost() ) } Directory(name: "docs") { MustacheFile( name: "category", contents: Mocks.Views.partialCategory() ) MustacheFile( name: "guide", contents: Mocks.Views.partialGuide() ) } } MustacheFile( name: "html", contents: Mocks.Views.html() ) MustacheFile( name: "redirect", contents: Mocks.Views.redirect() ) MustacheFile( name: "rss", contents: Mocks.Views.rss() ) MustacheFile( name: "sitemap", contents: Mocks.Views.sitemap() ) } } } } static func src( now: Date, debugContext: String = "{{.}}" ) -> Directory { let config: Config = .defaults let formatter = ToucanInputDateFormatter( dateConfig: config.dataTypes.date ) let postType = Mocks.ContentTypes.post() guard case let .date( publicationConfig ) = postType.properties["publication"]?.type else { fatalError( "Mock post type issue: publication is not a date property." ) } guard case let .date( expirationConfig ) = postType.properties["expiration"]?.type else { fatalError( "Mock post type issue: expiration is not a date property." ) } return Directory(name: "src") { YAMLFile( name: "site", contents: [ "name": "Test site name", "description": "Test site description", "language": "en-US", ] as [String: AnyCodable] ) Directory(name: "contents") { RawContentBundle( name: "", rawContent: Mocks.RawContents.homePage(now: now) ) RawContentBundle( name: "about", rawContent: Mocks.RawContents.aboutPage(now: now) ) RawContentBundle( name: "context", rawContent: Mocks.RawContents.contextPage(now: now) ) RawContentBundle( name: "404", rawContent: Mocks.RawContents.notFoundPage(now: now) ) Directory(name: "pages") { RawContentBundle( name: "page-1", rawContent: Mocks.RawContents.page(id: 1, now: now) ) RawContentBundle( name: "page-2", rawContent: Mocks.RawContents.page(id: 2, now: now) ) RawContentBundle( name: "page-3", rawContent: Mocks.RawContents.page(id: 3, now: now) ) } Directory(name: "redirects") { RawContentBundle( name: "home-old", rawContent: Mocks.RawContents.redirectHome(now: now) ) RawContentBundle( name: "about-old", rawContent: Mocks.RawContents.redirectAbout(now: now) ) } RawContentBundle( name: "sitemap.xml", rawContent: Mocks.RawContents.sitemapXML(now: now) ) RawContentBundle( name: "rss.xml", rawContent: Mocks.RawContents.rssXML(now: now) ) Directory(name: "blog") { Directory(name: "posts") { RawContentBundle( name: "post-1", rawContent: Mocks.RawContents.post( id: 1, now: now, // near past publication: formatter.string( from: now.addingTimeInterval(-86400), using: publicationConfig ), // near future expiration: formatter.string( from: now.addingTimeInterval(86400), using: expirationConfig ), featured: false, authorIDs: [1, 2], tagIDs: [1, 2] ), modificationDate: now ) RawContentBundle( name: "post-2", rawContent: Mocks.RawContents.post( id: 2, now: now, // past publication: formatter.string( from: now.addingTimeInterval( -86400 * 2 ), using: publicationConfig ), // future expiration: formatter.string( from: now.addingTimeInterval( 86400 * 2 ), using: expirationConfig ), featured: true, authorIDs: [1, 2, 3], tagIDs: [2] ), modificationDate: now ) RawContentBundle( name: "post-3", rawContent: Mocks.RawContents.post( id: 3, now: now, // distant past publication: formatter.string( from: now.addingTimeInterval( -86400 * 3 ), using: publicationConfig ), // distant future expiration: formatter.string( from: now.addingTimeInterval( 86400 * 3 ), using: expirationConfig ), featured: false, authorIDs: [2, 3], tagIDs: [2, 3] ), modificationDate: now ) Directory(name: "pages") { RawContentBundle( name: "{{post.pagination}}", rawContent: Mocks.RawContents.postPagination( now: now ) ) } } Directory(name: "authors") { RawContentBundle( name: "author-1", rawContent: Mocks.RawContents.author( id: 1, age: 18, now: now ) ) RawContentBundle( name: "author-2", rawContent: Mocks.RawContents.author( id: 2, age: 21, now: now ) ) RawContentBundle( name: "author-3", rawContent: Mocks.RawContents.author( id: 3, age: 42, now: now ) ) } Directory(name: "tags") { RawContentBundle( name: "tag-1", rawContent: Mocks.RawContents.tag(id: 1, now: now) ) RawContentBundle( name: "tag-2", rawContent: Mocks.RawContents.tag(id: 2, now: now) ) RawContentBundle( name: "tag-3", rawContent: Mocks.RawContents.tag(id: 3, now: now) ) } } Directory(name: "docs") { Directory(name: "categories") { RawContentBundle( name: "category-1", rawContent: Mocks.RawContents.category( id: 1, now: now ) ) RawContentBundle( name: "category-2", rawContent: Mocks.RawContents.category( id: 2, now: now ) ) RawContentBundle( name: "category-3", rawContent: Mocks.RawContents.category( id: 3, now: now ) ) } Directory(name: "guides") { RawContentBundle( name: "guide-1", rawContent: Mocks.RawContents.guide( id: 1, categoryID: 1, now: now ) ) RawContentBundle( name: "guide-2", rawContent: Mocks.RawContents.guide( id: 2, categoryID: 1, now: now ) ) RawContentBundle( name: "guide-3", rawContent: Mocks.RawContents.guide( id: 3, categoryID: 1, now: now ) ) RawContentBundle( name: "guide-4", rawContent: Mocks.RawContents.guide( id: 4, categoryID: 2, now: now ) ) RawContentBundle( name: "guide-5", rawContent: Mocks.RawContents.guide( id: 5, categoryID: 2, now: now ) ) RawContentBundle( name: "guide-6", rawContent: Mocks.RawContents.guide( id: 6, categoryID: 2, now: now ) ) RawContentBundle( name: "guide-7", rawContent: Mocks.RawContents.guide( id: 7, categoryID: 3, now: now ) ) RawContentBundle( name: "guide-8", rawContent: Mocks.RawContents.guide( id: 8, categoryID: 3, now: now ) ) RawContentBundle( name: "guide-9", rawContent: Mocks.RawContents.guide( id: 9, categoryID: 3, now: now ) ) } } } Mocks.E2E.types(postType: postType) Mocks.E2E.pipelines() Mocks.E2E.blocks() Mocks.E2E.templates(debugContext: debugContext) } } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+Files.swift ================================================ // // Mocks+Files.swift // Toucan // // Created by gerp83 on 2025. 04. 15.. // import FileManagerKitBuilder import Foundation extension File { enum Mocks {} } extension File.Mocks { // MARK: - static func replaceTransformer() -> File { .init( name: "replace", attributes: [.posixPermissions: 0o777], string: """ #!/bin/bash # Replaces all colons `:` with dashes `-` in the given file. # Usage: replace-char --file UNKNOWN_ARGS=() while [[ $# -gt 0 ]]; do case $1 in --file) TOUCAN_FILE="$2" shift shift ;; -*|--*) UNKNOWN_ARGS+=("$1" "$2") shift shift ;; *) shift ;; esac done if [[ -z "${TOUCAN_FILE}" ]]; then echo "❌ No file specified with --file." exit 1 fi echo "📄 Processing file: ${TOUCAN_FILE}" if [[ ${#UNKNOWN_ARGS[@]} -gt 0 ]]; then echo "ℹ️ Ignored unknown options: ${UNKNOWN_ARGS[*]}" fi sed 's/:/-/g' "${TOUCAN_FILE}" > "${TOUCAN_FILE}.tmp" && mv "${TOUCAN_FILE}.tmp" "${TOUCAN_FILE}" echo "✅ Done replacing characters." """ ) } // MARK: - static func templateCSS() -> File { File( name: "template.css", string: """ header, footer, .page { max-width: 800px; margin: 0 auto; } header { text-align: center; border-bottom: 1px dotted black; padding-bottom: 16px; } footer { text-align: center; border-top: 1px dotted black; padding-top: 16px; } .page { padding-top: 16px; padding-bottom: 16px; } header #logo img { width: 64px; } """ ) } // MARK: - static func template404View() -> MustacheFile { .init( name: "404", contents: Mocks.Views.notFound() ) } static func templateDefaultView() -> MustacheFile { .init( name: "default", contents: Mocks.Views.page() ) } static func templateHomeView() -> MustacheFile { .init( name: "home", contents: Mocks.Views.home() ) } static func templateFooterView() -> MustacheFile { .init( name: "footer", contents: """

This site was generated using Swift & Toucan.

{{site.title}} © {{site.generation.formats.year}}.

""" ) } static func templateHeaderView() -> MustacheFile { .init( name: "header", contents: """
""" ) } static func templateHTMLView() -> MustacheFile { .init( name: "html", contents: Mocks.Views.html() ) } static func templateRedirectView() -> MustacheFile { .init( name: "redirect", contents: Mocks.Views.redirect() ) } static func templateRSSView() -> MustacheFile { .init( name: "rss", contents: Mocks.Views.rss() ) } static func templateSitemapView() -> MustacheFile { .init( name: "sitemap", contents: Mocks.Views.sitemap() ) } // MARK: - static func notFoundPage() -> RawContentBundle { .init( name: "404", rawContent: Mocks.RawContents.notFoundPage() ) } static func aboutPage() -> RawContentBundle { .init( name: "about", rawContent: Mocks.RawContents.aboutPage() ) } static func aboutPageStyleCSS() -> File { File( name: "style.css", string: """ #home h1 { text-transform: uppercase; } """ ) } static func homePage() -> MarkdownFile { .init( name: "index", markdown: Mocks.RawContents.homePage().markdown ) } static func post( id: Int, now: Date = .init(), publication: String, expiration: String, draft: Bool, featured: Bool, authorIDs: [Int], tagIDs: [Int] ) -> RawContentBundle { .init( name: "post-\(id)", rawContent: Mocks.RawContents.post( id: id, now: now, publication: publication, expiration: expiration, draft: draft, featured: featured, authorIDs: authorIDs, tagIDs: tagIDs ) ) } static func rssBundle() -> Directory { Directory(name: "rss.xml") { File( name: "index.yml", string: """ type: rss """ ) } } static func sitemapBundle() -> Directory { Directory(name: "sitemap.xml") { File( name: "index.yml", string: """ type: sitemap """ ) } } // MARK: - misc static func svg1() -> File { File( name: "test1.svg", string: """ """ ) } static func svg2() -> File { File( name: "test2.svg", string: """ """ ) } static func yaml1() -> File { File( name: "test1.yaml", string: """ key1: value1 key2: value2 """ ) } static func yaml2() -> File { File( name: "test2.yaml", string: """ key3: value3 key4: value4 """ ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+Pipelines.swift ================================================ // // Mocks+Pipelines.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import ToucanSource import ToucanSDK extension Mocks.Pipelines { static func html() -> Pipeline { .init( id: "html", definesType: false, scopes: [:], queries: [ "featured": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, filter: .field( key: "featured", operator: .equals, value: true ) ) ], dataTypes: .init( date: .init( output: .defaults, formats: [ "rss": .init( localization: .defaults, format: "EEE, dd MMM yyyy HH:mm:ss Z" ), "sitemap": .init( localization: .defaults, format: "yyyy-MM-dd" ), "year": .init( localization: .defaults, format: "y" ), ] ) ), contentTypes: .init( include: [], exclude: [ "rss", "sitemap", "redirect", "not-found", ], lastUpdate: [ "page", "author", "tag", "post", "guide", "category", ], filterRules: [ "*": .field( key: "draft", operator: .equals, value: false ), "post": .and( [ .field( key: "draft", operator: .equals, value: false ), .field( key: "publication", operator: .lessThanOrEquals, value: "{{date.now}}" ), .field( key: "expiration", operator: .greaterThanOrEquals, value: "{{date.now}}" ), ] ), ] ), iterators: [ "post.pagination": .init( contentType: "post", limit: 2 ) ], assets: .init( behaviors: [ .init( id: "copy", input: .init(name: "*", ext: "*"), output: .init(name: "*", ext: "*") ) ], properties: [ .init( action: .add, property: "css", resolvePath: true, input: .init(name: "style", ext: "css") ), .init( action: .add, property: "js", resolvePath: false, input: .init(name: "main", ext: "js") ), .init( action: .set, property: "image", resolvePath: true, input: .init(name: "cover", ext: "jpg") ), .init( action: .load, property: "svg", resolvePath: false, input: .init( name: "icon", ext: "svg" ) ), .init( action: .load, property: "svgs", resolvePath: true, input: .init( path: "icons", name: "*", ext: "svg" ) ), .init( action: .parse, property: "yaml", resolvePath: false, input: .init( name: "data", ext: "yml" ) ), .init( action: .parse, property: "yamls", resolvePath: true, input: .init( path: "dataset", name: "*", ext: "yaml" ) ), ] ), transformers: [:], engine: .init( id: "mustache", options: [ "contentTypes": [ "page": [ ViewFrontMatterKeys.view.rawValue: "pages.default" ], "post": [ ViewFrontMatterKeys.view.rawValue: "blog.post.default" ], "author": [ ViewFrontMatterKeys.view.rawValue: "blog.author.default" ], "tag": [ ViewFrontMatterKeys.view.rawValue: "blog.tag.default" ], "category": [ ViewFrontMatterKeys.view.rawValue: "docs.category.default" ], "guide": [ ViewFrontMatterKeys.view.rawValue: "docs.guide.default" ], ] ] ), output: .init( path: "{{slug}}", file: "index", ext: "html" ) ) } static func notFound() -> Pipeline { .init( id: "not-found", definesType: true, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .init( include: [ "not-found" ], exclude: [], lastUpdate: [], filterRules: [:] ), iterators: [:], assets: .defaults, transformers: [:], engine: .init( id: "mustache", options: [ "contentTypes": [ "not-found": [ ViewFrontMatterKeys.view.rawValue: "pages.404" ] ] ] ), output: .init( path: "", file: "404", ext: "html" ) ) } static func redirect() -> Pipeline { .init( id: "redirect", definesType: true, scopes: [:], queries: [:], dataTypes: .defaults, contentTypes: .init( include: [ "redirect" ], exclude: [], lastUpdate: [], filterRules: [:] ), iterators: [:], assets: .defaults, transformers: [:], engine: .init( id: "mustache", options: [ "contentTypes": [ "redirect": [ ViewFrontMatterKeys.view.rawValue: "redirect" ] ] ] ), output: .init( path: "{{slug}}", file: "index", ext: "html" ) ) } static func rss() -> Pipeline { .init( id: "rss", definesType: true, scopes: [:], queries: [ "posts": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: SystemPropertyKeys.lastUpdate.rawValue, direction: .desc ) ] ) ], dataTypes: .init( date: .init( output: .defaults, formats: [ "rss": .init( localization: .defaults, format: "EEE, dd MMM yyyy HH:mm:ss Z" ) ] ) ), contentTypes: .init( include: [ "rss" ], exclude: [], lastUpdate: [ "post" ], filterRules: [:] ), iterators: [:], assets: .defaults, transformers: [:], engine: .init( id: "mustache", options: [ "contentTypes": [ "rss": [ ViewFrontMatterKeys.view.rawValue: "rss" ] ] ] ), output: .init( path: "", file: "rss", ext: "xml" ) ) } static func sitemap() -> Pipeline { .init( id: "sitemap", definesType: true, scopes: [:], queries: [ "pages": .init( contentType: "page", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: SystemPropertyKeys.lastUpdate.rawValue, direction: .desc ), .init(key: "id", direction: .desc), ] ), "posts": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: SystemPropertyKeys.lastUpdate.rawValue, direction: .desc ) ] ), "authors": .init( contentType: "author", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: SystemPropertyKeys.lastUpdate.rawValue, direction: .desc ) ] ), "tags": .init( contentType: "tag", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: SystemPropertyKeys.lastUpdate.rawValue, direction: .desc ) ] ), ], dataTypes: .init( date: .init( output: .defaults, formats: [ "sitemap": .init( localization: .defaults, format: "yyyy-MM-dd" ) ] ) ), contentTypes: .init( include: [ "sitemap" ], exclude: [], lastUpdate: [], filterRules: [:] ), iterators: [ "post.pagination": .init( contentType: "post", limit: 2 ) ], assets: .init( behaviors: [], properties: [] ), transformers: [:], engine: .init( id: "mustache", options: [ "contentTypes": [ "sitemap": [ ViewFrontMatterKeys.view.rawValue: "sitemap" ] ] ] ), output: .init( path: "", file: "sitemap", ext: "xml" ) ) } static func api() -> Pipeline { .init( id: "api", definesType: true, scopes: [:], queries: [ "posts": .init( contentType: "post", scope: Pipeline.Scope.Keys.list.rawValue, orderBy: [ .init( key: "publication", direction: .desc ) ] ) ], dataTypes: .defaults, contentTypes: .init( include: ["api"], exclude: [], lastUpdate: [], filterRules: [:] ), iterators: [ "post.pagination": .init( contentType: "post", limit: 2 ) ], assets: .defaults, transformers: [:], engine: .init( id: "json", options: [ "keyPath": "context.posts" ] ), output: .init( path: "api", file: "posts", ext: "json" ) ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+RawContents.swift ================================================ // // Mocks+RawContents.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import Foundation import ToucanSource import ToucanSDK extension Mocks.RawContents { static func homePage( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init(""), slug: "" ), markdown: .init( frontMatter: [ "title": "Home page", "description": "Home page description", ], contents: """ # Home page Home page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func notFoundPage( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("404"), slug: "404" ), markdown: .init( frontMatter: [ "type": "not-found", "title": "Not found page", "description": "Not found page description", ], contents: """ # Not found Not found page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func aboutPage( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("about"), slug: "about" ), markdown: .init( frontMatter: [ "title": "About page", "description": "About page description", "css": [ "/assets/about/about.css", "https://unpkg.com/test@1.0.0.css", ], ], contents: """ # About page About page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "style.css", "main.js", ] ) } static func contextPage( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("context"), slug: "context" ), markdown: .init( frontMatter: [ "title": "Context page", "description": "Context page description", ViewFrontMatterKeys.views.rawValue: [ "*": "pages.context", "invalid-pipeline": "invalid-view", ], ], contents: """ # Context page Context page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func redirectHome( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("redirects/home-old"), slug: "home-old" ), markdown: .init( frontMatter: [ "type": "redirect", "to": "", "code": "301", ], contents: "" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func redirectAbout( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("redirects/about-old"), slug: "about-old" ), markdown: .init( frontMatter: [ "type": "redirect", "to": "about", "code": "301", ], contents: "" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func rssXML( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("rss.xml"), slug: "rss.xml" ), markdown: .init( frontMatter: [ "type": "rss" ], contents: "" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func sitemapXML( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("sitemap.xml"), slug: "sitemap.xml" ), markdown: .init( frontMatter: [ "type": "sitemap" ], contents: "" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func page( id: Int, now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("pages/page-\(id)"), slug: "pages/page-\(id)" ), markdown: .init( frontMatter: [ "title": "Page #\(id)", "description": "Page #\(id) description", ], contents: """ # Page #\(id) Page #\(id) contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func author( id: Int, age: Int = 21, now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("blog/authors/author-\(id)"), slug: "blog/authors/author-\(id)" ), markdown: .init( frontMatter: [ "name": "Author #\(id)", "description": "Author #\(id) description", "image": "./assets/author-\(id).jpg", "age": .init(age), ], contents: """ # Author #\(id) Author page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "author-\(id).jpg" ] ) } static func tag( id: Int, now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("blog/tags/tag-\(id)"), slug: "blog/tags/tag-\(id)" ), markdown: .init( frontMatter: [ "title": "Tag \(id)", "description": "Tag #\(id) description", ], contents: """ # Tag #\(id) Tag page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func post( id: Int, now: Date = .init(), publication: String, expiration: String, draft: Bool = false, featured: Bool = false, authorIDs: [Int] = [], tagIDs: [Int] = [] ) -> RawContent { .init( origin: .init( path: .init("blog/posts/post-\(id)"), slug: "blog/posts/post-\(id)" ), markdown: .init( frontMatter: [ "title": "Post #\(id)", "description": "Post #\(id) description", "publication": .init(publication), "expiration": .init(expiration), "draft": .init(draft), "featured": .init(featured), "authors": .init(authorIDs.map { "author-\($0)" }), "tags": .init(tagIDs.map { "tag-\($0)" }), "rating": .init(Double(id)), "image": "cover-\(id).jpg", ], contents: """ # Post #\(id) Post page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "cover.jpg" ] ) } static func postPagination( now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("blog/posts/pages/{{post.pagination}}"), slug: "blog/posts/pages/{{post.pagination}}" ), markdown: .init( frontMatter: [ "type": "page", "title": "Post pagination page {{number}} / {{total}}", "description": "Post pagination page description", ], contents: """ # Post pagination page {{number}} / {{total}} Post pagination page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func category( id: Int, now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("docs/categories/category-\(id)"), slug: "docs/categories/category-\(id)" ), markdown: .init( frontMatter: [ "title": "Category #\(id)", "description": "Category #\(id) description", "order": .init(id), ], contents: """ # Category #\(id) Category page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } static func guide( id: Int, categoryID: Int, now: Date = .init() ) -> RawContent { .init( origin: .init( path: .init("docs/guides/guide-\(id)"), slug: "docs/guides/guide-\(id)" ), markdown: .init( frontMatter: [ "title": "Guide #\(id)", "description": "Guide #\(id) description", "category": "category-\(categoryID)", "order": .init(id), ], contents: """ # Guide #\(id) Guide page contents """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+Templates.swift ================================================ // // Mocks+Templates.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 06. 17.. // import ToucanCore @testable import ToucanSource extension Mocks.Templates { static func metadata( generatorVersion: Template.Metadata.GeneratorVersion = .init( value: GeneratorInfo.current.release, type: .upNextMajor ) ) -> Template.Metadata { let url = "http://localhost:8080/" return .init( name: "Test Template", description: "Test Template description", url: url, version: "1.0.0", generatorVersion: generatorVersion, license: .init( name: "Test License", url: url ), authors: [ .init( name: "Test Template Author", url: url ) ], demo: .init( url: url ), tags: [ "blog", "adaptive-colors", ] ) } static func example( generatorVersion: Template.Metadata.GeneratorVersion = .init( value: GeneratorInfo.current.release, type: .upNextMajor ) ) -> Template { .init( metadata: Self.metadata(generatorVersion: generatorVersion), components: .init( assets: [ "css/theme.css", "css/variables.css", ], views: [ .init( id: "pages.404", path: "pages/404.mustache", contents: Mocks.Views.notFound() ), .init( id: "blog.post.default", path: "blog/post/default.mustache", contents: Mocks.Views.post() ), .init( id: "blog.author.default", path: "blog/author/default.mustache", contents: Mocks.Views.author() ), .init( id: "html", path: "html.mustache", contents: Mocks.Views.html() ), ] ), overrides: .init( assets: [], views: [] ), content: .init( assets: [ "splash/750x1334.png", "splash/750x1334~dark.png", "icons/320.png", "CNAME", ], views: [] ) ) } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks+Views.swift ================================================ // // Mocks+Views.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // @testable import ToucanSource extension Mocks.Views { static func all( contextValue: String = "{{.}}" ) -> Template { .init( metadata: .init( name: "Mock", description: "Mock template", url: nil, version: "1.0.0-beta.6", generatorVersion: .init( value: .init("1.0.0-beta.6")!, type: .exact ), license: nil, authors: [], demo: nil, tags: [] ), components: .init( assets: [], views: [ .init(id: "html", path: "", contents: html()), .init(id: "redirect", path: "", contents: redirect()), .init(id: "rss", path: "", contents: rss()), .init(id: "sitemap", path: "", contents: sitemap()), .init(id: "pages.default", path: "", contents: page()), .init(id: "pages.404", path: "", contents: notFound()), .init( id: "pages.context", path: "", contents: context(value: contextValue) ), .init( id: "docs.category.default", path: "", contents: category() ), .init( id: "docs.guide.default", path: "", contents: guide() ), .init(id: "blog.post.default", path: "", contents: post()), .init( id: "blog.author.default", path: "", contents: author() ), .init(id: "blog.tag.default", path: "", contents: tag()), .init( id: "partials.blog.author", path: "", contents: partialAuthor() ), .init( id: "partials.blog.tag", path: "", contents: partialTag() ), .init( id: "partials.blog.post", path: "", contents: partialPost() ), .init( id: "partials.docs.category", path: "", contents: partialCategory() ), .init( id: "partials.docs.guide", path: "", contents: partialGuide() ), ] ), overrides: .init(assets: [], views: []), content: .init(assets: [], views: []) ) } static func redirect() -> String { #""" Redirecting…

Redirecting…

Click here if you are not redirected. """# } static func rss() -> String { #""" {{site.name}} {{site.description}} {{baseUrl}} {{site.language}} {{generation.formats.rss}} {{lastUpdate.formats.rss}} 250 {{#context.posts}} {{permalink}} <![CDATA[ {{title}} ]]> {{permalink}} {{publication.formats.rss}} {{/context.posts}} """# } static func sitemap() -> String { #""" {{#context.pages}} {{permalink}} {{lastUpdate.formats.sitemap}} {{/context.pages}} {{#context.posts}} {{permalink}} {{lastUpdate.formats.sitemap}} {{/context.posts}} {{#context.authors}} {{permalink}} {{lastUpdate.formats.sitemap}} {{/context.authors}} {{#context.tags}} {{permalink}} {{lastUpdate.formats.sitemap}} {{/context.tags}} {{#context.categories}} {{permalink}} {{lastUpdate.formats.sitemap}} {{/context.categories}} {{#context.guides}} {{permalink}} {{lastUpdate.formats.sitemap}} {{/context.guides}} """# } static func html() -> String { #""" {{#page.noindex}}{{/page.noindex}} {{page.title}} {{#page.hreflang}} {{/page.hreflang}} {{#page.prev}}{{/page.prev}} {{#page.next}}{{/page.next}} {{#page.css}}{{/page.css}} {{> partials.navigation}}
{{$main}}

No content.

{{/main}}
{{> partials.footer}} {{#page.js}}{{/page.js}} """# } static func notFound() -> String { #""" {{ {{& page.contents.html}} {{/main}} {{/html}} """# } static func navigation() -> String { #""" """# } static func footer() -> String { #"""

Toucan

"""# } static func page() -> String { #""" {{ String { #""" \#(value) """# } static func post() -> String { #""" {{
{{#page.image}}{{page.title}}{{/page.image}}
{{#page.contents.readingTime}} · {{.}} min read{{/page.contents.readingTime}}

{{page.title}}


{{page.description}}

{{& page.contents.html}}
{{#empty(page.related)}} {{/empty(page.related)}} {{^empty(page.related)}}

Related articles


{{#page.related}} {{> partials.blog.post}} {{/page.related}}
{{/empty(page.related)}}
{{> partials.outline }}
{{/main}} {{/html}} """# } static func posts() -> String { #""" {{ {{#empty(iterator.items)}} Empty. {{/empty(iterator.items)}} {{^empty(iterator.items)}}
{{#iterator.items}} {{> partials.blog.post}} {{/iterator.items}}
{{/empty(iterator.items)}} {{#empty(iterator.links)}} {{/empty(iterator.links)}} {{^empty(iterator.links)}} {{/empty(iterator.links)}} {{/main}} {{/html}} """# } static func tags() -> String { #""" {{ {{#empty(context.tags)}} Empty. {{/empty(context.tags)}} {{^empty(context.tags)}}
{{#context.tags}} {{> partials.blog.tag}} {{/context.tags}}
{{/empty(context.tags)}} {{/main}} {{/html}} """# } static func authors() -> String { #""" {{ {{#empty(context.authors)}} Empty. {{/empty(context.authors)}} {{^empty(context.authors)}}
{{#context.authors}} {{> partials.blog.author}} {{/context.authors}}
{{/empty(context.authors)}} {{/main}} {{/html}} """# } static func blogHome() -> String { #""" {{ {{#empty(context.posts)}} Empty. {{/empty(context.posts)}} {{^empty(context.posts)}}
{{#context.posts}} {{> partials.blog.post}} {{/context.posts}}
{{/empty(context.posts)}}
Browse all articles

Tags


{{#empty(context.tags)}} Empty. {{/empty(context.tags)}} {{^empty(context.tags)}}
{{#context.tags}} {{> partials.blog.tag}} {{/context.tags}}
{{/empty(context.tags)}}

Authors


{{#empty(context.authors)}} Empty. {{/empty(context.authors)}} {{^empty(context.authors)}}
{{#context.authors}} {{> partials.blog.author}} {{/context.authors}}
{{/empty(context.authors)}} {{/main}} {{/html}} """# } static func tag() -> String { #""" {{
{{#page.image}}{{page.title}}{{/page.image}}

{{page.title}}


{{page.description}}

{{count(page.posts)}} articles

{{& page.contents.html}} {{#empty(page.posts)}} Empty. {{/empty(page.posts)}} {{^empty(page.posts)}}
{{#page.posts}} {{> partials.blog.post}} {{/page.posts}}
{{/empty(page.posts)}} {{/main}} {{/html}} """# } static func author() -> String { #""" {{
{{#page.image}}{{page.title}}{{/page.image}}

{{page.title}}


{{page.description}}

{{count(page.posts)}} articles

{{& page.contents.html}} {{#empty(page.posts)}} Empty. {{/empty(page.posts)}} {{^empty(page.posts)}}
{{#page.posts}} {{> partials.blog.post}} {{/page.posts}}
{{/empty(page.posts)}} {{/main}} {{/html}} """# } static func category() -> String { #""" {{
{{> partials.docs.categories }}
Docs {{& page.contents.html}} {{#empty(page.guides)}} {{/empty(page.guides)}} {{^empty(page.guides)}}

Guides

    {{#page.guides}}
  • {{title}}
  • {{/page.guides}}
{{/empty(page.guides)}}
{{> partials.outline }}
{{/main}} {{/html}} """# } static func guide() -> String { #""" {{
{{> partials.docs.categories }}
{{#page.category}} {{title}} {{/page.category}} {{& page.contents.html}}
{{^page.guide.prev}}
{{/page.guide.prev}} {{#page.guide.prev}} {{/page.guide.prev}} {{^page.guide.next}}
{{/page.guide.next}} {{#page.guide.next}} {{/page.guide.next}}
{{> partials.outline }}
{{/main}} {{/html}} """# } static func docsHome() -> String { #""" {{
{{> partials.docs.categories }}
{{& page.contents.html}}
{{> partials.outline }}
{{/main}} {{/html}} """# } static func home() -> String { #""" {{

Most recent

Latest static site generator news, Toucan updates and releases.

{{#empty(context.posts)}} Empty. {{/empty(context.posts)}} {{^empty(context.posts)}}
{{#context.posts}} {{> partials.blog.post}} {{/context.posts}}
{{/empty(context.posts)}} {{/main}} {{/html}} """# } static func partialAuthor() -> String { #"""
{{#image}} {{title}} {{/image}}

{{title}}

{{count(posts)}} articles

"""# } static func partialPost() -> String { #"""
{{#featured}}featured{{/featured}} {{#image}} {{title}} {{/image}}
{{#contents.readingTime}} · {{.}} min read{{/contents.readingTime}}

{{title}}


{{description}}

{{#authors}} {{#image}}{{title}}{{/image}} {{/authors}}
{{#tags}} {{title}} {{/tags}}
"""# } static func partialTag() -> String { #"""
{{#image}} {{title}} {{/image}}

{{title}}

{{count(posts)}} articles

"""# } static func partialCategories() -> String { #""" """# } static func partialCategory() -> String { #"""

{{title}}

"""# } static func partialGuide() -> String { #"""

{{title}}

"""# } } ================================================ FILE: Tests/ToucanSDKTests/Mocks/Mocks.swift ================================================ // // Mocks.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 30.. // enum Mocks { enum ContentTypes {} enum RawContents {} enum Pipelines {} enum Templates {} enum Views {} enum Blocks {} enum E2E {} } ================================================ FILE: Tests/ToucanSDKTests/Template/TemplateValidatorTestSuite.swift ================================================ // // TemplateValidatorTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 23.. // // import Foundation import Logging import Testing @testable import ToucanCore @testable import ToucanSDK @testable import ToucanSource import Version @Suite struct TemplateValidatorTestSuite { @Test func valid() throws { let items: [( String, Template.Metadata.GeneratorVersion.ComparisonType, String )] = [ // .exact ("1.0.0-beta.6", .exact, "1.0.0-beta.6"), ("1.2.0", .exact, "1.2.0"), // .upNextMinor ("1.0.0-beta.6", .upNextMinor, "1.0.0"), ("1.0.0-beta.6", .upNextMinor, "1.0.0-rc.1"), ("1.0.0", .upNextMinor, "1.0.1"), // .upNextMajor ("1.0.0-beta.6", .upNextMajor, "1.0.0"), ("1.0.0-beta.6", .upNextMajor, "1.0.0-rc.1"), ("1.0.0", .upNextMajor, "1.0.1"), ("1.0.0", .upNextMajor, "1.2.0"), ] for item in items { let generatorVersion = Version(item.0)! let toucanVersion = Version(item.2)! let templateValidator = try TemplateValidator( generatorInfo: .init(version: toucanVersion.description) ) try templateValidator.validate( Mocks.Templates.example( generatorVersion: .init( value: generatorVersion, type: item.1 ) ) ) } } @Test func unsupportedVersion() throws { let items: [( String, Template.Metadata.GeneratorVersion.ComparisonType, String )] = [ // .exact ("1.0.0", .exact, "1.0.0-beta.1"), ("2.0.1", .exact, "2.0.0"), // .upNextMinor ("1.0.0", .upNextMinor, "1.0.1"), ("1.0.0", .upNextMinor, "1.0.2-beta.1"), ("1.0.0", .upNextMinor, "1.1.0"), ("1.0.0", .upNextMinor, "1.0.2-beta.1"), ("1.0.0", .upNextMinor, "2.0.0"), ("1.0.0-beta.6", .upNextMinor, "1.0.0-rc.1"), ("1.1.1", .upNextMinor, "1.0.0"), // .upNextMajor ("1.0.0", .upNextMajor, "1.0.1"), ("1.0.0", .upNextMajor, "1.2.0"), ("1.0.0", .upNextMajor, "1.5.0-beta.2"), ("1.0.0", .upNextMajor, "2.0.0"), ("2.0.0", .upNextMajor, "1.0.0"), ("1.5.0", .upNextMajor, "1.0.0-beta.3"), ] for item in items { let generatorVersion = Version(item.0)! let toucanVersion = Version(item.2)! let templateValidator = try TemplateValidator( generatorInfo: .init(version: toucanVersion.description) ) do { try templateValidator.validate( Mocks.Templates.example( generatorVersion: .init( value: generatorVersion, type: item.1 ) ) ) } catch { guard case let .unsupportedGeneratorVersion( version, currentVersion ) = error else { Issue.record( "Expected .unsupportedGeneratorVersion error, got: \(error)" ) return } #expect(version.value == generatorVersion) #expect(currentVersion == toucanVersion) } } } } ================================================ FILE: Tests/ToucanSDKTests/Toucan/ToucanTestSuite.swift ================================================ // // ToucanTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 06. 20.. // import FileManagerKitBuilder import Foundation import Logging import Testing import ToucanCore @testable import ToucanSDK import ToucanSource @Suite struct ToucanTestSuite { @Test func absoluteURLResolution() throws { let fileManager = FileManager.default let homeURL = fileManager.homeDirectoryForCurrentUser let cwd = fileManager.currentDirectoryPath let cwdURL = URL(filePath: cwd) let toucan = Toucan() let path1 = toucan.absoluteURL(for: ".").path() let exp1 = cwdURL.path() #expect(path1 == exp1) let path2 = toucan.absoluteURL(for: "/foo/bar").path() let exp2 = URL(filePath: "/foo/bar").path() #expect(path2 == exp2) let path3 = toucan.absoluteURL(for: "../foo/bar").path() let exp3 = cwdURL.appending(path: "../foo/bar").standardized.path() #expect(path3 == exp3) let path4 = toucan.absoluteURL(for: "../foo/../bar").path() let exp4 = cwdURL.appending(path: "../foo/../bar").standardized.path() #expect(path4 == exp4) let path5 = toucan.absoluteURL(for: "./foo/../bar").path() let exp5 = cwdURL.appending(path: "./foo/../bar").standardized.path() #expect(path5 == exp5) let path6 = toucan.absoluteURL(for: "~/../bar").path() let exp6 = homeURL.appending(path: "../bar").standardized.path() #expect(path6 == exp6) let path7 = toucan.absoluteURL(for: "bar").path() let exp7 = cwdURL.appending(path: "bar").standardized.path() #expect(path7 == exp7) let path8 = toucan.absoluteURL(for: "").path() let exp8 = cwdURL.appending(path: "").path().dropTrailingSlash() #expect(path8 == exp8) } @Test func homeURLResolution() throws { let fileManager = FileManager.default let homeURL = fileManager.homeDirectoryForCurrentUser let toucan = Toucan() let path1 = toucan.resolveHomeURL(for: "~/foo").path() let exp1 = homeURL.appending(path: "foo").path() #expect(path1 == exp1) let path2 = toucan.resolveHomeURL(for: "~/").path() let exp2 = homeURL.appending(path: "").path() #expect(path2 == exp2) } } ================================================ FILE: Tests/ToucanSDKTests/Utilities/AnyCodableWrapTests.swift ================================================ // // AnyCodableWrapTests.swift // Toucan // // Created by gerp83 on 2025. 04. 28.. // import Foundation import Testing import ToucanSource import ToucanSDK @Suite struct AnyCodableWrapTests { @Test func testWraps() throws { let boolValue = true let intValue = 100 let doubleValue = 100.1 let stringValue = "string" let nilValue: String? = nil let arrayValue = [AnyCodable("string"), AnyCodable("string2")] let dictValue = ["key": AnyCodable("value")] let dictValue2 = ["key": "value", "key2": "value2"] #expect(wrap(boolValue) == AnyCodable(true)) #expect(wrap(intValue) == AnyCodable(100)) #expect(wrap(doubleValue) == AnyCodable(100.1)) #expect(wrap(stringValue) == AnyCodable("string")) #expect(wrap(nilValue) == AnyCodable(nil)) #expect( wrap(arrayValue) == AnyCodable([AnyCodable("string"), AnyCodable("string2")]) ) #expect(wrap(dictValue) == AnyCodable(["key": AnyCodable("value")])) #expect( wrap(dictValue2) == AnyCodable([ "key": AnyCodable("value"), "key2": AnyCodable("value2"), ]) ) } @Test func testUnwraps() throws { let boolValue = AnyCodable(true) let intValue = AnyCodable(100) let doubleValue = AnyCodable(100.1) let stringValue = AnyCodable("string") let nilValue = AnyCodable(nil) let arrayValue = AnyCodable([ AnyCodable("string"), AnyCodable("string2"), ]) let dictValue = AnyCodable([ "key": AnyCodable("value"), "key2": AnyCodable("value2"), ]) let dictValue2 = AnyCodable(["key": 100, "key2": 200]) #expect(unwrap(boolValue) as? Bool == true) #expect(unwrap(intValue) as? Int == 100) #expect(unwrap(doubleValue) as? Double == 100.1) #expect(unwrap(stringValue) as? String == "string") #expect(unwrap(nilValue) == nil) #expect(unwrap(arrayValue) as? [String] == ["string", "string2"]) #expect( unwrap(dictValue) as? [String: String] == [ "key2": "value2", "key": "value", ] ) #expect( unwrap(dictValue2) as? [String: Int] == ["key": 100, "key2": 200] ) } } ================================================ FILE: Tests/ToucanSDKTests/Utilities/CopyManagerTestSuite.swift ================================================ // // CopyManagerTestSuite.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 03. 04.. // import FileManagerKitBuilder import Foundation import Testing import ToucanSDK @Suite struct CopyManagerTestSuite { @Test() func copyItemsRecursively() async throws { try FileManagerPlayground { Directory(name: "src") { Directory(name: "assets") { Directory(name: "icons") { "foo.svg" "bar.ico" } Directory(name: "images") { "image.png" "cover.jpg" } } } Directory(name: "workDir") {} } .test { let src = $1.appendingPathIfPresent("src/assets") let workDirURL = $1.appendingPathIfPresent("workDir") let copyManager = CopyManager( fileManager: $0, sources: [ src ], destination: workDirURL ) try copyManager.copy() #expect( $0.listDirectory( at: workDirURL.appendingPathIfPresent( "icons" ) ) .sorted() == [ "foo.svg", "bar.ico", ] .sorted() ) #expect( $0.listDirectory( at: workDirURL.appendingPathIfPresent( "images" ) ) .sorted() == [ "image.png", "cover.jpg", ] .sorted() ) } } @Test() func copyEmptyDirectory() async throws { try FileManagerPlayground { Directory(name: "src") { Directory(name: "assets") {} } Directory(name: "workDir") {} } .test { let src = $1.appendingPathIfPresent("src/assets") let workDirURL = $1.appendingPathIfPresent("workDir") let copyManager = CopyManager( fileManager: $0, sources: [ src ], destination: workDirURL ) try copyManager.copy() #expect($0.listDirectory(at: workDirURL).isEmpty) } } } ================================================ FILE: Tests/ToucanSDKTests/Utilities/PrettyPrint.swift ================================================ // // PrettyPrint.swift // Toucan // // Created by Tibor Bödecs on 2025. 02. 11.. // import Foundation import ToucanSource /// Pretty prints a `[String: AnyCodable]` dictionary as JSON to standard output. /// - Parameter object: A dictionary of key-value pairs with dynamic `AnyCodable` values. public func prettyPrint( _ object: [String: AnyCodable] ) { let encoder = JSONEncoder() encoder.outputFormatting = [ .prettyPrinted, .withoutEscapingSlashes, // .sortedKeys, // Enable if key ordering is desired ] do { let data = try encoder.encode(object) guard let value = String(data: data, encoding: .utf8) else { return } print(value) } catch { fatalError(error.localizedDescription) } } ================================================ FILE: Tests/ToucanSDKTests/Utilities/RecursiveMergeTests.swift ================================================ // // RecursiveMergeTests.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. // import Testing import ToucanSource import ToucanSDK @Suite struct RecursiveMergeTests { @Test func testBasicMerge() throws { let a: [String: AnyCodable] = [ "foo": "a" ] let b: [String: AnyCodable] = [ "foo": "b" ] let c = a.recursivelyMerged(with: b) #expect(c["foo"] == "b") } @Test func testComplexMerge() throws { let a: [String: Any] = [ "foo": "a", "bar": ["a": AnyCodable("b")], ] let b: [String: Any] = [ "foo": "b", "bar": ["c": AnyCodable("d")], ] let c = a.recursivelyMerged(with: b) let expected: [String: Any] = [ "foo": "b", "bar": [ "c": AnyCodable("d"), "a": AnyCodable("b"), ], ] #expect(c["foo"] as? String == expected["foo"] as? String) } } ================================================ FILE: Tests/ToucanSDKTests/Utilities/SlugTests.swift ================================================ // // SlugTests.swift // Toucan // // Created by gerp83 on 2025. 04. 04.. // import Testing import ToucanSDK @Suite struct SlugTests { @Test func permalink() throws { let slug = Slug("slug") #expect( slug.permalink( baseURL: "http://localhost:3000" ) == "http://localhost:3000/slug/" ) } @Test func permalinkForHomePage() throws { let slug = Slug("") #expect( slug.permalink( baseURL: "http://localhost:3000" ) == "http://localhost:3000/" ) } } ================================================ FILE: Tests/ToucanSDKTests/Utilities/UnboxingTestSuite.swift ================================================ // // UnboxingTestSuite.swift // Toucan // // Created by Viasz-Kádi Ferenc on 2025. 05. 09.. // // import Foundation import Testing import ToucanSource import ToucanSDK import ToucanMarkdown @Suite struct UnboxingTests { @Test func unboxing() throws { let value: [String: AnyCodable] = [ RootContextKeys.context.rawValue: [ "posts": [ [ "publication": AnyCodable( Optional( DateContext( date: .init( full: "Tuesday, April 15, 2025", long: "April 15, 2025", medium: "Apr 15, 2025", short: "4/15/25" ), time: .init( full: "2:00:00 PM Greenwich Mean Time", long: "2:00:00 PM GMT", medium: "2:00:00 PM", short: "2:00 PM" ), timestamp: 1744725600.0, iso8601: "2025-04-15T14:00:00.000Z", formats: [ "rss": "Tue, 15 Apr 2025 14:00:00 +0000", "sitemap": "2025-04-15", "year": "2025", ] ) ) ), "description": AnyCodable( "Migration guide for Toucan Beta 3: covering changes to content structure, template changes and rendering features." ), "featured": AnyCodable(true), PageContextKeys.contents.rawValue: AnyCodable([ PageContentsKeys.readingTime.rawValue: AnyCodable( 2 ), PageContentsKeys.outline.rawValue: AnyCodable([ AnyCodable( Optional( Outline( level: 2, text: "Changes in contents", fragment: Optional( "changes-in-contents" ), children: [] ) ) ), AnyCodable( Optional( Outline( level: 2, text: "Changes in templates", fragment: Optional( "changes-in-templates" ), children: [] ) ) ), AnyCodable( Optional( Outline( level: 2, text: "Pipelines", fragment: Optional("pipelines"), children: [] ) ) ), AnyCodable( Optional( Outline( level: 2, text: "Useful links", fragment: Optional("useful-links"), children: [] ) ) ), ]), PageContentsKeys.html.rawValue: AnyCodable( "

" ), ]), "authors": AnyCodable([ [ PageContextKeys.contents.rawValue: AnyCodable([ PageContentsKeys.outline.rawValue: AnyCodable([]), PageContentsKeys.html.rawValue: AnyCodable( "" ), PageContentsKeys.readingTime.rawValue: AnyCodable(1), ]), PageContextKeys.permalink.rawValue: AnyCodable( "https://toucansites.com/authors/gabor-lengyel/" ), "description": AnyCodable( "Former Android Developer, co-founder of Binary Birds Kft." ), "slug": AnyCodable( Optional( Slug( "authors/gabor-lengyel" ) ) ), "image": AnyCodable( "https://toucansites.com/assets/authors/gabor-lengyel/gabor-lengyel.jpg" ), "title": AnyCodable("Gábor Lengyel"), "order": AnyCodable(10), SystemPropertyKeys.lastUpdate.rawValue: AnyCodable( Optional( DateContext( date: .init( full: "Friday, April 18, 2025", long: "April 18, 2025", medium: "Apr 18, 2025", short: "4/18/25" ), time: .init( full: "12:45:44 PM Greenwich Mean Time", long: "12:45:44 PM GMT", medium: "12:45:44 PM", short: "12:45 PM" ), timestamp: 1744980344.8431244, iso8601: "2025-04-18T12:45:44.843Z", formats: [ "rss": "Fri, 18 Apr 2025 12:45:44 +0000", "sitemap": "2025-04-18", "year": "2025", ] ) ) ), ] ]), "image": AnyCodable(nil), "slug": AnyCodable( Optional(Slug("beta-3-migration-guide")) ), "title": AnyCodable("Beta 3 migration guide"), PageContextKeys.permalink.rawValue: AnyCodable( "https://toucansites.com/beta-3-migration-guide/" ), ] ] ] ] let encoder = JSONEncoder() let result = value.unboxed(encoder) let firstAuthorSlugValue = result.value( forKeyPath: "context.posts.0.authors.0.slug" ) let slug = try #require(firstAuthorSlugValue as? Slug) #expect(slug.value == "authors/gabor-lengyel") let publicationDateFullValue = result.value( forKeyPath: "context.posts.0.publication.date.full" ) let publicationDateFull = try #require( publicationDateFullValue as? String ) #expect(publicationDateFull == "Tuesday, April 15, 2025") } } ================================================ FILE: Tests/ToucanSourceTests/BuildTargetSourceLoaderTestSuite.swift ================================================ // // BuildTargetSourceLoaderTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 04.. import FileManagerKit import FileManagerKitBuilder import Foundation import Testing import ToucanCore import ToucanSerialization @testable import ToucanSource @Suite struct BuildTargetSourceLoaderTestSuite { // MARK: - private helpers private func testSourceHierarchy( @FileManagerPlayground.ItemBuilder _ builder: () -> [FileManagerPlayground.Item] ) -> Directory { Directory(name: "src", builder) } private func testSourceTypesHierarchy( @FileManagerPlayground.ItemBuilder _ builder: () -> [FileManagerPlayground.Item] ) -> Directory { testSourceHierarchy { Directory(name: "types", builder) } } private func testRawContentLoader( fileManager: FileManagerKit, url: URL ) -> RawContentLoader { let url = url.appending(path: "src/") let decoder = ToucanYAMLDecoder() let config = Config.defaults let locations = BuiltTargetSourceLocations( sourceURL: url, config: config ) let loader = RawContentLoader( contentsURL: locations.contentsURL, assetsPath: config.contents.assets.path, decoder: .init(), markdownParser: .init(decoder: decoder), fileManager: fileManager ) return loader } private func testSourceLoader( fileManager: FileManagerKit, url: URL ) -> BuildTargetSourceLoader { let url = url.appending(path: "src/") let target = Target.standard let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let loader = BuildTargetSourceLoader( sourceURL: url, target: target, fileManager: fileManager, encoder: encoder, decoder: decoder ) return loader } // MARK: - content types @Test() func validContentTypes() async throws { let systemProperties = [ SystemPropertyKeys.id.rawValue: Property( propertyType: .string, isRequired: true ), SystemPropertyKeys.lastUpdate.rawValue: Property( propertyType: .string, isRequired: true ), SystemPropertyKeys.slug.rawValue: Property( propertyType: .string, isRequired: true ), SystemPropertyKeys.type.rawValue: Property( propertyType: .string, isRequired: true ), ] let type1 = ContentType( id: "post", properties: systemProperties ) let type2 = ContentType( id: "tag", properties: systemProperties ) try FileManagerPlayground { testSourceTypesHierarchy { YAMLFile(name: "post", contents: type1) YAMLFile(name: "tag", contents: type2) } } .test { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) let config = try sourceLoader.loadConfig() let locations = sourceLoader.getLocations(using: config) let results = try sourceLoader.loadTypes(using: locations) let exp: [ContentType] = [type1, type2] .sorted(by: { $0.id < $1.id }) #expect(results == exp) } } @Test() func emptyContentTypes() async throws { try FileManagerPlayground { testSourceTypesHierarchy {} } .test { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) let config = try sourceLoader.loadConfig() let locations = sourceLoader.getLocations(using: config) let results = try sourceLoader.loadTypes(using: locations) #expect(results.isEmpty) } } @Test() func invalidContentTypes() async throws { try FileManagerPlayground { testSourceTypesHierarchy { File( name: "invalid.yaml", string: "" ) } } .test { do { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) let config = try sourceLoader.loadConfig() let locations = sourceLoader.getLocations(using: config) _ = try sourceLoader.loadTypes(using: locations) } catch let error as SourceLoaderError { #expect( error.logMessage == "Could not load: `ContentType`." ) } catch { Issue.record("Invalid error type: `\(type(of: error))`.") } } } // MARK: - blocks @Test func blocks() throws { try FileManagerPlayground { testSourceHierarchy { Directory(name: "blocks") { YAMLFile( name: "link", contents: Block( name: "link" ) ) File( name: "button.yml", string: """ name: Button tag: a parameters: - label: url default: "" - label: class default: "button" - label: target default: "_blank" removesChildParagraph: true attributes: - name: href value: "{{url}}" - name: target value: "{{target}}" - name: class value: "{{class}}" """ ) } } } .test { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) let config = try sourceLoader.loadConfig() let locations = sourceLoader.getLocations(using: config) let blocks = try sourceLoader.loadBlocks(using: locations) #expect(blocks.count == 2) } } // MARK: - valid source files @Test() func validSource() async throws { let type1 = ContentType( id: "post" ) let type2 = ContentType( id: "tag" ) try FileManagerPlayground { testSourceHierarchy { Directory(name: "contents") { "index.md" Directory(name: "assets") { "main.js" } Directory(name: "404") { "index.md" } Directory(name: "blog") { "noindex.yml" Directory(name: "authors") { "index.md" } } Directory(name: "redirects") { "noindex.yml" Directory(name: "home-old") { "index.md" } } } Directory(name: "types") { YAMLFile(name: "post", contents: type1) YAMLFile(name: "tag", contents: type2) } Directory(name: "blocks") { YAMLFile( name: "link", contents: Block( name: "link" ) ) } } } .test { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) let buildTargetSource = try sourceLoader.load() #expect(buildTargetSource.blocks.count == 1) #expect(buildTargetSource.types.count == 2) #expect(buildTargetSource.rawContents.count == 4) } } // MARK: - config with target name @Test func configWithTargetName() async throws { var config = Config.defaults config.templates.current.path = "test" try FileManagerPlayground { testSourceHierarchy { YAMLFile(name: "config-dev", contents: config) YAMLFile(name: "config", contents: Config.defaults) } } .test { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) let result = try sourceLoader.loadConfig() #expect(result.templates.current.path == "test") } } @Test func invalidConfigWithTargetName() async throws { try FileManagerPlayground { testSourceHierarchy { YAMLFile(name: "config-dev", contents: "invalid") YAMLFile(name: "config", contents: Config.defaults) } } .test { let sourceLoader = testSourceLoader(fileManager: $0, url: $1) do { _ = try sourceLoader.loadConfig() Issue.record("Invalid target config should throw an error.") } catch let error as SourceLoaderError { #expect(error.logMessage == "Could not load: `Config`.") } } } } ================================================ FILE: Tests/ToucanSourceTests/Extensions/FileManagerKitExtensionsTestSuite.swift ================================================ // // FileManagerKitExtensionsTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 04.. import FileManagerKitBuilder import Foundation import Testing @testable import ToucanSource @Suite struct FileManagerKitExtensionsTestSuite { @Test() func findEmpty() throws { try FileManagerPlayground() .test { let locations = $0.find(at: $1) #expect(locations.isEmpty) } } @Test() func findAllFiles() throws { try FileManagerPlayground { Directory(name: "foo") { Directory(name: "bar") { "baz.yaml" "qux.yml" } } } .test { let url = $1.appending(path: "foo/bar/") let locations = $0.find(at: url).sorted() #expect(locations == ["baz.yaml", "qux.yml"]) } } @Test() func findDirectoriesAndFiles() throws { try FileManagerPlayground { Directory(name: "foo") { Directory(name: "bar") "baz.yaml" "qux.yml" } } .test { let url = $1.appending(path: "foo/") let locations = $0.find(at: url).sorted() #expect(locations == ["bar", "baz.yaml", "qux.yml"]) } } @Test() func findMultipleExtensions() async throws { try FileManagerPlayground { Directory(name: "foo") { Directory(name: "bar") { "baz.yaml" "qux.yml" "quux.txt" } } } .test { let url = $1.appending(path: "foo/bar/") let locations = $0.find( extensions: [ "yml", "yaml", ], at: url ) .sorted() #expect(locations == ["baz.yaml", "qux.yml"]) } } } ================================================ FILE: Tests/ToucanSourceTests/Files/YAMLFile.swift ================================================ // // YAMLFile.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 20.. // import FileManagerKit import FileManagerKitBuilder import ToucanSerialization struct YAMLFile { var name: String var ext: String var contents: T init( name: String, ext: String = "yml", contents: T ) { self.name = name self.ext = ext self.contents = contents } } extension YAMLFile: BuildableItem { func buildItem() -> FileManagerPlayground.Item { let encoder = ToucanYAMLEncoder() return .file( .init( name: name + "." + ext, string: try! encoder.encode(contents) ) ) } } ================================================ FILE: Tests/ToucanSourceTests/MarkdownParserTestSuite.swift ================================================ // // MarkdownParserTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. // import Logging import Testing import ToucanCore import ToucanSerialization @testable import ToucanSource @Suite struct MarkdownParserTestSuite { @Test func basicParserLogic() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" --- slug: lorem-ipsum title: Lorem ipsum --- Lorem ipsum dolor sit amet. """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) let markdown = try parser.parse(input) #expect(markdown.frontMatter["slug"] == .init("lorem-ipsum")) #expect(markdown.frontMatter["title"] == .init("Lorem ipsum")) } @Test func frontMatterNoContent() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" --- slug: lorem-ipsum title: Lorem ipsum --- """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) let markdown = try parser.parse(input) #expect(markdown.frontMatter["slug"] == .init("lorem-ipsum")) #expect(markdown.frontMatter["title"] == .init("Lorem ipsum")) } @Test func frontMatterWithSeparatorInContent() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" --- slug: lorem-ipsum title: Lorem ipsum --- Text with '---' separator as content """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) let markdown = try parser.parse(input) #expect(markdown.frontMatter["slug"] == .init("lorem-ipsum")) #expect(markdown.frontMatter["title"] == .init("Lorem ipsum")) } @Test func firstMissingSeparator() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" slug: lorem-ipsum title: Lorem ipsum --- Lorem ipsum dolor sit amet. """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) let markdown = try parser.parse(input) #expect(markdown.frontMatter.isEmpty) } @Test func firstMissingSeparatorWithSeparatorInContent() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" slug: lorem-ipsum title: Lorem ipsum --- Text with '---' separator as content """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) let markdown = try parser.parse(input) #expect(markdown.frontMatter.isEmpty) } @Test func secondMissingSeparator() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" --- slug: lorem-ipsum title: Lorem ipsum Lorem ipsum dolor sit amet. """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) do { _ = try parser.parse(input) } catch let error as ToucanError { if let decodingError = error.lookup(DecodingError.self) { switch decodingError { case let .dataCorrupted(context): let expected = "The given data was not valid YAML." #expect(context.debugDescription == expected) default: throw error } } else { throw error } } } @Test func secondMissingSeparatorWithSeparatorInContent() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" --- slug: lorem-ipsum title: Lorem ipsum Text with '---' separator as content """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) do { _ = try parser.parse(input) } catch let error as ToucanError { if let context = error.lookup({ if case let DecodingError.dataCorrupted(ctx) = $0 { return ctx } return nil }) { let expected = "The given data was not valid YAML." #expect(context.debugDescription == expected) } else { throw error } } } @Test func withManySeparators() throws { let logger: Logger = .init(label: "MarkdownParserTestSuite") let input = #""" --- --- --- slug: lorem-ipsum title: Lorem ipsum --- --- --- Text with '---' separator as content """# let parser = MarkdownParser( decoder: ToucanYAMLDecoder(), logger: logger ) do { _ = try parser.parse(input) } catch let error as ToucanError { if let context = error.lookup({ if case let DecodingError.typeMismatch(_, ctx) = $0 { return ctx } return nil }) { let exp = "Expected to decode Mapping but found Node instead." #expect(context.debugDescription == exp) } else { throw error } } } } ================================================ FILE: Tests/ToucanSourceTests/Models/BuildTargetSourceLocationsTestSuite.swift ================================================ // // BuildTargetSourceLocationsTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 21.. // import FileManagerKit import FileManagerKitBuilder import Foundation import Testing import ToucanCore import ToucanSerialization @testable import ToucanSource @Suite struct BuildTargetSourceLocationsTestSuite { @Test() func defaults() async throws { let prefix = "/src" let def = "default" let templatesPath = "\(prefix)/templates" let templatePath = "\(templatesPath)/\(def)" let overridesPath = "\(templatesPath)/overrides/\(def)" let expectedBase = "\(prefix)" let expectedAssets = "\(prefix)/assets" let expectedSettings = "\(prefix)" let expectedContents = "\(prefix)/contents" let expectedTypes = "\(prefix)/types" let expectedBlocks = "\(prefix)/blocks" let expectedPipelines = "\(prefix)/pipelines" let expectedTemplates = templatesPath let expectedCurrentTemplate = templatePath let expectedTemplateAssets = "\(templatePath)/assets" let expectedTemplateTemplates = "\(templatePath)/views" let expectedOverrides = overridesPath let expectedOverrideAssets = "\(overridesPath)/assets" let expectedOverrideTemplates = "\(overridesPath)/views" let url = URL(filePath: prefix) let locations = BuiltTargetSourceLocations( sourceURL: url, config: .defaults ) let basePath = locations.baseURL.path() let assetsPath = locations.siteAssetsURL.path() let settingsPath = locations.siteSettingsURL.path() let contentsPath = locations.contentsURL.path() let typesPath = locations.typesURL.path() let blocksPath = locations.blocksURL.path() let pipelinesPath = locations.pipelinesURL.path() let templatesPathValue = locations.templatesURL.path() let currentTemplatePath = locations.currentTemplateURL.path() let templateAssetsPath = locations.currentTemplateAssetsURL.path() let templateTemplatesPath = locations.currentTemplateViewsURL.path() let overridesPathValue = locations.currentTemplateOverridesURL.path() let overrideAssetsPath = locations.currentTemplateAssetOverridesURL .path() let overrideTemplatesPath = locations.currentTemplateViewsOverridesURL .path() #expect(basePath == expectedBase) #expect(assetsPath == expectedAssets) #expect(settingsPath == expectedSettings) #expect(contentsPath == expectedContents) #expect(typesPath == expectedTypes) #expect(blocksPath == expectedBlocks) #expect(pipelinesPath == expectedPipelines) #expect(templatesPathValue == expectedTemplates) #expect(currentTemplatePath == expectedCurrentTemplate) #expect(templateAssetsPath == expectedTemplateAssets) #expect(templateTemplatesPath == expectedTemplateTemplates) #expect(overridesPathValue == expectedOverrides) #expect(overrideAssetsPath == expectedOverrideAssets) #expect(overrideTemplatesPath == expectedOverrideTemplates) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/AnyCodableTestSuite.swift ================================================ // // AnyCodableTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 30.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct AnyCodableTestSuite { struct SomeCodable: Codable { enum CodingKeys: String, CodingKey { case string case int case bool case hasUnderscore = "has_underscore" } var string: String var int: Int var bool: Bool var hasUnderscore: String } @Test func decodingInt() throws { let object = "123" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) #expect(result.value as? Int == 123) } @Test func decodingDouble() throws { let object = "123.45" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) #expect(result.value as? Double == 123.45) } @Test func decodingBool() throws { let object = "true" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) #expect(result.value as? Bool == true) } @Test func decodingString() throws { let object = "Hello" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) #expect(result.value as? String == "Hello") } @Test func decodingArray() throws { let object = "[1, 2, 3]" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) #expect(result.value as? [Int] == [1, 2, 3]) } @Test func decodingDictionary() throws { let object = """ key1: 1 key2: value """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) guard let dict = result.value as? [String: AnyCodable] else { Issue.record("Result is not a dictionary.") return } #expect(dict["key1"] == 1) #expect(dict["key2"] == "value") } @Test func decodingNestedStructures() throws { let object = """ name: "Toucan" description: "Static Site Generator" navigation: - label: "Home" url: "/" """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(AnyCodable.self, from: object) guard let dict = result.value as? [String: AnyCodable] else { Issue.record("Result is not a dictionary.") return } #expect(dict["name"] == "Toucan") } @Test func jSONDecoding() throws { let json = """ { "boolean": true, "integer": 42, "double": 3.141592653589793, "string": "string", "array": [1, 2, 3], "dict": { "a": "alpha", "b": "bravo", "c": "charlie" }, "null": null } """ .data(using: .utf8)! let decoder = JSONDecoder() let dictionary = try decoder.decode( [String: AnyCodable].self, from: json ) #expect(dictionary["boolean"]?.value as! Bool == true) #expect(dictionary["integer"]?.value as! Int == 42) #expect(dictionary["double"]?.value as! Double == 3.141592653589793) #expect(dictionary["string"]?.value as! String == "string") #expect(dictionary["array"]?.value as! [Int] == [1, 2, 3]) #expect( dictionary["dict"]?.value as! [String: AnyCodable] == [ "a": .init("alpha"), "b": .init("bravo"), "c": .init("charlie"), ] ) #expect(dictionary["null"]?.value == nil) } @Test func jSONDecodingEquatable() throws { let json = """ { "boolean": true, "integer": 42, "double": 3.141592653589793, "string": "string", "array": [1, 2, 3], "dict": { "a": "alpha", "b": "bravo", "c": "charlie" }, "null": null } """ .data(using: .utf8)! let decoder = JSONDecoder() let dictionary1 = try decoder.decode( [String: AnyCodable].self, from: json ) let dictionary2 = try decoder.decode( [String: AnyCodable].self, from: json ) #expect(dictionary1["boolean"] == dictionary2["boolean"]) #expect(dictionary1["integer"] == dictionary2["integer"]) #expect(dictionary1["double"] == dictionary2["double"]) #expect(dictionary1["string"] == dictionary2["string"]) #expect( dictionary1["array"]?.value as? [Int] == dictionary2["array"]?.value as? [Int] ) #expect( dictionary1["dict"]?.value as? [String: String] == dictionary2[ "dict" ]? .value as? [String: String] ) #expect(dictionary1["null"]?.value == nil) #expect(dictionary2["null"]?.value == nil) } @Test func jSONEncoding() throws { let someCodable = AnyCodable( SomeCodable( string: "String", int: 100, bool: true, hasUnderscore: "another string" ) ) let injectedValue = 1234 let dictionary: [String: AnyCodable] = [ "boolean": true, "integer": 42, "double": 3.141592653589793, "string": "string", "stringInterpolation": "string \(injectedValue)", "array": [1, 2, 3], "dict": [ "a": "alpha", "b": "bravo", "c": "charlie", ], "someCodable": someCodable, "null": nil, ] let encoder = JSONEncoder() let json = try encoder.encode(dictionary) let encodedJSONObject = try JSONSerialization.jsonObject(with: json, options: []) as! NSDictionary let expected = """ { "boolean": true, "integer": 42, "double": 3.141592653589793, "string": "string", "stringInterpolation": "string 1234", "array": [1, 2, 3], "dict": { "a": "alpha", "b": "bravo", "c": "charlie" }, "someCodable": { "string": "String", "int": 100, "bool": true, "has_underscore": "another string" }, "null": null } """ .data(using: .utf8)! let expectedJSONObject = try JSONSerialization.jsonObject( with: expected, options: [] ) as! NSDictionary #expect(encodedJSONObject == expectedJSONObject) } @Test func allValues() throws { let boolValue = AnyCodable(true) let intValue = AnyCodable(100) let doubleValue = AnyCodable(100.1) let stringValue = AnyCodable("string") let nilValue = AnyCodable(nil) let arrayValue = AnyCodable([ AnyCodable("string"), AnyCodable("string2"), ]) let dictValue = AnyCodable(["key": AnyCodable("value")]) // test values #expect(boolValue.boolValue() == true) #expect(intValue.intValue() == 100) #expect(doubleValue.doubleValue() == 100.1) #expect(stringValue.stringValue() == "string") #expect(nilValue.stringValue() == nil) #expect( arrayValue.arrayValue(as: AnyCodable.self) == [ AnyCodable("string"), AnyCodable("string2"), ] ) #expect(dictValue.dictValue() == ["key": AnyCodable("value")]) #expect(arrayValue.arrayValue(as: Int.self) == []) #expect(arrayValue.dictValue() == [:]) // test description/debugDescription #expect(boolValue.description == "true") #expect(boolValue.debugDescription == "AnyCodable(true)") #expect(intValue.description == "100") #expect(intValue.debugDescription == "AnyCodable(100)") #expect(doubleValue.description == "100.1") #expect(doubleValue.debugDescription == "AnyCodable(100.1)") #expect(stringValue.description == "string") #expect(stringValue.debugDescription == "AnyCodable(\"string\")") #expect(nilValue.description == "nil") #expect(nilValue.debugDescription == "AnyCodable(nil)") #expect( arrayValue.description == "[AnyCodable(\"string\"), AnyCodable(\"string2\")]" ) #expect( arrayValue.debugDescription == "AnyCodable([AnyCodable(\"string\"), AnyCodable(\"string2\")])" ) #expect(dictValue.description == "[\"key\": AnyCodable(\"value\")]") #expect( dictValue.debugDescription == "AnyCodable([\"key\": AnyCodable(\"value\")])" ) // hash _ = boolValue.hashValue _ = intValue.hashValue _ = doubleValue.hashValue _ = stringValue.hashValue _ = nilValue.hashValue _ = arrayValue.hashValue _ = dictValue.hashValue } @Test func allComparisons() throws { let boolValue = AnyCodable(true) let boolValue2 = AnyCodable(true) let intValue = AnyCodable(100) let intValue2 = AnyCodable(100) let doubleValue = AnyCodable(100.1) let doubleValue2 = AnyCodable(100.1) let stringValue = AnyCodable("string") let stringValue2 = AnyCodable("string") let nilValue = AnyCodable(nil) let nilValue2 = AnyCodable(nil) let arrayValue = AnyCodable([ AnyCodable("string"), AnyCodable("string2"), ]) let arrayValue2 = AnyCodable([ AnyCodable("string"), AnyCodable("string2"), ]) let dictValue = AnyCodable(["key": AnyCodable("value")]) let dictValue2 = AnyCodable(["key": AnyCodable("value")]) #expect(boolValue == boolValue2) #expect(intValue == intValue2) #expect(doubleValue == doubleValue2) #expect(stringValue == stringValue2) #expect(nilValue == nilValue2) #expect(arrayValue == arrayValue2) #expect(dictValue == dictValue2) #expect(dictValue != arrayValue) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Config/ConfigTestSuite.swift ================================================ // // ConfigTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 17.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct ConfigTestSuite { @Test func defaults() throws { let object = Config.defaults let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Config.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Config.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func empty() throws { let value = "" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Config.self, from: value) let expectation = Config.defaults #expect(result == expectation) } @Test func custom() throws { let value = """ blocks: path: custom1 contents: assets: path: custom2 path: custom3 dataTypes: date: formats: test1: format: his locale: hu-HU timeZone: CET input: format: ymd output: locale: en-GB timeZone: PST pipelines: path: custom4 renderer: outlineLevels: - 4 paragraphStyles: test: - test1 wordsPerMinute: 42 site: assets: path: custom5 settings: path: custom6 templates: assets: path: custom7 current: path: custom8 location: path: custom9 overrides: path: custom10 views: path: custom11 types: path: custom12 """ + "\n" let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Config.self, from: value) var expectation = Config.defaults expectation.blocks.path = "custom1" expectation.contents.assets.path = "custom2" expectation.contents.path = "custom3" expectation.dataTypes.date.input = .init( localization: .defaults, format: "ymd" ) expectation.dataTypes.date.output = .init( locale: "en-GB", timeZone: "PST" ) expectation.dataTypes.date.formats["test1"] = .init( localization: .init( locale: "hu-HU", timeZone: "CET" ), format: "his" ) expectation.pipelines.path = "custom4" expectation.renderer.outlineLevels = [4] expectation.renderer.paragraphStyles.styles = [ "test": [ "test1" ] ] expectation.renderer.wordsPerMinute = 42 expectation.site.assets.path = "custom5" expectation.site.settings.path = "custom6" expectation.templates.assets.path = "custom7" expectation.templates.current.path = "custom8" expectation.templates.location.path = "custom9" expectation.templates.overrides.path = "custom10" expectation.templates.views.path = "custom11" expectation.types.path = "custom12" #expect(result.blocks == expectation.blocks) #expect(result.contents == expectation.contents) #expect(result.dataTypes == expectation.dataTypes) #expect(result.pipelines == expectation.pipelines) #expect(result.renderer == expectation.renderer) #expect(result.site == expectation.site) #expect(result.templates == expectation.templates) #expect(result.types == expectation.types) let encodedValue: String = try encoder.encode(expectation) #expect(result == expectation) #expect(value == encodedValue) } @Test func invalidKey() throws { let value = """ dataTypesss: date: input: format: ymd """ + "\n" let decoder = ToucanYAMLDecoder() do { let _ = try decoder.decode(Config.self, from: value) } catch { if let context = error.lookup({ if case let DecodingError.dataCorrupted(ctx) = $0 { return ctx } return nil }) { let expected = "Unknown keys found: `dataTypesss`. Expected keys: `blocks`, `contents`, `dataTypes`, `pipelines`, `renderer`, `site`, `templates`, `types`." #expect(context.debugDescription == expected) } else { throw error } } } } ================================================ FILE: Tests/ToucanSourceTests/Objects/DateFormatting/DateFormattingTestSuite.swift ================================================ // // DateFormattingTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 28.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct DateFormattingTestSuite { @Test func decodeFullSpec() throws { let yaml = """ locale: "fr_FR" timeZone: "Europe/Budapest" format: "yyyy-MM-dd" """ let decoder = ToucanYAMLDecoder() let options = try decoder.decode( DateFormatterConfig.self, from: yaml ) #expect(options.localization.locale == "fr_FR") #expect(options.localization.timeZone == "Europe/Budapest") #expect(options.format == "yyyy-MM-dd") } @Test func decodeDefaultValues() throws { let yaml = """ format: "MM/dd/yyyy" """ let decoder = ToucanYAMLDecoder() let options = try decoder.decode( DateFormatterConfig.self, from: yaml ) #expect( options.localization.locale == DateLocalization.defaults.locale ) #expect( options.localization.timeZone == DateLocalization.defaults.timeZone ) #expect(options.format == "MM/dd/yyyy") } @Test func encodeProducesExpectedYAML() throws { let options = DateFormatterConfig( localization: DateLocalization( locale: "de_DE", timeZone: "Europe/Berlin" ), format: "dd.MM.yyyy" ) let encoder = ToucanYAMLEncoder() let yamlString: String = try encoder.encode(options) let exp = """ format: dd.MM.yyyy locale: de_DE timeZone: Europe/Berlin """ .trimmingCharacters(in: .whitespacesAndNewlines) #expect( yamlString.trimmingCharacters(in: .whitespacesAndNewlines) == exp ) } @Test func encodeDefaultsProducesYAMLWithFormatOnly() throws { let options = DateFormatterConfig( localization: DateLocalization.defaults, format: "yyyy" ) let encoder = ToucanYAMLEncoder() let yamlString: String = try encoder.encode(options) let exp = """ format: yyyy """ .trimmingCharacters(in: .whitespacesAndNewlines) #expect( yamlString.trimmingCharacters(in: .whitespacesAndNewlines) == exp ) } @Test func invalidLocale() throws { let decoder = ToucanYAMLDecoder() let yaml = """ format: yyyy locale: invalid timeZone: GMT """ do { _ = try decoder.decode(DateLocalization.self, from: yaml) } catch { if let context = error.lookup({ if case let DecodingError.dataCorrupted(ctx) = $0 { return ctx } return nil }) { let expected = "Invalid locale identifier." #expect(context.debugDescription == expected) } else { throw error } } } @Test func invalidTimeZone() throws { let decoder = ToucanYAMLDecoder() let yaml = """ format: yyyy locale: en-US timeZone: invalid """ do { _ = try decoder.decode(DateLocalization.self, from: yaml) } catch { if let context = error.lookup({ if case let DecodingError.dataCorrupted(ctx) = $0 { return ctx } return nil }) { let expected = "Invalid time zone identifier." #expect(context.debugDescription == expected) } else { throw error } } } @Test func invalidFormat() throws { let decoder = ToucanYAMLDecoder() let yaml = """ format: "" locale: en-US timeZone: GMT """ do { _ = try decoder.decode(DateFormatterConfig.self, from: yaml) } catch { if let context = error.lookup({ if case let DecodingError.dataCorrupted(ctx) = $0 { return ctx } return nil }) { let expected = "Empty date format value." #expect(context.debugDescription == expected) } else { throw error } } } @Test func preserveDefaultValues() throws { let original = DateFormatterConfig( localization: DateLocalization( locale: "en-US", timeZone: "GMT" ), format: "yyyy-MM-dd" ) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let yamlString: String = try encoder.encode(original) let decoded = try decoder.decode( DateFormatterConfig.self, from: yamlString ) #expect(decoded == original) } @Test func preserveCustomValues() throws { let original = DateFormatterConfig( localization: DateLocalization( locale: "hu-HU", timeZone: "CET" ), format: "yyyy-MM-dd" ) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let yamlString: String = try encoder.encode(original) let decoded = try decoder.decode( DateFormatterConfig.self, from: yamlString ) #expect(decoded == original) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Pipeline/PipelineContentTypeTestSuite.swift ================================================ // // PipelineContentTypeTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 12.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PipelineContentTypeTestSuite { @Test func invalidKey() throws { let data = """ foo: bar """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() do { let _ = try decoder.decode(Pipeline.ContentTypes.self, from: data) } catch { if let context = error.lookup({ if case let DecodingError.dataCorrupted(ctx) = $0 { return ctx } return nil }) { let expected = "Unknown keys found: `foo`. Expected keys: `exclude`, `filterRules`, `include`, `lastUpdate`." #expect(context.debugDescription == expected) } else { throw error } } } @Test func standard() throws { let data = """ include: - post exclude: - rss lastUpdate: - page """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.ContentTypes.self, from: data ) #expect(result.include == ["post"]) #expect(result.exclude == ["rss"]) #expect(result.lastUpdate == ["page"]) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Pipeline/PipelineScopeContextTestSuite.swift ================================================ // // PipelineScopeContextTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 12.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PipelineScopeContextTestSuite { @Test func initialization() throws { let result = Pipeline.Scope.Context(stringValue: "foo") #expect(!result.contains(.properties)) #expect(!result.contains(.contents)) #expect(!result.contains(.relations)) #expect(!result.contains(.queries)) #expect(!result.contains(.detail)) } @Test func decodingMultipleValues() throws { let json = #"["\#(Pipeline.Scope.Context.Keys.contents.rawValue)", "\#(Pipeline.Scope.Context.Keys.queries.rawValue)"]"# let data = json.data(using: .utf8)! let result = try ToucanJSONDecoder() .decode(Pipeline.Scope.Context.self, from: data) #expect(!result.contains(.properties)) #expect(result.contains(.contents)) #expect(!result.contains(.relations)) #expect(result.contains(.queries)) #expect(!result.contains(.detail)) } @Test func decodingSingleValue() throws { let json = #""\#(Pipeline.Scope.Context.Keys.properties.rawValue)""# let data = json.data(using: .utf8)! let result = try ToucanJSONDecoder() .decode(Pipeline.Scope.Context.self, from: data) #expect(result.contains(.properties)) #expect(!result.contains(.contents)) #expect(!result.contains(.relations)) #expect(!result.contains(.queries)) #expect(!result.contains(.detail)) } @Test func decodingSingleAllValue() throws { let json = #""\#(Pipeline.Scope.Context.Keys.detail.rawValue)""# let data = json.data(using: .utf8)! let result = try ToucanJSONDecoder() .decode(Pipeline.Scope.Context.self, from: data) #expect(result.contains(.properties)) #expect(result.contains(.contents)) #expect(result.contains(.relations)) #expect(result.contains(.queries)) #expect(result.contains(.detail)) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Pipeline/PipelineScopeTestSuite.swift ================================================ // // PipelineScopeTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 12.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PipelineScopeTestSuite { @Test func minimal() throws { let data = """ context: detail """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.Scope.self, from: data ) #expect(result.context == .detail) try #require(result.fields.count == 0) #expect(result.fields == []) } @Test func fields() throws { let data = """ context: properties fields: - foo - bar """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.Scope.self, from: data ) #expect(result.context == .properties) try #require(result.fields.count == 2) #expect(result.fields == ["foo", "bar"]) } @Test func context() throws { let data = """ context: - contents - relations fields: - foo """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.Scope.self, from: data ) #expect(result.context == [.contents, .relations]) try #require(result.fields.count == 1) #expect(result.fields == ["foo"]) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Pipeline/PipelineTestSuite.swift ================================================ // // PipelineTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 30.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PipelineTestSuite { @Test func minimal() throws { let data = """ id: test engine: id: engine output: path: path file: file ext: ext """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.self, from: data ) #expect(result.id == "test") #expect(result.engine.id == "engine") #expect(result.output.path == "path") #expect(result.output.file == "file") #expect(result.output.ext == "ext") } @Test func standard() throws { let data = """ id: test queries: featured: contentType: post limit: 10 filter: key: featured operator: equals value: true orderBy: - key: publication direction: desc contentTypes: include: - page - post engine: id: test options: foo: bar foo2: bool: false double: 2.0 int: 100 date: 01/16/2023 array: - value1 - value2 output: path: "{{slug}}" file: "{{id}}" ext: json """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.self, from: data ) #expect(result.contentTypes.include == ["page", "post"]) let query = try #require(result.queries["featured"]) #expect(query.contentType == "post") #expect(result.engine.id == "test") #expect(result.engine.options.string("") == nil) #expect(result.engine.options.string("foo") == "bar") #expect(result.engine.options.string("foo.foo2") == nil) #expect( result.engine.options.string("foo4", allowingEmptyValue: true) == nil ) #expect(result.engine.options.string("foo4") == nil) #expect(result.engine.options.bool("bool") == false) #expect(result.engine.options.double("double") == 2.0) #expect(result.engine.options.int("int") == 100) let formatter = DateFormatter() formatter.locale = .init(identifier: "en-US") formatter.timeZone = .init(secondsFromGMT: 0)! formatter.dateFormat = "MM/dd/yyyy" #expect( result.engine.options.date("date", formatter: formatter)? .formatted(.iso8601) == "2023-01-16T00:00:00Z" ) #expect( result.engine.options.date("date2", formatter: formatter)? .formatted() == nil ) #expect( result.engine.options.array("array", as: String.self) == [ "value1", "value2", ] ) #expect(result.engine.options.array("array", as: Int.self) == []) } @Test func scopes() throws { let data = """ id: test scopes: post: list: context: - detail fields: engine: id: test output: path: "{{slug}}" file: index ext: html """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.self, from: data ) #expect(result.contentTypes.include.isEmpty) #expect(result.engine.id == "test") let defaultScope = try #require( result.scopes[Pipeline.Scope.Keys.wildcard.rawValue] ) let defaultReferenceScope = try #require( defaultScope[Pipeline.Scope.Context.Keys.reference.rawValue] ) let defaultListScope = try #require( defaultScope[Pipeline.Scope.Context.Keys.list.rawValue] ) let defaultDetailScope = try #require( defaultScope[Pipeline.Scope.Context.Keys.detail.rawValue] ) #expect(defaultReferenceScope.context == .reference) #expect(defaultListScope.context == .list) #expect(defaultDetailScope.context == .detail) let postScope = try #require(result.scopes["post"]) let postListScope = try #require( postScope[Pipeline.Scope.Keys.list.rawValue] ) #expect(postListScope.context == .detail) } @Test func assets() throws { let data = """ id: test assets: properties: - action: add property: js resolvePath: false input: name: main ext: js - action: set property: image resolvePath: true input: name: cover ext: jpg - action: load property: svgs resolvePath: false input: name: "*" ext: svg - action: parse property: data resolvePath: false input: name: "*" ext: json engine: id: engine output: path: path file: file ext: ext """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Pipeline.self, from: data ) #expect(result.assets.behaviors.isEmpty) #expect(result.assets.properties.count == 4) #expect(result.assets.properties[0].action == .add) #expect(result.assets.properties[1].action == .set) #expect(result.assets.properties[2].action == .load) #expect(result.assets.properties[3].action == .parse) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Pipeline/PipelineTransformersTestSuite.swift ================================================ // // PipelineTransformersTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PipelineTransformersTestSuite { @Test func initWithName() throws { let contentTransformer = Pipeline.Transformers.Transformer( name: "test" ) #expect(contentTransformer.name == "test") } @Test func initWithRun() throws { let transformerPipeline = Pipeline.Transformers( run: [ .init(name: "test") ], isMarkdownResult: false ) #expect(transformerPipeline.isMarkdownResult == false) #expect(transformerPipeline.run[0].name == "test") } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Property/PropertyTestSuite.swift ================================================ // // PropertyTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 30.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PropertyTestSuite { @Test func stringType() throws { let data = """ defaultValue: hello required: false type: string """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Property.self, from: data) let encoder = ToucanYAMLEncoder() let yaml = try encoder.encode(result) #expect(result.type == .string) #expect(result.required == false) #expect(result.defaultValue?.value as? String == "hello") #expect( data.trimmingCharacters(in: .whitespacesAndNewlines) == yaml.trimmingCharacters(in: .whitespacesAndNewlines) ) } @Test func assetType() throws { let data = """ required: true type: asset """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Property.self, from: data) let encoder = ToucanYAMLEncoder() let yaml = try encoder.encode(result) #expect(result.type == .asset) #expect(result.required == true) #expect( data.trimmingCharacters(in: .whitespacesAndNewlines) == yaml.trimmingCharacters(in: .whitespacesAndNewlines) ) } @Test func dateTypeWithFormat() throws { let data = """ type: date config: format: "ymd" locale: en-US timeZone: EST """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Property.self, from: data) #expect( result.type == .date( config: .init( localization: .init( locale: "en-US", timeZone: "EST" ), format: "ymd" ) ) ) #expect(result.required == true) #expect(result.defaultValue == nil) } @Test func dateTypeWithoutFormat() throws { let data = """ type: date required: true """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Property.self, from: data) #expect(result.type == .date(config: nil)) #expect(result.required == true) #expect(result.defaultValue == nil) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Property/PropertyTypeTestSuite.swift ================================================ // // PropertyTypeTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 01. 31.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct PropertyTypeTestSuite { @Test func equality() throws { let dateFormat = PropertyType.date(config: nil) #expect(PropertyType.bool == .bool) #expect(PropertyType.bool != .int) #expect(PropertyType.int == .int) #expect(PropertyType.int != .double) #expect(PropertyType.double == .double) #expect(PropertyType.double != .string) #expect(PropertyType.string == .string) #expect(PropertyType.string != dateFormat) #expect(dateFormat == .date(config: nil)) #expect( dateFormat != .date( config: .init( localization: .defaults, format: "y.m.d" ) ) ) } @Test func decodingBool() throws { let object = """ type: bool """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .bool) } @Test func encodingBool() throws { let object = """ type: bool """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) let encoder = ToucanYAMLEncoder() let yaml = try encoder.encode(result) #expect(result == .bool) #expect( object.trimmingCharacters(in: .whitespacesAndNewlines) == yaml.trimmingCharacters(in: .whitespacesAndNewlines) ) } @Test func decodingInt() throws { let object = """ type: int """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .int) } @Test func decodingDouble() throws { let object = """ type: double """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .double) } @Test func decodingString() throws { let object = """ type: string """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .string) } @Test func decodingDate() throws { let object = """ type: date config: format: "y.m.d" """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect( result == .date( config: .init( localization: .defaults, format: "y.m.d" ) ) ) } @Test func decodingArrayOfBool() throws { let object = """ type: array of: type: bool """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .array(of: .bool)) } @Test func decodingArrayOfInt() throws { let object = """ type: array of: type: int """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .array(of: .int)) } @Test func decodingArrayOfDouble() throws { let object = """ type: array of: type: double """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .array(of: .double)) } @Test func decodingArrayOfString() throws { let object = """ type: array of: type: string """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect(result == .array(of: .string)) } @Test func decodingArrayOfDate() throws { let object = """ type: array of: type: date config: format: "y.m.d" """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(PropertyType.self, from: object) #expect( result == .array( of: .date( config: .init( localization: .defaults, format: "y.m.d" ) ) ) ) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Query/ConditionTestSuite.swift ================================================ // // ConditionTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct ConditionTestSuite { @Test func fieldBasics() throws { let object = Condition.field( key: "foo", operator: .equals, value: "a" ) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Condition.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Condition.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func andBasics() throws { let object = Condition.and( [ .field( key: "foo", operator: .equals, value: "a" ), .field( key: "bar", operator: .notEquals, value: "b" ), ] ) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Condition.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Condition.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func orBasics() throws { let object = Condition.or( [ .field( key: "foo", operator: .equals, value: "a" ), .field( key: "bar", operator: .notEquals, value: "b" ), ] ) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Condition.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Condition.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func customField() throws { let value = """ key: foo operator: equals value: a """ + "\n" let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Condition.self, from: value) let expectation = Condition.field( key: "foo", operator: .equals, value: "a" ) let encodedValue: String = try encoder.encode(expectation) #expect(result == expectation) #expect(value == encodedValue) } @Test func customAnd() throws { let value = """ and: - key: foo operator: equals value: a - key: bar operator: notEquals value: b """ + "\n" let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Condition.self, from: value) let expectation = Condition.and( [ .field( key: "foo", operator: .equals, value: "a" ), .field( key: "bar", operator: .notEquals, value: "b" ), ] ) let encodedValue: String = try encoder.encode(expectation) #expect(result == expectation) #expect(value == encodedValue) } @Test func customOr() throws { let value = """ or: - key: foo operator: equals value: a - key: bar operator: notEquals value: b """ + "\n" let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Condition.self, from: value) let expectation = Condition.or( [ .field( key: "foo", operator: .equals, value: "a" ), .field( key: "bar", operator: .notEquals, value: "b" ), ] ) let encodedValue: String = try encoder.encode(expectation) #expect(result == expectation) #expect(value == encodedValue) } @Test func stringValue() throws { let data = """ key: name operator: equals value: hello """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Condition.self, from: data ) guard case let .field(key, op, value) = result else { Issue.record("Result is not a field case.") return } #expect(key == "name") #expect(op == .equals) #expect(value.value(as: String.self) == "hello") } @Test func arrayValue() throws { let data = """ key: name operator: in value: - foo - bar - baz """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Condition.self, from: data ) guard case let .field(key, op, value) = result else { Issue.record("Result is not a field case.") return } #expect(key == "name") #expect(op == .in) #expect(value.value(as: [String].self) == ["foo", "bar", "baz"]) } @Test func orConditionValues() throws { let data = """ or: - key: name operator: equals value: hello - key: description operator: like value: foo """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Condition.self, from: data ) guard case let .or(conditions) = result else { Issue.record("Result is not an and case.") return } try #require(conditions.count == 2) guard case let .field(key, op, value) = conditions[0] else { Issue.record("Condition is not a field case.") return } #expect(key == "name") #expect(op == .equals) #expect(value.value(as: String.self) == "hello") guard case let .field(key, op, value) = conditions[1] else { Issue.record("Condition is not a field case.") return } #expect(key == "description") #expect(op == .like) #expect(value.value(as: String.self) == "foo") } @Test func complexCondition() throws { let data = """ or: - key: name operator: equals value: hello - and: - key: featured operator: equals value: false - key: likes operator: greaterThan value: 100 """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Condition.self, from: data ) guard case let .or(conditions) = result else { Issue.record("Result is not an and case.") return } try #require(conditions.count == 2) guard case let .field(key, op, value) = conditions[0] else { Issue.record("Condition is not a field case.") return } #expect(key == "name") #expect(op == .equals) #expect(value.value(as: String.self) == "hello") guard case let .and(subconditions) = conditions[1] else { Issue.record("Result is not an and case.") return } guard case let .field(key, op, value) = subconditions[0] else { Issue.record("Condition is not a field case.") return } #expect(key == "featured") #expect(op == .equals) #expect(value.value(as: Bool.self) == false) guard case let .field(key, op, value) = subconditions[1] else { Issue.record("Condition is not a field case.") return } #expect(key == "likes") #expect(op == .greaterThan) #expect(value.value(as: Int.self) == 100) } @Test func wrongCondition() throws { let data = """ wrong: - key: name operator: equals value: hello """ let decoder = ToucanYAMLDecoder() do { _ = try decoder.decode( Condition.self, from: data ) } catch { #expect( error.localizedDescription.contains( "ToucanDecoderError" ) ) } } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Query/DirectionTestSuite.swift ================================================ // // DirectionTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct DirectionTestSuite { @Test func basics() throws { let object = Direction.allCases let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode([Direction].self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode([Direction].self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func defaults() throws { let object = Direction.defaults #expect(object == .asc) } @Test func ascending() throws { let value = "asc" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Direction.self, from: value) let expectation = Direction.asc #expect(result == expectation) } @Test func descending() throws { let value = "desc" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Direction.self, from: value) let expectation = Direction.desc #expect(result == expectation) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Query/OperatorTestSuite.swift ================================================ // // OperatorTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct OperatorTestSuite { @Test func basics() throws { let object = Operator.allCases let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode([Operator].self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode([Operator].self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func equals() throws { let value = "equals" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.equals #expect(result == expectation) } @Test func notEquals() throws { let value = "notEquals" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.notEquals #expect(result == expectation) } @Test func lessThan() throws { let value = "lessThan" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.lessThan #expect(result == expectation) } @Test func lessThanOrEquals() throws { let value = "lessThanOrEquals" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.lessThanOrEquals #expect(result == expectation) } @Test func greaterThan() throws { let value = "greaterThan" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.greaterThan #expect(result == expectation) } @Test func greaterThanOrEquals() throws { let value = "greaterThanOrEquals" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.greaterThanOrEquals #expect(result == expectation) } @Test func like() throws { let value = "like" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.like #expect(result == expectation) } @Test func caseInsensitiveLike() throws { let value = "caseInsensitiveLike" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.caseInsensitiveLike #expect(result == expectation) } @Test func `in`() throws { let value = "in" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.in #expect(result == expectation) } @Test func contains() throws { let value = "contains" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.contains #expect(result == expectation) } @Test func matching() throws { let value = "matching" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Operator.self, from: value) let expectation = Operator.matching #expect(result == expectation) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Query/OrderTestSuite.swift ================================================ // // OrderTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 18.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct OrderTestSuite { @Test func basics() throws { let object = Order(key: "foo") let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Order.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Order.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func defaults() throws { let value = """ key: foo """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Order.self, from: value) let expectation = Order(key: "foo") #expect(result == expectation) } @Test func custom() throws { let value = """ direction: desc key: foo """ + "\n" let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Order.self, from: value) let expectation = Order(key: "foo", direction: .desc) let encodedValue: String = try encoder.encode(expectation) #expect(result == expectation) #expect(value == encodedValue) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Query/QueryTestSuite.swift ================================================ // // QueryTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 04. 15.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct QueryTestSuite { @Test func basics() throws { let object = Query( contentType: "post", scope: "custom", limit: 10, offset: 5, filter: .field(key: "title", operator: .like, value: "foo"), orderBy: [ .init(key: "publication", direction: .desc), .init(key: "title"), ] ) let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Query.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Query.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func defaults() throws { let data = """ contentType: post """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Query.self, from: data) #expect(result.contentType == "post") #expect(result.scope == nil) #expect(result.limit == nil) #expect(result.offset == nil) #expect(result.filter == nil) #expect(result.orderBy.isEmpty) } @Test func custom() throws { let data = """ contentType: post scope: list limit: 1 offset: 0 filter: key: name operator: equals value: hello orderBy: - key: name - key: other direction: desc """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Query.self, from: data ) #expect(result.contentType == "post") #expect(result.scope == Pipeline.Scope.Keys.list.rawValue) #expect(result.limit == 1) #expect(result.offset == 0) guard case let .field(key, op, value) = result.filter else { Issue.record("Result is not a field case.") return } #expect(key == "name") #expect(op == .equals) #expect(value.value(as: String.self) == "hello") try #require(result.orderBy.count == 2) #expect(result.orderBy[0].key == "name") #expect(result.orderBy[0].direction == .asc) #expect(result.orderBy[1].key == "other") #expect(result.orderBy[1].direction == .desc) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Relation/RelationTestSuite.swift ================================================ // // RelationTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 12.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct RelationTestSuite { @Test func basicOrdering() throws { let data = """ references: post type: many order: key: title direction: desc """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Relation.self, from: data ) #expect(result.references == "post") #expect(result.type == .many) #expect(result.order?.key == "title") #expect(result.order?.direction == .desc) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Relation/RelationTypeTestSuite.swift ================================================ // // RelationTypeTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 05. 18.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct RelationTypeTestSuite { @Test func basics() throws { let object = [RelationType.one, RelationType.many] let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode([RelationType].self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode([RelationType].self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func one() throws { let value = "one" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(RelationType.self, from: value) let expectation = RelationType.one #expect(result == expectation) } @Test func many() throws { let value = "many" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(RelationType.self, from: value) let expectation = RelationType.many #expect(result == expectation) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Settings/SettingsTestSuite.swift ================================================ // // SettingsTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 30.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct SettingsTestSuite { @Test func defaults() throws { let object = Settings.defaults let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let value1: String = try encoder.encode(object) let result1 = try decoder.decode(Settings.self, from: value1) let value2: Data = try encoder.encode(object) let result2 = try decoder.decode(Settings.self, from: value2) #expect(object == result1) #expect(object == result2) } @Test func empty() throws { let value = "" let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Settings.self, from: value) let expectation = Settings.defaults #expect(result == expectation) } @Test func custom() throws { let value = """ foo: bar """ + "\n" let encoder = ToucanYAMLEncoder() let decoder = ToucanYAMLDecoder() let result = try decoder.decode(Settings.self, from: value) var expectation = Settings.defaults expectation.values["foo"] = "bar" let encodedValue: String = try encoder.encode(expectation) #expect(result == expectation) #expect(value == encodedValue) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Target/TargetConfigTestSuite.swift ================================================ // // TargetConfigTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 15.. // import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct TargetConfigTestSuite { @Test func full() throws { let data = """ targets: - name: dev config: "./dev.yml" url: "http://localhost:3000" output: "./dist/" default: true - name: live url: "https://example.com" """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( TargetConfig.self, from: data ) #expect(result.targets.count == 2) #expect(result.default.name == "dev") #expect(result.default.isDefault == true) #expect(result.targets[1].name == "live") } @Test func defaultFallbackToFirst() throws { let data = """ targets: - name: fallback - name: another """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( TargetConfig.self, from: data ) #expect(result.targets.count == 2) #expect(result.default.name == "fallback") #expect(result.default.isDefault == true) } @Test func oneDefaultIsValid() throws { let data = """ targets: - name: one default: true - name: two """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode(TargetConfig.self, from: data) #expect(result.targets.count == 2) #expect(result.default.name == "one") } @Test func noDefaultFallsBackToFirst() throws { let data = """ targets: - name: alpha - name: beta """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode(TargetConfig.self, from: data) #expect(result.targets.count == 2) #expect(result.default.name == "alpha") #expect(result.default.isDefault == true) } @Test func multipleDefaultsThrows() throws { let data = """ targets: - name: foo default: true - name: bar default: true """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() #expect(throws: (any Error).self) { _ = try decoder.decode(TargetConfig.self, from: data) } } @Test func emptyListFallsBackToDefaults() throws { let data = """ targets: [] """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode(TargetConfig.self, from: data) #expect(!result.targets.isEmpty) #expect(result.default == Target.standard) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Target/TargetTestSuite.swift ================================================ // // TargetTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 15.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct TargetTestSuite { @Test func full() throws { let data = """ name: "dev" config: "./some-config.yml" url: "https://example.com" output: "./out" default: true """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Target.self, from: data ) #expect(result.name == "dev") #expect(result.config == "./some-config.yml") #expect(result.url == "https://example.com") #expect(result.output == "./out") #expect(result.isDefault == true) } @Test func defaults() throws { let data = """ name: "dev" """ .data(using: .utf8)! let decoder = ToucanYAMLDecoder() let result = try decoder.decode( Target.self, from: data ) #expect(result.name == "dev") #expect(result.config == "") #expect(result.url == "http://localhost:3000") #expect(result.output == "dist") #expect(result.isDefault == false) } } ================================================ FILE: Tests/ToucanSourceTests/Objects/Types/TypesTestSuite.swift ================================================ // // TypesTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 30.. import Foundation import Testing import ToucanSerialization @testable import ToucanSource @Suite struct TypesTestSuite { @Test func minimal() throws { let data = """ id: post """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(ContentType.self, from: data) #expect(result.id == "post") } @Test func complex() throws { let data = """ id: post properties: title: type: string publication: type: date config: format: "y.m.d" required: true relations: authors: references: author type: many order: key: name tags: references: tag type: many order: key: priority direction: desc queries: prev: contentType: post limit: 1 filter: key: publication operator: lessThan value: "{{publication}}" orderBy: - key: publication direction: desc next: contentType: post limit: 1 filter: key: publication operator: greaterThan value: "{{publication}}" orderBy: - key: publication direction: asc related: contentType: post limit: 4 filter: and: - key: authors operator: matching value: "{{authors}}" - key: id operator: notEquals value: "{{id}}" similar: contentType: post limit: 4 filter: and: - key: tags operator: matching value: "{{tags}}" - key: id operator: notEquals value: "{{id}}" """ let decoder = ToucanYAMLDecoder() let result = try decoder.decode(ContentType.self, from: data) #expect(result.id == "post") } } ================================================ FILE: Tests/ToucanSourceTests/RawContentLoaderTestSuite.swift ================================================ // // RawContentLoaderTestSuite.swift // Toucan // // Created by Tibor Bödecs on 2025. 05. 19.. // import FileManagerKit import FileManagerKitBuilder import Foundation import Logging import Testing import ToucanSerialization @testable import ToucanSource extension RawContent { var withoutLastModificationDatePrecision: Self { .init( origin: origin, markdown: markdown, lastModificationDate: Double(Float(lastModificationDate)), assetsPath: assetsPath, assets: assets ) } } @Suite struct RawContentLoaderTestSuite { private func testSourceContentsHierarchy( @FileManagerPlayground.ItemBuilder _ builder: () -> [FileManagerPlayground.Item] ) -> Directory { Directory(name: "src") { Directory(name: "contents", builder) } } private func testRawContentLoader( fileManager: FileManagerKit, url: URL ) -> RawContentLoader { let url = url.appending(path: "./src/") let decoder = ToucanYAMLDecoder() let config = Config.defaults let locations = BuiltTargetSourceLocations( sourceURL: url, config: config ) let loader = RawContentLoader( contentsURL: locations.contentsURL, assetsPath: config.contents.assets.path, decoder: .init(), markdownParser: .init(decoder: decoder), fileManager: fileManager ) return loader } // MARK: - locate origin index file types private func testBlogArticleHierarchy( @FileManagerPlayground.ItemBuilder _ builder: () -> [FileManagerPlayground.Item] ) -> Directory { testSourceContentsHierarchy { Directory(name: "blog") { Directory(name: "articles") { "noindex.yaml" Directory(name: "first-beta-release", builder) } } } } private func testBlogArticleOrigin() -> Origin { .init( path: .init("blog/articles/first-beta-release"), slug: "blog/first-beta-release" ) } private func testExpectationRequirements( fileManager: FileManagerKit, url: URL ) throws { let loader = testRawContentLoader(fileManager: fileManager, url: url) let results = loader.locateOrigins() #expect(results.count == 1) let result = try #require(results.first) let expected = testBlogArticleOrigin() #expect(result == expected) } // MARK: - origins @Test() func locateOriginsEmptyResults() async throws { try FileManagerPlayground() .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.isEmpty) } } @Test() func locateOriginsWithNoIndexFile() async throws { try FileManagerPlayground { testSourceContentsHierarchy { Directory(name: "blog") { Directory(name: "articles") { "noindex.yaml" Directory(name: "first-beta-release") { "index.md" } } } } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.count == 1) let result = try #require(results.first) let expected = Origin( path: .init("blog/articles/first-beta-release"), slug: "blog/first-beta-release" ) #expect(result == expected) } } @Test() func locateOriginsIgnoreSlugBrackets() async throws { try FileManagerPlayground { testSourceContentsHierarchy { Directory(name: "[01]blog") { Directory(name: "[01]articles") { Directory(name: "[01]first-beta-release") { "index.md" } } } } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.count == 1) let result = try #require(results.first) let expected = Origin( path: .init( "[01]blog/[01]articles/[01]first-beta-release" ), slug: "blog/articles/first-beta-release" ) #expect(result == expected) } } @Test() func locateOriginsNoIndexBrackets() async throws { try FileManagerPlayground { testSourceContentsHierarchy { Directory(name: "[01]blog") { Directory(name: "[articles]") { Directory(name: "[02]first-beta-release") { "index.md" } } } } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.count == 1) let result = try #require(results.first) let expected = Origin( path: .init( "[01]blog/[articles]/[02]first-beta-release" ), slug: "blog/first-beta-release" ) #expect(result == expected) } } @Test( arguments: [ ["index.md"], ["index.markdown"], ["index.yml"], ["index.yaml"], ["index.yml", "index.yaml"], ["index.md", "index.markdown"], ["index.md", "index.markdown", "index.yml", "index.yaml"], ] ) func locateFiles(files: [String]) async throws { try FileManagerPlayground { testBlogArticleHierarchy { files.map { .file(.init(name: $0)) } } } .test { try testExpectationRequirements(fileManager: $0, url: $1) } } // MARK: - loading contents func testMarkdownFile( ext: String, modificationDate: Date ) -> File { File( name: "index.\(ext)", attributes: [ .modificationDate: modificationDate ], string: """ --- title: "Hello index.\(ext)" --- # Hello index.\(ext) Lorem ipsum dolor sit amet """ ) } func testYAMLFile( ext: String, modificationDate: Date ) -> File { File( name: "index.\(ext)", attributes: [ .modificationDate: modificationDate ], string: """ title: "Hello index.\(ext)" """ ) } func testAssetsDirectory() -> Directory { Directory(name: "assets") { "cover.png" "main.js" "style.css" } } func testExpectedRawContent( ext: String, emptyContents: Bool, modificationDate: Date ) -> RawContent { .init( origin: testBlogArticleOrigin(), markdown: .init( frontMatter: [ "title": "Hello index.\(ext)" ], contents: emptyContents ? "" : """ # Hello index.\(ext) Lorem ipsum dolor sit amet """ ), lastModificationDate: modificationDate.timeIntervalSince1970, assetsPath: "assets", assets: [ "cover.png", "main.js", "style.css", ] .sorted() ) } @Test( arguments: [ "md", "markdown", ] ) func loadMarkdownContents(ext: String) async throws { let now = Date() try FileManagerPlayground { testBlogArticleHierarchy { testAssetsDirectory() testMarkdownFile(ext: ext, modificationDate: now) } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.count == 1) let origin = try #require(results.first) let content = try loader.loadRawContent(at: origin) let expectation = testExpectedRawContent( ext: ext, emptyContents: false, modificationDate: now ) #expect( content.withoutLastModificationDatePrecision == expectation.withoutLastModificationDatePrecision ) } } @Test( arguments: [ "yml", "yaml", ] ) func loadYAMLContents(ext: String) async throws { let now = Date() try FileManagerPlayground { testBlogArticleHierarchy { testAssetsDirectory() testYAMLFile(ext: ext, modificationDate: now) } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.count == 1) let origin = try #require(results.first) let content = try loader.loadRawContent(at: origin) let expectation = testExpectedRawContent( ext: ext, emptyContents: true, modificationDate: now ) #expect( content.withoutLastModificationDatePrecision == expectation.withoutLastModificationDatePrecision ) } } @Test() func loadMergedFileContents() async throws { let now = Date() try FileManagerPlayground { testBlogArticleHierarchy { Directory(name: "assets") { "cover.png" "style.css" "main.js" } testMarkdownFile(ext: "md", modificationDate: now) testMarkdownFile(ext: "markdown", modificationDate: now) testYAMLFile(ext: "yml", modificationDate: now) testYAMLFile(ext: "yaml", modificationDate: now) } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = loader.locateOrigins() #expect(results.count == 1) let origin = try #require(results.first) let content = try loader.loadRawContent(at: origin) let exp = RawContent( origin: testBlogArticleOrigin(), markdown: .init( frontMatter: [ "title": "Hello index.yml" ], contents: """ # Hello index.md Lorem ipsum dolor sit amet """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [ "cover.png", "main.js", "style.css", ] .sorted() ) #expect(content.origin == exp.origin) #expect(content.markdown == exp.markdown) #expect(content.markdown.frontMatter == exp.markdown.frontMatter) #expect(content.markdown.contents == exp.markdown.contents) #expect( Float(content.lastModificationDate) == Float(exp.lastModificationDate) ) #expect(content.assets == exp.assets) #expect( content.withoutLastModificationDatePrecision == exp.withoutLastModificationDatePrecision ) } } @Test() func loadMultipleRawContents() async throws { let now = Date() try FileManagerPlayground { testSourceContentsHierarchy { Directory(name: "example-1") { testMarkdownFile(ext: "md", modificationDate: now) } Directory(name: "example-2") { testYAMLFile(ext: "yml", modificationDate: now) } } } .test { let loader = testRawContentLoader(fileManager: $0, url: $1) let results = try loader.load() #expect(results.count == 2) let expected: [RawContent] = [ .init( origin: .init( path: .init("example-1"), slug: "example-1" ), markdown: .init( frontMatter: ["title": "Hello index.md"], contents: """ # Hello index.md Lorem ipsum dolor sit amet """ ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ), .init( origin: .init( path: .init("example-2"), slug: "example-2" ), markdown: .init( frontMatter: ["title": "Hello index.yml"], contents: "" ), lastModificationDate: now.timeIntervalSince1970, assetsPath: "assets", assets: [] ), ] #expect( results.map(\.withoutLastModificationDatePrecision) == expected.map(\.withoutLastModificationDatePrecision) ) } } } ================================================ FILE: Tests/ToucanSourceTests/TemplateLoaderTestSuite.swift ================================================ // // TemplateLoaderTestSuite.swift // Toucan // // Created by Binary Birds on 2025. 03. 12.. import FileManagerKit import FileManagerKitBuilder import Foundation import Logging import Testing import ToucanSerialization @testable import ToucanCore @testable import ToucanSource @Suite struct TemplateLoaderTestSuite { @Test() func standardTemplateLoading() async throws { try FileManagerPlayground { Directory(name: "src") { Directory(name: "assets") { "style.css" } Directory(name: "contents") { Directory(name: "about") { File( name: "pages.about.mustache", string: """ about content override """ ) } } Directory(name: "templates") { Directory(name: "default") { File( name: "template.yaml", string: """ author: name: Test Template Author url: http://localhost:8080/ demo: url: http://localhost:8080/ description: Test Template description generatorVersion: value: "1.0.0-beta.6" type: "upNextMajor" license: name: Test License url: http://localhost:8080/ name: Test Template tags: - blog - adaptive-colors url: http://localhost:8080/ version: 1.0.0 """ ) Directory(name: "assets") { "template.css" } Directory(name: "views") { Directory(name: "pages") { File( name: "default.mustache", string: """ default """ ) File( name: "about.mustache", string: """ about """ ) File( name: "test.html", string: """ test.html """ ) } File( name: "html.mustache", string: """ html """ ) "README.md" } "README.md" } Directory(name: "overrides") { Directory(name: "default") { Directory(name: "assets") { "template.css" } Directory(name: "views") { Directory(name: "pages") { File( name: "default.mustache", string: """ default override """ ) File( name: "about.mustache", string: """ about override """ ) } "README.md" } "README.md" } } } } } .test { let sourceURL = $1.appending(path: "src/") let config = Config.defaults let locations = BuiltTargetSourceLocations( sourceURL: sourceURL, config: config ) let loader = TemplateLoader( locations: locations, fileManager: $0, encoder: ToucanYAMLEncoder(), decoder: ToucanYAMLDecoder() ) let template = try loader.load() #expect( template.metadata.generatorVersion.value.description == "1.0.0-beta.6" ) #expect(template.metadata.generatorVersion.type == .upNextMajor) #expect( template.components.assets.sorted() == [ "template.css" ] .sorted() ) #expect( template.components.views.map(\.path).sorted() == [ "pages/default.mustache", "pages/about.mustache", "pages/test.html", "html.mustache", ] .sorted() ) #expect( template.overrides.assets.sorted() == [ "template.css" ] .sorted() ) #expect( template.overrides.views.map(\.path).sorted() == [ "pages/about.mustache", "pages/default.mustache", ] .sorted() ) #expect( template.content.assets.sorted() == [ "style.css" ] .sorted() ) #expect( template.content.views.map(\.path).sorted() == [ "about/pages.about.mustache" ] .sorted() ) let results = template.getViewIDsWithContents() let exp: [String: String] = [ "pages.test": "test.html", "pages.about": "about content override", "pages.default": "default override", "html": "html", ] #expect( results == .init( uniqueKeysWithValues: exp.sorted { $0.key < $1.key } ) ) } } @Test func defaultGeneratorVersionComparisonType() async throws { try FileManagerPlayground { Directory(name: "src") { Directory(name: "templates") { Directory(name: "default") { File( name: "template.yaml", string: """ author: name: Test Template Author url: http://localhost:8080/ demo: url: http://localhost:8080/ description: Test Template description generatorVersion: value: "1.0.0-beta.6" license: name: Test License url: http://localhost:8080/ name: Test Template tags: - blog - adaptive-colors url: http://localhost:8080/ version: 1.0.0 """ ) } } } } .test { let sourceURL = $1.appending(path: "src/") let config = Config.defaults let locations = BuiltTargetSourceLocations( sourceURL: sourceURL, config: config ) let loader = TemplateLoader( locations: locations, fileManager: $0, encoder: ToucanYAMLEncoder(), decoder: ToucanYAMLDecoder() ) let template = try loader.load() #expect( template.metadata.generatorVersion.value.description == "1.0.0-beta.6" ) #expect(template.metadata.generatorVersion.type == .upNextMajor) } } @Test() func invalidGeneratorVersion() async throws { try FileManagerPlayground { Directory(name: "src") { Directory(name: "templates") { Directory(name: "default") { File( name: "template.yaml", string: """ author: name: Test Template Author url: http://localhost:8080/ demo: url: http://localhost:8080/ description: Test Template description generatorVersion: value: "invalid" type: "upNextMajor" license: name: Test License url: http://localhost:8080/ name: Test Template tags: - blog - adaptive-colors url: http://localhost:8080/ version: 1.0.0 """ ) } } } } .test { let sourceURL = $1.appending(path: "src/") let config = Config.defaults let locations = BuiltTargetSourceLocations( sourceURL: sourceURL, config: config ) let loader = TemplateLoader( locations: locations, fileManager: $0, encoder: ToucanYAMLEncoder(), decoder: ToucanYAMLDecoder() ) do { let _ = try loader.load() Issue.record("Expected DecodingError.dataCorrupted.") } catch let error as ToucanError { guard let objectLoaderError = error.lookup( ObjectLoaderError.self ), let toucanDecoderError = objectLoaderError.lookup( ToucanDecoderError.self ), let decodingError = toucanDecoderError.lookup( DecodingError.self ), case let DecodingError.dataCorrupted(context) = decodingError else { throw error } let expected = "Invalid semantic version" #expect(context.debugDescription == expected) } } } } ================================================ FILE: scripts/install-toucan.sh ================================================ #!/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_DIR="${1:-/usr/local/bin}" if [ ! -d "$INSTALL_DIR" ]; then sudo mkdir -p "$INSTALL_DIR" fi for binary in toucan toucan-generate toucan-init toucan-serve toucan-watch; do sudo install .build/release/${binary} "${INSTALL_DIR}/${binary}" done ================================================ FILE: scripts/packaging/deb.sh ================================================ #!/bin/bash set -e VERSION="$1" NAME="toucan" if [ -z "$VERSION" ]; then echo "Usage: $0 " exit 1 fi ARCH="amd64" BUILD_DIR="build-deb" PKG_ROOT="$BUILD_DIR/${NAME}_${VERSION}" INSTALL_PREFIX="/usr/local/bin" BIN_DIR=".build/release" BINARY_NAMES=("toucan" "toucan-generate" "toucan-init" "toucan-serve" "toucan-watch") echo "📦 Building .deb for $NAME version $VERSION" # Collect matching executables EXECUTABLES="" for BINNAME in "${BINARY_NAMES[@]}"; do CANDIDATE="$BIN_DIR/$BINNAME" if [ -f "$CANDIDATE" ] && [ -x "$CANDIDATE" ]; then EXECUTABLES+="$CANDIDATE"$'\n' else echo "⚠️ Skipping missing or non-executable: $BINNAME" fi done if [ -z "$EXECUTABLES" ]; then echo "❌ No executable binaries found" exit 1 fi # Prepare package directory structure rm -rf "$PKG_ROOT" mkdir -p "$PKG_ROOT/DEBIAN" mkdir -p "$PKG_ROOT$INSTALL_PREFIX" # Copy binaries while IFS= read -r BIN; do [ -z "$BIN" ] && continue BASENAME=$(basename "$BIN") cp "$BIN" "$PKG_ROOT$INSTALL_PREFIX/$BASENAME" chmod +x "$PKG_ROOT$INSTALL_PREFIX/$BASENAME" echo "✅ Added $BASENAME" done <<< "$EXECUTABLES" cat > "$PKG_ROOT/DEBIAN/control" < Description: $NAME is a static site generator written in Swift. Section: utils Priority: optional EOF dpkg-deb --build "$PKG_ROOT" CUSTOM_NAME="toucan-linux-amd64-${VERSION}.deb" mv "$PKG_ROOT.deb" "$BUILD_DIR/$CUSTOM_NAME" echo "🎉 DEB created: $BUILD_DIR/$CUSTOM_NAME" ================================================ FILE: scripts/packaging/dmg.sh ================================================ #!/bin/bash set -e VERSION="$1" DMG_NAME="toucan-macos-${VERSION}.dmg" VOL_NAME="Toucan Installer" PKG_NAME="toucan-macos-${VERSION}.pkg" RELEASE_DIR="$(pwd)/release" PKG_PATH="${RELEASE_DIR}/${PKG_NAME}" DMG_PATH="${RELEASE_DIR}/${DMG_NAME}" DMG_ROOT="$(pwd)/dmg-root" LICENSE_SOURCE="./LICENSE" if [ -z "$VERSION" ]; then echo "Usage: $0 " exit 1 fi if [ ! -f "$PKG_PATH" ]; then echo "❌ .pkg file not found at $PKG_PATH" exit 1 fi echo "📦 Creating .dmg from $PKG_NAME" rm -rf "$DMG_ROOT" mkdir -p "$DMG_ROOT" # Copy and rename .pkg cp "$PKG_PATH" "$DMG_ROOT/Toucan.pkg" # Copy LICENSE file if [ -f "$LICENSE_SOURCE" ]; then cp "$LICENSE_SOURCE" "$DMG_ROOT/LICENSE" else echo "⚠️ LICENSE file not found at $LICENSE_SOURCE" fi # Create README cat > "$DMG_ROOT/README.txt" < "$DMG_ROOT/Install Toucan.command" <" exit 1 fi echo "📦 Creating .pkg and .zip for $NAME version $VERSION" # Paths ROOT_DIR=$(pwd) UNIVERSAL_DIR="$ROOT_DIR/.build/universal" RELEASE_DIR="$ROOT_DIR/release" PKGROOT="$ROOT_DIR/pkg-root" PKGFILE="$RELEASE_DIR/${NAME}-macos-${VERSION}.pkg" ZIPFILE="$RELEASE_DIR/${NAME}-macos-${VERSION}.zip" SHAFILE="$RELEASE_DIR/${NAME}-macos-${VERSION}.sha256" BINARIES=("toucan" "toucan-generate" "toucan-init" "toucan-serve" "toucan-watch") # Cleanup rm -rf "$UNIVERSAL_DIR" "$PKGROOT" mkdir -p "$UNIVERSAL_DIR" "$PKGROOT/usr/local/bin" "$RELEASE_DIR" # Build universal binaries for BIN in "${BINARIES[@]}"; do ARM64_BIN=".build/arm64-apple-macosx/release/$BIN" X86_64_BIN=".build/x86_64-apple-macosx/release/$BIN" OUT="$UNIVERSAL_DIR/$BIN" if [[ -x "$ARM64_BIN" && -x "$X86_64_BIN" ]]; then lipo -create "$ARM64_BIN" "$X86_64_BIN" -output "$OUT" echo "✅ Universal binary created: $BIN" if [[ -n "$MAC_APP_IDENTITY" ]]; then codesign --sign "$MAC_APP_IDENTITY" \ --options runtime \ --timestamp \ --verbose \ --force "$OUT" echo "🔏 Signed: $BIN" fi cp "$OUT" "$PKGROOT/usr/local/bin/" else echo "⚠️ Skipping $BIN — missing architecture binary" fi done # Add LICENSE mkdir -p "$PKGROOT/usr/local/share/$NAME" cp LICENSE "$PKGROOT/usr/local/share/$NAME/LICENSE" || echo "⚠️ LICENSE not found" # Create .pkg pkgbuild \ --identifier "com.binarybirds.${NAME}" \ --version "$VERSION" \ --install-location / \ --root "$PKGROOT" \ "$PKGFILE" # Sign .pkg if [[ -n "$MAC_INSTALLER_IDENTITY" ]]; then TMPFILE="${PKGFILE}.tmp" productsign --sign "$MAC_INSTALLER_IDENTITY" "$PKGFILE" "$TMPFILE" mv "$TMPFILE" "$PKGFILE" echo "✅ Signed .pkg: $PKGFILE" else echo "⚠️ No MAC_INSTALLER_IDENTITY provided" fi # Notarize .pkg if [[ -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APP_SPECIFIC_PASSWORD" ]]; then echo "📤 Notarizing .pkg" xcrun notarytool submit "$PKGFILE" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APP_SPECIFIC_PASSWORD" \ --wait xcrun stapler staple "$PKGFILE" echo "✅ Notarization complete" else echo "⚠️ Notarization skipped" fi # Zip universal binaries cd "$UNIVERSAL_DIR" zip -r "$ZIPFILE" ./* cd "$ROOT_DIR" # SHA256 hash shasum -a 256 "$ZIPFILE" > "$SHAFILE" echo "✅ SHA256 written: $SHAFILE" ================================================ FILE: scripts/packaging/rpm.sh ================================================ #!/bin/bash set -e VERSION="$1" NAME="toucan" if [ -z "$VERSION" ]; then echo "Usage: $0 " exit 1 fi TARBALL="${NAME}-${VERSION}.tar.gz" TOPDIR="$HOME/rpmbuild" BIN_DIR=".build/release" BUILD_DIR="build-rpm" BINARY_NAMES=("toucan" "toucan-generate" "toucan-init" "toucan-serve" "toucan-watch") echo "📦 Building RPM for $NAME version $VERSION" # Prepare RPM directories mkdir -p "$TOPDIR"/{BUILD,RPMS,SOURCES,SPECS,SRPMS} mkdir -p "$BUILD_DIR" WORKDIR=$(mktemp -d) trap 'rm -rf "$WORKDIR"' EXIT # Stage binaries SRC_DIR="$WORKDIR/${NAME}-${VERSION}/usr/local/bin" mkdir -p "$SRC_DIR" EXECUTABLES=() for BIN in "${BINARY_NAMES[@]}"; do SRC="$BIN_DIR/$BIN" if [ -x "$SRC" ]; then cp "$SRC" "$SRC_DIR/" chmod +x "$SRC_DIR/$BIN" EXECUTABLES+=("$BIN") echo "✅ Staged: $BIN" else echo "⚠️ Skipped: $BIN" fi done if [ ${#EXECUTABLES[@]} -eq 0 ]; then echo "❌ No valid executables found" exit 1 fi # Optionally include license file and readme file cp -f LICENSE README.md "$WORKDIR/${NAME}-${VERSION}/" 2>/dev/null || echo "ℹ️ File(s) not found" # Create source tarball for rpmbuild tar -czf "$TOPDIR/SOURCES/$TARBALL" -C "$WORKDIR" "${NAME}-${VERSION}" # Copy .spec file cp "./scripts/packaging/${NAME}.spec" "$TOPDIR/SPECS/" # Build the RPM rpmbuild -ba "$TOPDIR/SPECS/${NAME}.spec" --define "ver $VERSION" # Copy and rename RPM FINAL_RPM=$(find "$TOPDIR/RPMS" -type f -name "*.rpm" | head -n1) RPM_OUTPUT="$BUILD_DIR/${NAME}-linux-x86_64-${VERSION}.rpm" cp "$FINAL_RPM" "$RPM_OUTPUT" echo "🎉 RPM created: $RPM_OUTPUT" # Create ZIP of raw binaries ZIP_NAME="${NAME}-linux-${VERSION}.zip" SHA_NAME="${NAME}-linux-${VERSION}.sha256" ZIP_DIR="$BUILD_DIR/bin" rm -rf "$ZIP_DIR" mkdir -p "$ZIP_DIR" for BIN in "${EXECUTABLES[@]}"; do cp "$BIN_DIR/$BIN" "$ZIP_DIR/" done cd "$ZIP_DIR" zip "../$ZIP_NAME" ./* cd - >/dev/null # Create SHA256 cd "$BUILD_DIR" shasum -a 256 "$ZIP_NAME" > "$SHA_NAME" cd - >/dev/null echo "✅ ZIP created: $BUILD_DIR/$ZIP_NAME" echo "✅ SHA256 created: $BUILD_DIR/$SHA_NAME" ================================================ FILE: scripts/packaging/toucan.spec ================================================ Name: toucan Version: %{ver} Release: 1 Summary: A static site generator (SSG) written in Swift License: MIT URL: https://github.com/toucansites/toucan Source0: %{name}-%{version}.tar.gz BuildArch: x86_64 %description Toucan is a static site generator written in Swift. %prep %setup -q %build echo "Skipping build; using precompiled binaries." %install mkdir -p %{buildroot}/usr/local/bin cp -a usr/local/bin/* %{buildroot}/usr/local/bin/ %files /usr/local/bin/* %dir /usr/local/bin %license LICENSE %doc README.md ================================================ FILE: scripts/run-chmod.sh ================================================ #!/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/" ================================================ FILE: scripts/uninstall-toucan.sh ================================================ #!/usr/bin/env bash set -euo pipefail log() { printf -- "** %s\n" "$*" >&2; } error() { printf -- "** ERROR: %s\n" "$*" >&2; } fatal() { error "$@"; exit 1; } INSTALL_DIR="${1:-/usr/local/bin}" for binary in toucan toucan-generate toucan-init toucan-serve toucan-watch; do sudo rm -f "${INSTALL_DIR}/${binary}" done