diff --git a/.csharpierignore b/.csharpierignore index 02dc197..7e3489e 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1 +1,3 @@ **/nuget.config +**/_snapshots/ +**/_snapshot/ diff --git a/.editorconfig b/.editorconfig index 8738748..fa80a79 100644 --- a/.editorconfig +++ b/.editorconfig @@ -42,8 +42,9 @@ indent_size = 4 generated_code = true # XML project files -[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,nativeproj,locproj}] +[*.{slnx,csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,nativeproj,locproj}] indent_size = 2 +max_line_length = 160 # Xml build files [*.builds] diff --git a/.github/instructions/blazor.instructions.md b/.github/instructions/blazor.instructions.md index 4139ff1..52bd117 100644 --- a/.github/instructions/blazor.instructions.md +++ b/.github/instructions/blazor.instructions.md @@ -1,9 +1,7 @@ --- applyTo: '**/*.razor, **/*.razor.cs, **/*.razor.css' -description: 'This file contains instructions for Blazor development. - It includes guidelines for component development, performance optimization, and following Blazor coding standards. - Ensure to follow the practices outlined in this file to maintain code quality and consistency.' +description: This file contains instructions for Blazor development. It includes guidelines for component development, performance optimization, and following Blazor coding standards. Ensure to follow the practices outlined in this file to maintain code quality and consistency. --- # Blazor Development Instructions diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index a1a9f90..2a62557 100644 --- a/.github/instructions/csharp.instructions.md +++ b/.github/instructions/csharp.instructions.md @@ -1,9 +1,7 @@ --- applyTo: '**/*.cs' -description: 'This file contains instructions for C# development. - It includes guidelines for using GitHub Copilot, managing dependencies, and following coding standards. - Ensure to follow the practices outlined in this file to maintain code quality and consistency.' +description: This file contains instructions for C# development. It includes guidelines for using GitHub Copilot, managing dependencies, and following coding standards. Ensure to follow the practices outlined in this file to maintain code quality and consistency. --- # C# Development Instructions diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6778c94..5ebf52a 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -28,9 +28,13 @@ jobs: all: if: github.run_id != 1 name: Build & Tests - uses: dailydevops/pipelines/.github/workflows/build-dotnet-single.yml@1.2.28 + uses: dailydevops/pipelines/.github/workflows/build-dotnet-matrix.yml@8c417832638f49cca3ea19247dc9f15d3632a1fc # 2.2.8 with: + disableTestsOnLinux: false + disableTestsOnMacOs: false + disableTestsOnWindows: false dotnetLogging: ${{ inputs.dotnet-logging }} dotnetVersion: ${{ vars.NE_DOTNET_TARGETFRAMEWORKS }} + failFast: false solution: ./ForgingBlazor.slnx secrets: inherit diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml new file mode 100644 index 0000000..e1db07b --- /dev/null +++ b/.github/workflows/cleanup-cache.yml @@ -0,0 +1,44 @@ +name: Delete Cache after PR merge + +on: + pull_request: + types: + - closed + +permissions: + contents: read + actions: write + +jobs: + cleanup: + name: Clear cache + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + fetch-depth: 0 + + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + REPO=${{ github.repository }} + + # get the branch + BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" + + # fetch list of cache key + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) + + # set this to not fail the workflow while deleting cache keys + set +e + + # delete cache key + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/clone-labels.yml b/.github/workflows/clone-labels.yml new file mode 100644 index 0000000..903a242 --- /dev/null +++ b/.github/workflows/clone-labels.yml @@ -0,0 +1,35 @@ +name: Clone Labels + +on: + schedule: + - cron: '0 0 1 * *' # 1st of every month at 00:00 UTC + - cron: '0 0 15 * *' # 15th of every month at 00:00 UTC + + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + clone-labels: + name: Clone Labels + runs-on: ubuntu-latest + if: github.repository != 'dailydevops/template-dotnet' + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Clone labels + run: | + SOURCE_REPO="dailydevops/template-dotnet" + + echo "Cloning labels from $SOURCE_REPO" + + # Clone labels from source to target repository + gh label clone $SOURCE_REPO --force + + echo "✅ Labels successfully cloned from $SOURCE_REPO to ${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..e190b15 --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,28 @@ +name: NuGet Package + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + actions: read + contents: write + pull-requests: write + +concurrency: + group: publish-nuget + cancel-in-progress: true + +jobs: + nuget: + name: Publish + if: ${{ github.event.workflow_run.conclusion == 'success' && github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }} + uses: dailydevops/pipelines/.github/workflows/publish-nuget.yml@8c417832638f49cca3ea19247dc9f15d3632a1fc # 2.2.8 + with: + workflowName: ${{ github.event.workflow_run.name }} + artifactPattern: release-packages-* + environment: NuGet + runId: ${{ github.event.workflow_run.id }} + secrets: inherit diff --git a/.github/workflows/update-license.yml b/.github/workflows/update-license.yml index 42ad82c..a49e3e4 100644 --- a/.github/workflows/update-license.yml +++ b/.github/workflows/update-license.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write pull-requests: write jobs: @@ -15,11 +15,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 - - uses: FantasticFiasco/action-update-license-year@v3 + - uses: FantasticFiasco/action-update-license-year@f180e962fa988db222d8f03ef4636750312d1b3d # v3 with: token: ${{ secrets.GITHUB_TOKEN }} prTitle: 'chore: Updated LICENSE' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..000ec0b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +info@daily-devops.net. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..06021dc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +All contributions must follow the rules below to stay consistent with our automation and downstream consumers. + +## Expectations +- Use English for all code, documentation, discussions, and commit messages. +- Follow the trunk-based workflow implied by GitVersion; prefer short-lived feature branches merged via pull requests. +- Keep production code under `src/` and test projects under `tests/` using the `{ProjectName}.Tests.Unit` / `{ProjectName}.Tests.Integration` pattern. +- Add NuGet packages without versions in project files; define versions centrally in `Directory.Packages.props` only. +- Leave repository-wide config files (e.g., `.editorconfig`, `Directory.Build.props`, `Directory.Packages.props` structure) unchanged unless explicitly requested. + +## Commit messages (required) +Commit messages **must** follow the [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) format: + +```text +[optional scope]: +``` + +Allowed types include `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`, `perf`, and `revert`. Indicate breaking changes with `!` after the type/scope or a `BREAKING CHANGE:` footer. + +## Development workflow +- The `global.json` file pins test runner and in some cases the required SDK version; see README.md for the required .NET SDK version and any additional prerequisites. +- Restore, build, and test from the solution root before opening a pull request: `dotnet restore`, `dotnet build`, `dotnet test`. +- Execute code formatting using `csharpier format .` from the solution root. +- Keep code and documentation clear and concise; prefer small, focused pull requests. +- Update documentation when behavior or public surface changes. + +## Pull requests +- Provide a short summary, the motivation for the change, and any relevant issue links. +- List the tests you ran and the outcomes. +- Ensure new code includes appropriate tests (unit or integration) placed under the matching `tests/` project. +- Avoid reformatting unrelated code or introducing drive-by changes. + +## Dependency updates +We use Renovate Bot to create automated dependency update pull requests using conventional commit prefixes. When adding new dependencies manually, follow the same conventions: +- Declare the version in `Directory.Packages.props`. +- Reference the package in the project file without a version attribute. +- Prefer the smallest viable dependency set; remove unused packages. \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index c6df64b..195e29b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,15 +1,15 @@ $(MSBuildProjectName) - - - - $(RepositoryUrl)/releases - - 2024 + ForgingBlazor is a modern Blazor-based static site generator backed by a clean, well-structured component library. Built on .NET for high performance and fast builds, it delivers a streamlined, Blazor-first workflow for creating efficient, maintainable, and scalable websites. + https://github.com/dailydevops/forgingblazor.git + https://github.com/dailydevops/forgingblazor + $(PackageProjectUrl)/releases + forgingblazor;staticcontent;staticsitegenerator;generator;blog;blogengine;blazor + 2025 - - - net8.0 + + <_ProjectTargetFrameworks>net9.0;net10.0 + <_TestTargetFrameworks>net9.0;net10.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 410e154..e3d9cb7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,12 +4,32 @@ true - + - + - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/ForgingBlazor.slnx b/ForgingBlazor.slnx index bfb1438..5d8e3ab 100644 --- a/ForgingBlazor.slnx +++ b/ForgingBlazor.slnx @@ -20,6 +20,17 @@ - - + + + + + + + + + + diff --git a/LICENSE b/LICENSE index 8095c9a..db012ef 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-2025 Daily DevOps & .NET +Copyright (c) 2023-2026 Daily DevOps & .NET Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/renovate.json b/renovate.json index ce044b3..58b8d02 100644 --- a/renovate.json +++ b/renovate.json @@ -13,9 +13,19 @@ "automergeType": "pr", "automergeStrategy": "squash", "enabledManagers": [ - "ci", - "nuget", - "custom.regex" + "custom.regex", + "dockerfile", + "github-actions", + "nuget" + ], + "ignorePaths": [ + "**/node_modules/**", + "**/bower_components/**", + "**/vendor/**", + "**/examples/**", + "**/__tests__/**", + "**/test/**", + "**/__fixtures__/**" ], "customManagers": [ { @@ -26,11 +36,22 @@ ], "matchStrings": [ "[^\"]+)\"\\s+Version=\"\\[(?[^,]+),.*\"", - "[^\"]+)\"\\s+Version=\"(?[^,]+)\" />", - "[^\"]+)/(?[^,]+)\">" + "[^\"]+)\"\\s+Version=\"(?[^,]+)\"", + "[^\"]+)/(?[^,]+)\"" ], "datasourceTemplate": "nuget", "versioningTemplate": "nuget" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/\\.cs$/" + ], + "matchStrings": [ + "/\\*\\s*dockerimage\\s*\\*/\\s*\"(?[^:]+):(?[^\"]+)\"" + ], + "datasourceTemplate": "docker", + "versioningTemplate": "docker" } ], "packageRules": [ @@ -51,17 +72,58 @@ }, { "matchManagers": [ - "ci" + "github-actions" ], "labels": [ "dependency:actions" ], + "automerge": true, "semanticCommits": "enabled", "semanticCommitType": "chore", "semanticCommitScope": "ci", "assignees": [ "samtrion" ] + }, + { + "matchDatasources": [ + "docker", + "custom.regex" + ], + "labels": [ + "dependency:docker" + ], + "semanticCommits": "enabled", + "semanticCommitType": "chore", + "semanticCommitScope": "docker", + "assignees": [ + "samtrion" + ], + "versioning": "loose" + }, + { + "groupName": "Testcontainers packages", + "groupSlug": "testcontainers-packages", + "matchManagers": [ + "nuget", + "custom.regex" + ], + "matchPackageNames": [ + "Testcontainers", + "Testcontainers.*" + ] + }, + { + "groupName": "TUnit packages", + "groupSlug": "tunit-packages", + "matchManagers": [ + "nuget", + "custom.regex" + ], + "matchPackageNames": [ + "TUnit", + "TUnit.*" + ] } ] } diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IApplication.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IApplication.cs new file mode 100644 index 0000000..9ba9f45 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IApplication.cs @@ -0,0 +1,24 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Represents the main application entry point that can be executed asynchronously. +/// +/// +/// This interface defines the contract for applications built using the ForgingBlazor framework. +/// Implementations encapsulate the application's execution logic and lifecycle. +/// +public interface IApplication +{ + /// + /// Runs the application asynchronously and returns an exit code indicating the result. + /// + /// A token that can be used to request cancellation of the application execution. + /// + /// A that represents the asynchronous operation. + /// The task result contains an exit code where 0 typically indicates success, and non-zero values indicate various error conditions. + /// + /// + /// Implementations should respect the cancellation token and return a non-zero exit code (typically 130) when cancellation is requested. + /// + ValueTask RunAsync(CancellationToken cancellationToken = default); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IApplicationBuilder.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IApplicationBuilder.cs new file mode 100644 index 0000000..b0a8f55 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IApplicationBuilder.cs @@ -0,0 +1,33 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using Microsoft.Extensions.DependencyInjection; + +/// +/// Provides a builder pattern for configuring and constructing an instance. +/// +/// +/// This interface follows the builder pattern to allow fluent configuration of services and application settings +/// before creating the final application instance. Use the property to register services +/// in the dependency injection container, then call to create the configured application. +/// +public interface IApplicationBuilder +{ + /// + /// Builds and returns a configured instance based on the current builder state. + /// + /// A fully configured application instance ready to be run. + /// + /// This method should typically be called once after all configuration is complete. + /// The returned application will have access to all services registered in the collection. + /// + IApplication Build(); + + /// + /// Gets the service collection used to register services for dependency injection. + /// + /// + /// An instance that allows registration of application services, + /// configurations, and dependencies that will be available throughout the application lifecycle. + /// + IServiceCollection Services { get; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IBlogSegmentBuilder.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IBlogSegmentBuilder.cs new file mode 100644 index 0000000..c46f82e --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IBlogSegmentBuilder.cs @@ -0,0 +1,41 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Defines a builder pattern for configuring blog content segments in a ForgingBlazor application. +/// +/// +/// The blog post type that inherits from . +/// +/// +/// +/// This interface extends with blog-specific configuration capabilities. +/// It provides a fluent API for adding validation rules and configuring blog segment behavior. +/// +/// +/// Instances implementing this interface are typically created through application builder extension methods +/// such as AddBlogSegment and should not be instantiated directly. +/// +/// +/// +/// +public interface IBlogSegmentBuilder : ISegmentBuilder + where TBlogType : BlogPostBase +{ + /// + /// Adds a validation rule for blog posts in this segment. + /// + /// + /// The instance that defines validation logic for blog posts. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// This method signature hides the base interface method to return the blog-specific builder type, + /// enabling continued use of blog-specific configuration methods in a fluent chain. + /// + new IBlogSegmentBuilder WithValidation(IValidation validation); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentCollector.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentCollector.cs new file mode 100644 index 0000000..1d71130 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentCollector.cs @@ -0,0 +1,51 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a content collector that processes and collects content from a specific source for ForgingBlazor applications. +/// +/// +/// +/// Content collectors are responsible for discovering, parsing, and registering content items (such as pages or blog posts) +/// from various sources. Implementations of this interface define how content is collected for specific segment types. +/// +/// +/// Multiple collectors can be registered for the same segment, and they are executed in priority order +/// (highest priority first). +/// +/// +/// +/// +public interface IContentCollector +{ + /// + /// Gets the priority of this content collector in the collection pipeline. + /// + /// + /// An integer value where higher numbers indicate higher priority. + /// Collectors with higher priority are executed before those with lower priority. + /// + int Priority { get; } + + /// + /// Asynchronously collects content from the source and registers it with the content register. + /// + /// + /// The instance to register collected content with. + /// Cannot be . + /// + /// + /// The instance containing configuration for the content segment. + /// Cannot be . + /// + /// + /// A token that can be used to request cancellation of the collection operation. + /// + /// + /// A that represents the asynchronous content collection operation. + /// + ValueTask CollectAsync( + IContentRegister contentRegister, + IContentRegistration registration, + CancellationToken cancellationToken + ); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentRegister.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentRegister.cs new file mode 100644 index 0000000..5dce03b --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentRegister.cs @@ -0,0 +1,40 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Internal marker interface used to identify the content registration service during the startup phase. +/// This interface is used to register the core content registration implementation in the dependency +/// injection container and enables the filtering mechanism to exclude startup-specific services from +/// the command execution pipeline. +/// +/// +/// This is an intentionally empty marker interface that serves as a metadata tag for service identification. +/// It should be implemented by the internal content registration service that handles content-related +/// initialization during application startup. +/// +public interface IContentRegister +{ + /// + /// Asynchronously collects content from all registered content segments using their associated collectors. + /// + /// + /// A token that can be used to request cancellation of the collection operation. + /// + /// + /// A that represents the asynchronous content collection operation. + /// + ValueTask CollectAsync(CancellationToken cancellationToken); + + /// + /// Registers a page with the content register. + /// + /// + /// The page type that inherits from . + /// + /// + /// The page instance to register. Cannot be . + /// + void Register(TPageType page) + where TPageType : PageBase; +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentRegistration.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentRegistration.cs new file mode 100644 index 0000000..9dfa36c --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IContentRegistration.cs @@ -0,0 +1,65 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Defines a contract for registering content page types with the ForgingBlazor framework. +/// This interface enables the registration and discovery of page types that will be processed +/// and rendered by the framework during content generation. +/// +public interface IContentRegistration +{ + /// + /// Gets the of the page to be registered with the ForgingBlazor framework. + /// + /// + /// A representing the page class that will be used for content generation. + /// This type should typically derive from . + /// + Type PageType { get; } + + /// + /// Gets the URL segment identifier for this content registration. + /// + /// + /// A representing the URL path segment where content is located. + /// An empty string indicates this is the default (root) content registration. + /// + string Segment { get; } + + /// + /// Gets the list of paths to exclude from content collection for this registration. + /// + /// + /// A read-only list of relative paths to exclude, or if no exclusions are defined. + /// + IReadOnlyList? ExcludePaths { get; } + + /// + /// Gets the priority of this content registration in the collection pipeline. + /// + /// + /// An integer value where higher numbers indicate higher priority. + /// Registrations with higher priority are processed before those with lower priority. + /// + int Priority { get; } +} + +/// +/// Defines a content registration that represents the default (fallback) content handling. +/// +/// +/// Default registrations have the lowest priority and are responsible for handling content +/// that doesn't belong to any specific segment. They exclude paths that have specific segment registrations. +/// +public interface IDefaultRegistration : IContentRegistration +{ + /// + /// Sets the paths to exclude from default content collection. + /// + /// + /// The enumerable collection of segment paths to exclude. + /// Cannot be . + /// + void SetExcludePaths(IEnumerable excludePaths); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyAuthor.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyAuthor.cs new file mode 100644 index 0000000..6fd0237 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyAuthor.cs @@ -0,0 +1,19 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a page property that identifies the author or creator of the page content. +/// +/// +/// This interface is commonly used for blog posts, articles, and other content types where authorship attribution is important. +/// +public interface IPropertyAuthor +{ + /// + /// Gets or sets the name or identifier of the page's author. + /// + /// + /// A string containing the author's name or identifier, or if no author is specified. + /// This value is typically displayed in page metadata, bylines, and author attribution sections. + /// + string? Author { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyCategories.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyCategories.cs new file mode 100644 index 0000000..edf5bde --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyCategories.cs @@ -0,0 +1,20 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a page property that organizes content into one or more categories. +/// +/// +/// Categories provide a hierarchical or high-level classification system for organizing content. +/// Unlike tags, categories typically represent broader content groupings and are often used for navigation structure. +/// +public interface IPropertyCategories +{ + /// + /// Gets or sets the collection of categories assigned to this page. + /// + /// + /// A read-only set of category names. Using a set ensures each category appears only once and provides efficient membership testing. + /// Categories are typically used for content organization, navigation menus, and filtering content by topic or section. + /// + IReadOnlySet Categories { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyPublishedDate.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyPublishedDate.cs new file mode 100644 index 0000000..64da29d --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyPublishedDate.cs @@ -0,0 +1,24 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a page property that tracks when the page was published or made available. +/// +/// +/// This interface is commonly used for blog posts, articles, and time-sensitive content where publication date is relevant. +/// The date is stored with timezone information to accurately represent when content became available. +/// +public interface IPropertyPublishedDate +{ + /// + /// Gets or sets the date and time when the blog post was published. + /// + /// + /// A representing the publication date and time with timezone information, + /// or if the publication date has not been set. + /// + /// + /// This property is used for sorting blog posts chronologically, displaying publication dates, + /// and filtering content by date ranges. + /// + DateTimeOffset? PublishDate { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySeries.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySeries.cs new file mode 100644 index 0000000..b6e4ae7 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySeries.cs @@ -0,0 +1,20 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a page property that associates content with a named series or collection of related pages. +/// +/// +/// Series are used to group related content that should be read or presented in a specific order, +/// such as tutorial parts, article series, or multi-part guides. +/// +public interface IPropertySeries +{ + /// + /// Gets or sets the name of the series this page belongs to. + /// + /// + /// A string identifying the series name, or if the page is not part of a series. + /// Pages with the same series name are typically grouped together and presented with navigation between parts. + /// + string? Series { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySitemap.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySitemap.cs new file mode 100644 index 0000000..9d75a78 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySitemap.cs @@ -0,0 +1,30 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines sitemap-related properties that control how a page is included in XML sitemaps. +/// +/// +/// This interface allows pages to configure their visibility and priority in generated sitemap files, +/// which affects how search engines discover and index content. +/// +public interface IPropertySitemap +{ + /// + /// Gets or sets a value indicating whether this page should be excluded from the sitemap. + /// + /// + /// to exclude the page from sitemap generation; to include it. + /// The default value is typically , meaning pages are included by default. + /// + bool ExcludeFromSitemap { get; set; } + + /// + /// Gets or sets the priority hint for this page relative to other pages on the site. + /// + /// + /// An optional integer value typically between 0 and 10, where higher values suggest higher priority, + /// or to use the default priority. This is a hint to search engines and does not + /// guarantee any specific crawling behavior. + /// + int? Priority { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySummary.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySummary.cs new file mode 100644 index 0000000..34d26fb --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertySummary.cs @@ -0,0 +1,19 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a page property that provides a brief summary or description of the page content. +/// +/// +/// This interface is typically used for page metadata, SEO descriptions, and preview text in listings or cards. +/// +public interface IPropertySummary +{ + /// + /// Gets or sets an optional summary or brief description of the page content. + /// + /// + /// A string containing the page summary, or if no summary is specified. + /// This value is commonly used for meta descriptions, RSS feeds, and page previews. + /// + string? Summary { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyTags.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyTags.cs new file mode 100644 index 0000000..489da8e --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IPropertyTags.cs @@ -0,0 +1,20 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Defines a page property that assigns descriptive tags or keywords to content for classification and discovery. +/// +/// +/// Tags provide a flexible, non-hierarchical way to classify content using keywords. +/// Unlike categories, tags are typically more granular and can be freely added to describe specific aspects or topics of the content. +/// +public interface IPropertyTags +{ + /// + /// Gets or sets the collection of tags assigned to this page. + /// + /// + /// A read-only set of tag names or keywords. Using a set ensures each tag appears only once and provides efficient membership testing. + /// Tags are commonly used for content discovery, related content recommendations, and SEO keywords. + /// + IList? Tags { get; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/ISegmentBuilder.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/ISegmentBuilder.cs new file mode 100644 index 0000000..08f97b6 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/ISegmentBuilder.cs @@ -0,0 +1,51 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Defines a builder pattern for configuring content segments in a ForgingBlazor application. +/// +/// +/// The page type that inherits from . +/// +/// +/// +/// This interface provides a fluent API for configuring content segments, including adding validation rules +/// and registering segment-specific services. Implementations allow customization of how pages within +/// a segment are processed and validated. +/// +/// +/// Instances implementing this interface are typically created through application builder extension methods +/// and should not be instantiated directly. +/// +/// +/// +/// +public interface ISegmentBuilder + where TPageType : PageBase +{ + /// + /// Gets the service collection for registering additional services related to the segment. + /// + /// + /// An instance that allows registration of segment-specific services. + /// + IServiceCollection Services { get; } + + /// + /// Adds a validation rule for pages in this segment. + /// + /// + /// The instance that defines validation logic for pages. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// Multiple validation rules can be added to a segment by calling this method multiple times. + /// Each validation will be executed during content processing. + /// + ISegmentBuilder WithValidation(IValidation validation); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IStartUpMarker.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IStartUpMarker.cs new file mode 100644 index 0000000..73c4e23 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IStartUpMarker.cs @@ -0,0 +1,17 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using System.Diagnostics.CodeAnalysis; + +/// +/// Marker interface used to identify services registered during the startup phase of the application. +/// This interface is used internally to filter and distinguish command-related services from other +/// registered services in the dependency injection container. Services implementing this interface +/// are excluded from the command execution pipeline after the startup phase completes. +/// +/// +/// This is an intentionally empty marker interface that serves as a metadata tag for service filtering. +/// It should be implemented by services that are only needed during application initialization and +/// should not be exposed as command-related services during runtime execution. +/// +[SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = "As Designed.")] +public interface IStartUpMarker; diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IValidation.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IValidation.cs new file mode 100644 index 0000000..6f17a96 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IValidation.cs @@ -0,0 +1,21 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Defines a validation service that validates a specific page type within a validation context. +/// +/// The type of page to validate. Must derive from . +/// +/// Implementations of this interface perform validation logic on pages and report issues through the provided . +/// +public interface IValidation + where TPageType : PageBase +{ + /// + /// Validates the specified page and reports any errors or warnings to the validation context. + /// + /// The page instance to validate. + /// The validation context used to record errors and warnings discovered during validation. + void Validate(TPageType page, IValidationContext context); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IValidationContext.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IValidationContext.cs new file mode 100644 index 0000000..9f899c2 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Abstractions/IValidationContext.cs @@ -0,0 +1,87 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +using System.Runtime.CompilerServices; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Represents a validation context that tracks validation state and diagnostics for a specific page type. +/// +public interface IValidationContext + where TPageType : PageBase +{ + /// + /// Gets a value indicating whether the validation context contains any error-level diagnostics. + /// + /// + /// if one or more errors have been recorded during validation; otherwise, . + /// + bool HasErrors { get; } + + /// + /// Gets a value indicating whether the validation context contains any warning-level diagnostics. + /// + /// + /// if one or more warnings have been recorded during validation; otherwise, . + /// + bool HasWarnings { get; } + + /// + /// Validates a property value against a condition and records an error if the condition is met. + /// + /// The type of the property being validated. + /// + /// A function that extracts the property value from the page instance. + /// + /// + /// A predicate function that returns if the property value represents an error condition. + /// + /// + /// The error message to record if the condition evaluates to . + /// + /// + /// The name of the property being validated. This is automatically captured from the expression. + /// + /// + /// The same instance for method chaining. + /// + /// + /// This method uses caller argument expression to automatically capture the property name from the lambda expression, + /// providing clear diagnostic information without manual string literals. + /// + IValidationContext ValidateError( + Func propertyValue, + Func condition, + string errorMessage, + [CallerArgumentExpression(nameof(propertyValue))] string? propertyName = null + ); + + /// + /// Validates a property value against a condition and records a warning if the condition is met. + /// + /// The type of the property being validated. + /// + /// A function that extracts the property value from the page instance. + /// + /// + /// A predicate function that returns if the property value represents a warning condition. + /// + /// + /// The warning message to record if the condition evaluates to . + /// + /// + /// The name of the property being validated. This is automatically captured from the expression. + /// + /// + /// The same instance for method chaining. + /// + /// + /// This method uses caller argument expression to automatically capture the property name from the lambda expression, + /// providing clear diagnostic information without manual string literals. + /// + IValidationContext ValidateWarning( + Func propertyValue, + Func condition, + string warningMessage, + [CallerArgumentExpression(nameof(propertyValue))] string? propertyName = null + ); +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Commands/CommandOptions.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Commands/CommandOptions.cs new file mode 100644 index 0000000..01fbe69 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Commands/CommandOptions.cs @@ -0,0 +1,174 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Commands; + +using System.CommandLine; +using Microsoft.Extensions.Logging; + +/// +/// Provides standard command-line options used across multiple ForgingBlazor CLI commands. +/// +/// +/// This static class centralizes the definition of common command-line options to ensure consistency +/// across all commands. Each option is pre-configured with appropriate defaults and descriptions. +/// +public static class CommandOptions +{ + /// + /// Gets the command-line option for specifying the configuration file path. + /// + /// + /// An for the "--config-path" ("-c") flag with no default value. + /// + /// + /// This option allows users to specify an absolute or relative path to a configuration file + /// that contains settings for the ForgingBlazor application. + /// + public static Option ConfigFile { get; } = + new Option("--config-path", "-c") + { + Description = "Specifies the absolute or relative path to the configuration file", + Arity = ArgumentArity.ZeroOrOne, + }; + + /// + /// Gets the command-line option for specifying the content directory path. + /// + /// + /// An of for the "--content-path" flag with a default value of "content". + /// + /// + /// This option allows users to specify an absolute or relative path to the directory containing content files. + /// The default value of "content" assumes a standard project structure with a content folder at the project root. + /// + public static Option ContentPath { get; } = + new Option("--content-path") + { + Description = "Specifies the absolute or relative path to the content directory", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = static _ => DefaultPaths.Content, + }; + + /// + /// Gets the command-line option for excluding dynamic content from processing. + /// + /// + /// An of for the "--exclude-dynamic-content" flag with a default value of . + /// + /// + /// When specified, this option excludes dynamically generated content from the build output, + /// resulting in a purely static site without any runtime-generated pages. + /// + public static Option ExcludeDynamicContent { get; } = + new Option("--exclude-dynamic-content") + { + Description = "Excludes dynamically generated content from the build output", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = static _ => false, + }; + + /// + /// Gets the command-line option for specifying the execution environment. + /// + /// + /// An of for the "--environment" ("-e") flag with a default value of "Production". + /// + /// + /// This option allows users to specify the environment context for command execution, + /// such as Development, Staging, or Production. This can affect behavior such as draft inclusion + /// and logging levels. + /// + public static Option Environment { get; } = + new Option("--environment", "-e") + { + Description = "Specifies the execution environment (e.g., Development, Staging, Production)", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = static _ => Defaults.Environment, + }; + + /// + /// Gets the command-line option for including draft pages in processing. + /// + /// + /// An of for the "--include-drafts" flag with a default value of . + /// + /// + /// When specified, this option includes draft content in the output. By default, only published content is processed. + /// + public static Option IncludeDrafts { get; } = + new Option("--include-drafts") + { + Description = "Includes draft pages in the build output", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = static _ => false, + }; + + /// + /// Gets the command-line option for including future-dated pages in processing. + /// + /// + /// An of for the "--include-future" flag with a default value of . + /// + /// + /// When specified, this option includes pages scheduled for future publication in the output. + /// By default, only currently published content is processed. + /// + public static Option IncludeFuture { get; } = + new Option("--include-future") + { + Description = "Includes pages with future publication dates in the build output", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = static _ => false, + }; + + /// + /// Gets the command-line option for setting the logging level. + /// + /// + /// An of for the "--log-level" ("-l") flag + /// with a default value of . + /// + /// + /// This option allows users to control the verbosity of logging output, from minimal (Error) to detailed (Trace) logs. + /// + public static Option LogLevel { get; } = + new Option("--log-level", "-l") + { + Description = + "Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None)", + Arity = ArgumentArity.ZeroOrOne, + Recursive = true, + DefaultValueFactory = static _ => Microsoft.Extensions.Logging.LogLevel.Information, + }; + + /// + /// Gets the command-line option for specifying the project path. + /// + /// + /// An of nullable for the "--project-path" flag with no default value. + /// + /// + /// This option allows users to specify the path to the ForgingBlazor project to process. + /// + public static Option ProjectPath { get; } = + new Option("--project-path") + { + Description = "Specifies the path to the ForgingBlazor project to process", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = static _ => Directory.GetCurrentDirectory(), + }; + + /// + /// Gets the command-line option for specifying the output path. + /// + /// + /// An of nullable for the "--output-path" flag with no default value. + /// + /// + /// This option allows users to specify an absolute or relative path where output files should be generated. + /// + public static Option OutputPath { get; } = + new Option("--output-path") + { + Description = "Specifies the absolute or relative path for the build output", + Arity = ArgumentArity.ZeroOrOne, + }; +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/DefaultPaths.cs b/src/NetEvolve.ForgingBlazor.Extensibility/DefaultPaths.cs new file mode 100644 index 0000000..47a2034 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/DefaultPaths.cs @@ -0,0 +1,35 @@ +namespace NetEvolve.ForgingBlazor.Extensibility; + +/// +/// Provides default constant values for the +/// fully qualified type NetEvolve.ForgingBlazor.Extensibility.DefaultPaths. +/// These values define common relative paths used across the +/// ForgingBlazor extensibility layer. +/// +public static class DefaultPaths +{ + /// + /// The default relative path for asset files. + /// + public const string Assets = "assets"; + + /// + /// The default relative path for content files. + /// + public const string Content = "content"; + + /// + /// The default relative path for data files. + /// + public const string Data = "data"; + + /// + /// The default relative path for static files. + /// + public const string Static = "static"; + + /// + /// The default relative path to the web root. + /// + public const string WebRoot = "wwwroot"; +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Defaults.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Defaults.cs new file mode 100644 index 0000000..a8a29ef --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Defaults.cs @@ -0,0 +1,15 @@ +namespace NetEvolve.ForgingBlazor.Extensibility; + +/// +/// Provides default constant values for the +/// fully qualified type NetEvolve.ForgingBlazor.Extensibility.Defaults. +/// These values define the default application environment settings +/// used across the ForgingBlazor extensibility layer. +/// +public static class Defaults +{ + /// + /// The default application environment name. + /// + public const string Environment = "Production"; +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Models/BlogPostBase.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Models/BlogPostBase.cs new file mode 100644 index 0000000..e78368c --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Models/BlogPostBase.cs @@ -0,0 +1,24 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Models; + +using System.Collections.Generic; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Provides the base implementation for blog post pages with common blogging metadata. +/// +/// +/// This abstract record extends and implements common blogging interfaces +/// to provide publication date, author attribution, and tagging functionality. +/// All blog post types should inherit from this class to ensure consistent blog-specific properties. +/// +public abstract record BlogPostBase : PageBase, IPropertyPublishedDate, IPropertyAuthor, IPropertyTags +{ + /// + public DateTimeOffset? PublishDate { get; set; } + + /// + public string? Author { get; set; } + + /// + public IList? Tags { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/Models/PageBase.cs b/src/NetEvolve.ForgingBlazor.Extensibility/Models/PageBase.cs new file mode 100644 index 0000000..d4de107 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/Models/PageBase.cs @@ -0,0 +1,160 @@ +namespace NetEvolve.ForgingBlazor.Extensibility.Models; + +using System; +using YamlDotNet.Serialization; + +/// +/// Provides the base implementation for all page types in the ForgingBlazor framework. +/// +/// +/// This abstract record defines the fundamental properties that all pages must have, including slug, title, and optional link title. +/// All page types should inherit from this class to ensure consistent core functionality and metadata across the application. +/// +public abstract record PageBase +{ + /// + /// Gets or sets the unique URL-friendly identifier for the page. + /// + /// + /// A URL-safe string that uniquely identifies the page. Must contain only lowercase letters, numbers, and hyphens. + /// This value is used in URL routing and must be unique across pages of the same type. + /// + /// + /// The slug should be lowercase, use hyphens to separate words, and contain no spaces or special characters. + /// Example: "my-first-blog-post" or "getting-started-guide". + /// + [YamlMember( + Alias = "slug", + Description = "The unique identifier for the page used in URLs. Must be URL-friendly and contain no spaces or special characters. This value should be lowercase and use hyphens to separate words." + )] + public required string Slug { get; set; } + + /// + /// Gets or sets the primary display title of the page. + /// + /// + /// The main heading text that appears at the top of the page and in page metadata. + /// This value is also used for SEO title tags and social media sharing. + /// + /// + /// The title should be descriptive and concise for optimal readability and SEO performance. + /// It appears as the main heading (typically H1) on the page. + /// + [YamlMember( + Alias = "title", + Description = "The primary display title of the page. This appears as the main heading and in page metadata. Should be descriptive and concise for optimal readability." + )] + public required string Title { get; set; } + + /// + /// Gets or sets an optional shortened title used in navigation and breadcrumbs. + /// + /// + /// A shortened or alternative version of the title for use in constrained UI spaces, + /// or to use the main as fallback. + /// + /// + /// This property is useful when the main title is too long for navigation menus, breadcrumbs, or other UI elements + /// where space is limited. When not specified, the main title is used throughout the application. + /// + [YamlMember( + Alias = "linkTitle", + Description = "An optional shortened or alternative title used specifically for navigation links and breadcrumbs. When not specified, the main title is used as fallback. Useful for long titles that need truncation in UI elements." + )] + public string? LinkTitle { get; set; } + + /// + /// Gets or sets the absolute URL of the page including the base URL. + /// + /// + /// The fully qualified URL of the page, including the protocol, domain, and path. + /// This value is automatically set during page processing and includes the complete URL structure + /// (e.g., "https://example.com/blog/my-first-post"). + /// + /// + /// This property is excluded from YAML serialization and is populated at runtime. + /// It provides the complete URL for use in metadata, canonical links, and social media sharing tags. + /// The absolute URL is constructed by combining the site's base URL with the page's relative path. + /// + [YamlIgnore] + public string AbsoluteLink { get; internal set; } = default!; + + /// + /// Gets or sets the relative URL path of the page from the site root. + /// + /// + /// The site-relative path of the page starting with a forward slash. + /// This value is automatically set during page processing and represents the path portion of the URL + /// without the protocol or domain (e.g., "/blog/my-first-post"). + /// + /// + /// This property is excluded from YAML serialization and is populated at runtime. + /// It provides the relative path for use in internal navigation, routing, and link generation. + /// The relative URL is derived from the page type and slug, following the site's routing conventions. + /// + [YamlIgnore] + public string RelativeLink { get; internal set; } = default!; + + /// + /// Gets or sets a value indicating whether this page is an index page (_index.md). + /// + /// + /// if this is an index page; otherwise, . + /// + /// + /// This property is excluded from YAML serialization and is set at runtime during content collection. + /// Index pages are special pages that represent directory-level content, typically named _index.md. + /// + [YamlIgnore] + public bool IsIndexPage { get; internal set; } + + /// + /// Gets or sets a value indicating whether this page is the home page (root _index.md). + /// + /// + /// if this is the home page; otherwise, . + /// + /// + /// This property is excluded from YAML serialization and is set at runtime during content collection. + /// The home page is the _index.md file located in the root of the content segment directory. + /// + [YamlIgnore] + public bool IsHomePage { get; internal set; } + + /// + /// Gets or sets the markdown content of the page (excluding YAML front matter). + /// + /// + /// A containing the markdown content, or if no content is present. + /// + /// + /// This property is excluded from YAML serialization and is populated at runtime during content collection. + /// It contains the page's markdown content that appears after the YAML front matter delimiter. + /// + [YamlIgnore] + public string? Content { get; internal set; } + + /// + /// Gets or sets the date and time when the page was created. + /// + /// + /// The creation date and time of the page, or if not set. + /// + /// + /// This property is excluded from YAML serialization and is populated at runtime during content collection. + /// + [YamlIgnore] + public DateTimeOffset? CreationDate { get; internal set; } + + /// + /// Gets or sets the date and time when the page was last modified. + /// + /// + /// The last modified date and time of the page, or if not set. + /// + /// + /// This property is excluded from YAML serialization and is populated at runtime during content collection. + /// + [YamlIgnore] + public DateTimeOffset? LastModifiedDate { get; internal set; } +} diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/NetEvolve.ForgingBlazor.Extensibility.csproj b/src/NetEvolve.ForgingBlazor.Extensibility/NetEvolve.ForgingBlazor.Extensibility.csproj new file mode 100644 index 0000000..85fbe12 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/NetEvolve.ForgingBlazor.Extensibility.csproj @@ -0,0 +1,16 @@ + + + $(_ProjectTargetFrameworks) + $(PackageTags);extensibility + `NetEvolve.ForgingBlazor.Extensibility` is the core extensibility package for the ForgingBlazor framework, providing the fundamental abstractions, interfaces, and base models required to build custom content processors and extend the framework's functionality. This package serves as the foundation for creating plugins, content handlers, and custom page types within the ForgingBlazor ecosystem. + + + + + + + + + + + diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/README.md b/src/NetEvolve.ForgingBlazor.Extensibility/README.md new file mode 100644 index 0000000..45d137f --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/README.md @@ -0,0 +1,150 @@ +# NetEvolve.ForgingBlazor.Extensibility + +[![NuGet](https://img.shields.io/nuget/v/NetEvolve.ForgingBlazor.Extensibility?logo=nuget)](https://www.nuget.org/packages/NetEvolve.ForgingBlazor.Extensibility/) +[![NuGet](https://img.shields.io/nuget/dt/NetEvolve.ForgingBlazor.Extensibility?logo=nuget)](https://www.nuget.org/packages/NetEvolve.ForgingBlazor.Extensibility/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/dailydevops/forgingblazor/blob/main/LICENSE) + +## Overview + +`NetEvolve.ForgingBlazor.Extensibility` is the core extensibility package for the ForgingBlazor framework, providing the fundamental abstractions, interfaces, and base models required to build custom content processors and extend the framework's functionality. This package serves as the foundation for creating plugins, content handlers, and custom page types within the ForgingBlazor ecosystem. + +## Purpose + +The extensibility package is designed to enable developers to: + +* **Extend Content Types**: Create custom page types and blog post structures by inheriting from base models +* **Build Custom Processors**: Implement content registration and validation logic for specialized workflows +* **Develop Plugins**: Create reusable components that integrate seamlessly with the ForgingBlazor application lifecycle +* **Define Custom Segments**: Implement specialized builders for content organization and navigation +* **Integrate Services**: Transfer and manage services across application scopes efficiently + +## Key Features + +### Abstraction Layer + +Provides a comprehensive set of interfaces that define the contracts for: + +* **Application Lifecycle** (`IApplication`, `IApplicationBuilder`): Core application initialization and execution +* **Content Management** (`IContentRegister`, `IContentRegistration`): Content discovery and registration +* **Validation Framework** (`IValidation`, `IValidationContext`): Content and configuration validation +* **Page Properties**: Modular interfaces for blog post metadata including authors, tags, categories, series, publication dates, and summaries +* **Segment Building** (`ISegmentBuilder`, `IBlogSegmentBuilder`): Content organization and navigation structure + +### Base Models + +Robust foundation classes for content creation: + +* **`PageBase`**: Abstract record providing core page properties (slug, title, link title) with YAML serialization support +* **`BlogPostBase`**: Extends `PageBase` with blogging-specific properties including publication dates, author attribution, and tagging capabilities + +### Service Management + +Advanced dependency injection utilities: + +* **Service Transfer**: Efficiently migrate services between different service providers while maintaining scope integrity +* **Startup Isolation**: Automatic filtering of startup-specific services to prevent lifecycle conflicts +* **Scope Management**: Support for hierarchical service provider structures + +## Target Frameworks + +* **.NET 9.0** (`net9.0`) +* **.NET 10.0** (`net10.0`) + +## Dependencies + +This package leverages the following dependencies: + +* **Microsoft.AspNetCore.App**: Core ASP.NET functionality and web framework support +* **System.CommandLine**: Command-line interface infrastructure for extensible applications +* **YamlDotNet**: YAML serialization and deserialization for content metadata + +## Installation + +```bash +dotnet add package NetEvolve.ForgingBlazor.Extensibility +``` + +## Usage + +### Creating a Custom Page Type + +```csharp +using NetEvolve.ForgingBlazor.Extensibility.Models; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +public record CustomPage : PageBase, IPropertySummary +{ + public string? Summary { get; set; } + + // Add custom properties specific to your page type + public string? CustomField { get; set; } +} +``` + +### Implementing a Custom Blog Post + +```csharp +using NetEvolve.ForgingBlazor.Extensibility.Models; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +public record TutorialPost : BlogPostBase, IPropertySeries, IPropertyCategories +{ + public string? Series { get; set; } + public IReadOnlySet? Categories { get; set; } + + // Tutorial-specific properties + public TimeSpan EstimatedDuration { get; set; } + public string DifficultyLevel { get; set; } = "Beginner"; +} +``` + +## Architecture Considerations + +### Extensibility Points + +The package provides multiple extension points: + +1. **Page Models**: Inherit from `PageBase` or `BlogPostBase` to create custom content types +2. **Property Interfaces**: Implement property interfaces (`IPropertyAuthor`, `IPropertyTags`, etc.) for metadata composition +3. **Lifecycle Hooks**: Implement `IApplication` for custom application execution logic +4. **Validation Pipeline**: Add validators through `IValidation` for content integrity +5. **Content Registration**: Use `IContentRegister` for custom content discovery mechanisms + +### Design Principles + +* **Interface Segregation**: Modular property interfaces enable composition over inheritance +* **Immutability**: Record types encourage immutable data structures for thread safety +* **Separation of Concerns**: Clear boundaries between content models, validation, and registration +* **YAML-First**: Built-in support for YAML frontmatter in content files +* **Async-First**: All primary operations support asynchronous execution patterns + +## Best Practices + +1. **Use Records for Content Models**: Leverage C# record types for immutable, value-based content representations +2. **Implement Property Interfaces**: Compose page capabilities through interface implementation rather than deep inheritance +3. **Validate Early**: Implement `IValidation` to catch content issues during build time +4. **Document YAML Attributes**: Use `YamlMember` attributes with descriptions for clear metadata documentation +5. **Respect Cancellation**: Always honor `CancellationToken` parameters in async methods +6. **Keep Models Simple**: Avoid business logic in model classes; delegate to services or handlers + +## Contributing + +Contributions are welcome! Please ensure that: + +* All code follows the project's established patterns and conventions +* New abstractions are well-documented with XML comments +* Changes maintain backward compatibility where possible +* Unit tests cover new functionality + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/dailydevops/forgingblazor/blob/main/LICENSE) file for details. + +## Related Packages + +* [**NetEvolve.ForgingBlazor**](https://www.nuget.org/packages/NetEvolve.ForgingBlazor): Main framework implementation consuming this extensibility package +* [**NetEvolve.ForgingBlazor.Logging**](https://www.nuget.org/packages/NetEvolve.ForgingBlazor.Logging): Logging extensions for the ForgingBlazor framework + +--- + +**Made with ❤️ by the NetEvolve Team** \ No newline at end of file diff --git a/src/NetEvolve.ForgingBlazor.Extensibility/ServiceCollectionExtensions.cs b/src/NetEvolve.ForgingBlazor.Extensibility/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..12bf97b --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Extensibility/ServiceCollectionExtensions.cs @@ -0,0 +1,101 @@ +namespace NetEvolve.ForgingBlazor.Extensibility; + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Provides extension methods for to facilitate service management and transfer operations. +/// +/// +/// +/// This static class offers utility methods for transferring services between service providers, +/// particularly for propagating services from a parent container to child scopes while filtering +/// out startup-specific and transient enumerable services. +/// +/// +public static class ServiceCollectionExtensions +{ + /// + /// Cached reference for used for filtering services. + /// + /// + /// This field caches the type reference to improve performance during service filtering operations + /// by avoiding repeated reflection calls. + /// + private static readonly Type _typeStartUpMarker = typeof(IStartUpMarker); + + /// + /// Cached reference for used for filtering services. + /// + /// + /// This field caches the type reference to improve performance during service filtering operations + /// by avoiding repeated reflection calls. It is used to filter out the enumerable of service descriptors + /// that is passed between providers. + /// + private static readonly Type _typeEnumerableServiceDescriptor = typeof(IEnumerable); + + /// + /// Transfers all non-startup services from one service provider to a service collection, excluding startup markers and enumerables. + /// + /// + /// The target to transfer services into. + /// Cannot be . + /// + /// + /// The source containing the service descriptors to transfer. + /// Cannot be . + /// + /// + /// The same instance with transferred services added, enabling method chaining. + /// + /// + /// + /// This method transfers service descriptors from one service provider to a service collection, + /// while filtering out services that: + /// + /// Implement (startup-specific services) + /// Have implementation types that implement + /// Are assignable to (transient enumerable collections) + /// + /// + /// + /// This is useful for propagating services from a parent container to child scopes without including startup-specific services + /// or internal service descriptors that are used only for configuration. + /// + /// + /// The method uses cached type references (_typeStartUpMarker and _typeEnumerableServiceDescriptor) to optimize performance + /// during repeated filtering operations. + /// + /// + /// Thrown when or is . + public static IServiceCollection TransferAllServices( + this IServiceCollection services, + IServiceProvider serviceProvider + ) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(serviceProvider); + + foreach (var descriptors in serviceProvider.GetServices()) + { + var serviceType = descriptors.ServiceType; + var implementationType = descriptors.ImplementationType; + + if ( + serviceType.IsAssignableTo(_typeStartUpMarker) + || implementationType?.IsAssignableTo(_typeStartUpMarker) == true + || serviceType.IsAssignableTo(_typeEnumerableServiceDescriptor) + || implementationType?.IsAssignableTo(_typeEnumerableServiceDescriptor) == true + ) + { + continue; + } + + services.Add(descriptors); + } + + return services; + } +} diff --git a/src/NetEvolve.ForgingBlazor.Logging/ApplicationBuilderExtensions.cs b/src/NetEvolve.ForgingBlazor.Logging/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..b2ad7be --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Logging/ApplicationBuilderExtensions.cs @@ -0,0 +1,119 @@ +namespace NetEvolve.ForgingBlazor.Logging; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Provides extension methods for to configure and integrate logging services +/// into the Forging Blazor application pipeline. +/// +/// +/// This class offers a fluent API for adding logging capabilities to applications built with the Forging Blazor framework. +/// It provides both pre-configured default logging setups and flexible custom configurations to meet various logging requirements. +/// +public static class ApplicationBuilderExtensions +{ + /// + /// Configures the application with default logging providers, including console and debug output. + /// + /// + /// The instance that represents the application being configured. + /// This parameter serves as the entry point for adding logging services to the application's service collection. + /// Cannot be . + /// + /// + /// The same instance that was passed in, enabling method chaining + /// and fluent configuration of additional application features. + /// + /// + /// Thrown when is . + /// + /// + /// + /// This method provides a convenient way to quickly set up logging with sensible defaults. + /// It automatically configures both console and debug logging providers using the overload + /// . + /// + /// + /// The console provider outputs log messages to the standard console output via , + /// while the debug provider writes to the debug output window in development environments via . + /// + /// + /// For production scenarios or when specific logging providers are required, consider using the overload + /// that accepts an delegate for custom configuration. + /// + /// + /// + /// Example usage: + /// + /// var builder = ApplicationBuilder.Create(); + /// builder.WithLogging(); + /// + /// + /// + public static IApplicationBuilder WithLogging(this IApplicationBuilder builder) => + builder.WithLogging(configure => configure.AddConsole().AddDebug()); + + /// + /// Configures the application with custom logging providers using a flexible configuration delegate. + /// + /// + /// The instance that represents the application being configured. + /// This parameter serves as the entry point for adding logging services to the application's service collection. + /// Cannot be . + /// + /// + /// An delegate that provides fine-grained control over the logging configuration. + /// This delegate receives an instance that can be used to add logging providers, + /// set minimum log levels, add filters, and perform other logging-related configurations. + /// Cannot be . + /// + /// + /// The same instance that was passed in, enabling method chaining + /// and fluent configuration of additional application features. + /// + /// + /// Thrown when or is . + /// + /// + /// + /// This method offers maximum flexibility for configuring logging in the Forging Blazor application. + /// It allows developers to choose specific logging providers, configure log levels, add custom filters, + /// and integrate third-party logging frameworks according to their application's requirements. + /// + /// + /// The configuration delegate is executed immediately during the application builder's configuration phase, + /// ensuring that logging services are properly registered before any application components attempt to use them. + /// + /// + /// Common use cases include integrating structured logging providers (such as Serilog or NLog), + /// configuring cloud-based logging services (like Application Insights or AWS CloudWatch), + /// or setting up custom logging filters for performance optimization. + /// + /// + /// + /// Example with custom log levels and multiple providers: + /// + /// var builder = ApplicationBuilder.Create(); + /// builder.WithLogging(logging => + /// { + /// logging.SetMinimumLevel(LogLevel.Information); + /// logging.AddConsole(); + /// logging.AddEventLog(); + /// logging.AddFilter(Microsoft, LogLevel.Warning); + /// }); + /// + /// + /// + /// + /// + public static IApplicationBuilder WithLogging(this IApplicationBuilder builder, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + + _ = builder.Services.AddLogging(configure); + + return builder; + } +} diff --git a/src/NetEvolve.ForgingBlazor.Logging/NetEvolve.ForgingBlazor.Logging.csproj b/src/NetEvolve.ForgingBlazor.Logging/NetEvolve.ForgingBlazor.Logging.csproj new file mode 100644 index 0000000..e2c9c1e --- /dev/null +++ b/src/NetEvolve.ForgingBlazor.Logging/NetEvolve.ForgingBlazor.Logging.csproj @@ -0,0 +1,18 @@ + + + $(_ProjectTargetFrameworks) + $(PackageTags);logging + + true + + + + + + + + + + + + diff --git a/src/NetEvolve.ForgingBlazor/Application.cs b/src/NetEvolve.ForgingBlazor/Application.cs new file mode 100644 index 0000000..098086a --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Application.cs @@ -0,0 +1,150 @@ +namespace NetEvolve.ForgingBlazor; + +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; + +/// +/// Provides the default implementation of for executing ForgingBlazor command-line applications. +/// +/// +/// +/// This sealed class encapsulates the complete application lifecycle and command execution logic for ForgingBlazor CLI applications. +/// It accepts command-line arguments and a , orchestrating the parsing and invocation of registered +/// commands through the System.CommandLine framework. +/// +/// +/// The application retrieves the root command from the service provider, parses the command-line arguments, +/// and delegates execution to the appropriate command handler based on the parsed input. +/// +/// +/// This class is typically not instantiated directly; use to create and configure +/// application instances with proper dependency injection setup. +/// +/// +/// +/// +/// var builder = ApplicationBuilder.CreateDefaultBuilder(args); +/// var app = builder.Build(); +/// return await app.RunAsync(); +/// +/// +/// +/// +/// +internal sealed class Application : IApplication +{ + /// + /// Stores the command-line arguments passed to the application. + /// + /// + /// These arguments are captured from the application entry point and later parsed by the + /// to determine which CLI commands to execute and with what parameters. + /// + private readonly string[] _args; + + /// + /// Stores the service provider for resolving dependencies during command execution. + /// + /// + /// This service provider is created by the and contains all + /// registered application services, including the and registered content registrations. + /// It is used during to retrieve the root command for parsing and executing CLI commands. + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// Gets or sets the invocation configuration used to customize command execution behavior. + /// + /// + /// An optional instance that controls how commands are executed, + /// including error handling, output redirection, and other CLI invocation options. + /// Returns if no custom configuration is configured. + /// + /// + /// This configuration is passed to the invocation method during command execution in the method. + /// It allows fine-grained control over how the System.CommandLine framework processes and executes parsed commands. + /// + /// + /// + internal InvocationConfiguration? InvocationConfiguration { get; set; } + + /// + /// Initializes a new instance of the class with command-line arguments and a service provider. + /// + /// + /// The command-line arguments passed to the application. + /// Cannot be . + /// These arguments are parsed to determine which commands to execute and with what parameters. + /// + /// + /// The instance that provides access to registered application services. + /// Cannot be . + /// This service provider is used to retrieve the and other registered services + /// for command execution and dependency resolution. + /// + /// + /// + /// This constructor stores references to both the command-line arguments and the service provider + /// for later use during the execution phase. + /// + /// + /// This constructor should typically not be called directly. Instead, use + /// to create and configure application instances with proper dependency injection setup and default services. + /// + /// + /// + /// + /// + public Application(string[] args, IServiceProvider serviceProvider) + { + _args = args; + _serviceProvider = serviceProvider; + } + + /// + /// Asynchronously runs the application by parsing and executing the command-line arguments. + /// + /// + /// A that can be used to request cancellation of the application execution. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains an exit code where 0 typically indicates success, and non-zero values indicate error conditions. + /// + /// + /// + /// This method executes the following steps: + /// + /// Retrieves the from the service provider + /// Parses the command-line arguments using the root command's parsing logic + /// Invokes the parsed command with the optional + /// Returns the exit code from the command invocation + /// + /// + /// + /// The command is resolved from the service provider at runtime, allowing for dependency injection + /// of any required services into the command handlers. The ConfigureAwait(false) call ensures + /// efficient async execution without forcing continuations to run on the original synchronization context. + /// + /// + /// + /// Thrown when the is not found in the service provider. + /// This indicates that the application builder's method + /// was not properly called or required services were not registered. + /// + /// + /// + /// + public async ValueTask RunAsync(CancellationToken cancellationToken = default) + { + // Configure the command structure + var rootCommand = + _serviceProvider.GetService() + ?? throw new InvalidOperationException($"{nameof(RootCommand)} not found in the service provider."); + var parseResults = rootCommand.Parse(_args); + + return await parseResults.InvokeAsync(InvocationConfiguration, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/NetEvolve.ForgingBlazor/ApplicationBuilder.cs b/src/NetEvolve.ForgingBlazor/ApplicationBuilder.cs new file mode 100644 index 0000000..9ce12be --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/ApplicationBuilder.cs @@ -0,0 +1,327 @@ +namespace NetEvolve.ForgingBlazor; + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; +using NetEvolve.ForgingBlazor.Services; + +/// +/// Provides the default implementation of for configuring and building ForgingBlazor applications. +/// +/// +/// +/// This sealed class implements the builder pattern to configure services and create instances. +/// The builder provides a fluent API for configuring dependency injection services before creating the final application instance. +/// +/// +/// Use the static method to instantiate a new builder with default services, +/// or for a minimal configuration without default services. +/// Configure services through the property, and finally call to create the application. +/// +/// +/// +/// +/// var builder = ApplicationBuilder.CreateDefaultBuilder(args); +/// builder.Services.AddSingleton<IMyService, MyService>(); +/// var app = builder.Build(); +/// await app.RunAsync(); +/// +/// +/// +/// +/// +public sealed class ApplicationBuilder : IApplicationBuilder +{ + /// + /// Stores the command-line arguments passed to the application. + /// + /// + /// These arguments are passed from the application entry point and are later made available + /// to the instance when it is built via the method. + /// + private readonly string[] _args; + + /// + /// Stores the type reference for used for service discovery. + /// + /// + /// This type is cached during initialization for efficient service validation in the + /// method. It represents the interface type that all + /// content registrations must implement, used to locate registered content providers. + /// + private readonly Type _typeContentRegistration = typeof(IContentRegistration); + + /// + /// Stores the generic type definition for used for validation. + /// + /// + /// This generic type definition is cached during initialization and used in + /// to check if any default content registration has been added to the service collection. + /// It is compared against the ImplementationType of registered services to determine if + /// at least one page type has been configured as default content. + /// + private readonly Type _typeDefaultContentRegistration = typeof(DefaultContentRegistration<>); + + /// + /// Gets the service collection used to register services for dependency injection. + /// + /// + /// An instance that allows registration of application services, + /// configurations, and dependencies that will be available throughout the application lifecycle. + /// Services registered in this collection will be resolved through the + /// created during the operation. + /// + /// + /// Use this property to register services, configurations, and other dependencies before calling . + /// The service collection is initialized in the constructor and remains available until the builder is built. + /// + /// + /// + public IServiceCollection Services { get; init; } + + /// + /// Initializes a new instance of the class with the specified command-line arguments. + /// + /// The command-line arguments passed to the application. Cannot be . + /// + /// + /// This constructor initializes a new for dependency injection and stores + /// the command-line arguments for later use when creating the instance. + /// + /// + /// It is recommended to use the static factory methods or + /// instead of calling this constructor directly. + /// + /// + /// + /// + public ApplicationBuilder(string[] args) + { + _args = args; + + Services = new ServiceCollection(); + } + + /// + /// Builds and returns a configured instance based on the current builder state. + /// + /// + /// A fully configured instance with all registered services available + /// through dependency injection, ready to be run via . + /// + /// + /// + /// This method performs the following steps: + /// + /// Validates the builder configuration to ensure all required services are registered + /// Creates a copy of service descriptors for later service transfer to child scopes + /// Builds the service provider from the configured service collection + /// Creates and returns a new instance with the service provider + /// + /// + /// + /// After calling this method, the builder should not be reused, and any further service registrations + /// will not affect the created application. The service provider is passed to the + /// where it can be transferred to child scopes through the . + /// + /// + /// + /// Thrown if there are service configuration errors, missing required content registrations, or dependency resolution failures. + /// + /// + /// + /// + /// + public IApplication Build() + { + ValidateConfiguration(); + + var serviceProvider = Services + // Little bit hacky, but we need to pass the service descriptors to the ServiceProvider + // so that we can transfer most of these registered services to child scopes/containers. + .AddSingleton>(Services) + .BuildServiceProvider(); + + return new Application(_args, serviceProvider); + } + + /// + /// Creates a new instance with the specified command-line arguments + /// and registers default services required for ForgingBlazor applications. + /// + /// The command-line arguments passed to the application. Cannot be . + /// + /// A new instance ready for additional service configuration, + /// with default ForgingBlazor services already registered. + /// + /// + /// + /// This is the recommended entry point for creating a ForgingBlazor application builder with standard configuration. + /// The method creates a new builder instance and registers all default services required by the ForgingBlazor framework. + /// + /// + /// After calling this method, you can configure additional services using the property, + /// then call to create the application instance. + /// + /// + /// If you need a minimal builder without default services, use instead. + /// + /// + /// + /// + /// var builder = ApplicationBuilder.CreateDefaultBuilder(args); + /// builder.Services.AddSingleton<ICustomService, CustomService>(); + /// var app = builder.Build(); + /// await app.RunAsync(); + /// + /// + /// + /// + /// + public static IApplicationBuilder CreateDefaultBuilder(string[] args) + { + var builder = new ApplicationBuilder(args); + return builder.AddDefaultContent(); + } + + /// + /// Creates a new instance with the specified command-line arguments, + /// registers default services required for ForgingBlazor applications, and configures default pages with a custom page type. + /// + /// + /// The custom page type that inherits from to be used as the base for default pages. + /// This type will be used to configure the default page structure and behavior throughout the application. + /// + /// The command-line arguments passed to the application. Cannot be . + /// + /// A new instance ready for additional service configuration, + /// with default ForgingBlazor services and custom page type configuration already registered. + /// + /// + /// + /// This is an overload of that allows specifying a custom page type + /// for default page configuration. The method creates a new builder instance, registers all default services required + /// by the ForgingBlazor framework, and configures default pages using the specified . + /// + /// + /// After calling this method, you can configure additional services using the property, + /// then call to create the application instance. + /// + /// + /// If you don't need a custom page type, use the non-generic overload instead. + /// If you need a minimal builder without default services, use . + /// + /// + /// + /// + /// public class CustomPage : PageBase + /// { + /// // Custom page implementation + /// } + /// + /// var builder = ApplicationBuilder.CreateDefaultBuilder<CustomPage>(args); + /// builder.Services.AddSingleton<ICustomService, CustomService>(); + /// var app = builder.Build(); + /// await app.RunAsync(); + /// + /// + /// + /// + /// + /// + /// + public static IApplicationBuilder CreateDefaultBuilder(string[] args) + where TPageType : PageBase + { + var builder = new ApplicationBuilder(args); + return builder.AddDefaultContent(); + } + + /// + /// Creates a new instance with the specified command-line arguments + /// without registering any default services. + /// + /// The command-line arguments passed to the application. Cannot be . + /// + /// A new instance with an empty service collection, + /// ready for manual service configuration. + /// + /// + /// + /// This method creates a minimal builder without any pre-registered services, giving you full control + /// over the service configuration. Use this when you need complete control over which services are registered + /// or when the default services provided by are not required. + /// + /// + /// After calling this method, you must manually register all required services using the + /// property before calling . + /// + /// + /// For standard ForgingBlazor applications with default services, prefer using . + /// + /// + /// + /// + /// var builder = ApplicationBuilder.CreateEmptyBuilder(args); + /// // Manually register all required services + /// builder.Services.AddSingleton<IMyService, MyService>(); + /// var app = builder.Build(); + /// await app.RunAsync(); + /// + /// + /// + /// + /// + internal static IApplicationBuilder CreateEmptyBuilder(string[] args) => new ApplicationBuilder(args); + + /// + /// Validates the builder configuration to ensure all required services are registered. + /// + /// + /// + /// This method performs validation by checking for the following requirements: + /// + /// At least one default content registration must exist in the service collection + /// Logging infrastructure must be configured; if not, is registered as fallback + /// + /// + /// + /// If no content registration is found, this method throws an instructing + /// the user to call or register custom content. + /// If logging is not registered, and services are + /// automatically registered with null implementations to prevent resolution failures. + /// + /// + /// + /// Thrown when no content registration is found in the service collection, indicating that + /// no pages have been configured for the application. + /// + private void ValidateConfiguration() + { + // If no DefaultContentRegistration is found, it means no pages were registered + if ( + !Services.Any(x => + x.ServiceType == _typeContentRegistration + && x.ImplementationType?.Name.Equals(_typeDefaultContentRegistration.Name, StringComparison.Ordinal) + == true + ) + ) + { + throw new InvalidOperationException( + $"No content registration found. Please configure default pages using {ApplicationBuilderExtensions.AddDefaultContent}() or register custom content." + ); + } + + // Check if logging is already registered + if (!Services.IsServiceTypeRegistered()) + { + // If not, register NullLoggerFactory and NullLogger as defaults + _ = Services + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + } + } +} diff --git a/src/NetEvolve.ForgingBlazor/ApplicationBuilderExtensions.cs b/src/NetEvolve.ForgingBlazor/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..0c727a5 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/ApplicationBuilderExtensions.cs @@ -0,0 +1,255 @@ +namespace NetEvolve.ForgingBlazor; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NetEvolve.ForgingBlazor.Builders; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; +using NetEvolve.ForgingBlazor.Models; +using NetEvolve.ForgingBlazor.Services; + +/// +/// Provides extension methods for to configure default page handling. +/// +public static class ApplicationBuilderExtensions +{ + /// + /// Adds a blog segment to the application with the default type. + /// + /// + /// The instance to configure. + /// Cannot be . + /// + /// + /// The URL segment identifier for the blog section. + /// Cannot be or whitespace. + /// + /// + /// An instance for further blog configuration. + /// + /// + /// + /// This is a convenience method that calls + /// with as the type parameter. + /// + /// + /// The segment parameter defines the URL path component where blog posts will be accessible. + /// For example, a segment of blog will place blog posts under the /blog/ path. + /// + /// + /// Thrown when is . + /// Thrown when is or whitespace. + /// + public static IBlogSegmentBuilder AddBlogSegment(this IApplicationBuilder builder, string segment) => + builder.AddBlogSegment(segment); + + /// + /// Adds a blog segment to the application with a custom blog post type. + /// + /// + /// The custom blog post type that inherits from . + /// + /// + /// The instance to configure. + /// Cannot be . + /// + /// + /// The URL segment identifier for the blog section. + /// Cannot be or whitespace. + /// + /// + /// An instance for further blog configuration. + /// + /// + /// + /// This method configures a blog section in the application with custom blog post types. + /// The segment parameter defines the URL path component where blog posts will be accessible. + /// + /// + /// Use the returned to add validation rules, + /// configure pagination, or customize blog behavior. + /// + /// + /// Thrown when is . + /// Thrown when is or whitespace. + /// + /// + public static IBlogSegmentBuilder AddBlogSegment( + this IApplicationBuilder builder, + string segment + ) + where TBlogType : BlogPostBase + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(segment); + + return new BlogSegmentBuilder(builder, segment); + } + + /// + /// Configures the application with default pages using the standard implementation. + /// + /// + /// The instance to configure. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// + /// This is a convenience method that calls + /// with as the type parameter, providing a quick way to set up default pages + /// without specifying a custom page type. + /// + /// + /// Thrown when is . + /// Thrown when default pages have already been registered. + /// + public static IApplicationBuilder AddDefaultContent(this IApplicationBuilder builder) => + builder.AddDefaultContent(); + + /// + /// Configures the application with default pages using a custom page type that derives from . + /// + /// + /// The custom page type that must inherit from and represent the default page implementation. + /// + /// + /// The instance to configure. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// + /// This method configures the application with default page infrastructure using a custom page type. + /// It performs the following steps: + /// + /// Validates that the builder is not + /// Checks that default pages have not already been registered to prevent duplicates + /// Registers all ForgingBlazor core services via + /// Registers the singleton to track that defaults are configured + /// Registers the for content collection + /// Registers the as a keyed singleton service + /// + /// + /// + /// Once default pages are registered, you can add additional custom segments using + /// or . + /// + /// + /// Thrown when is . + /// Thrown when default pages have already been registered in the service collection. + /// + /// + /// + public static IApplicationBuilder AddDefaultContent(this IApplicationBuilder builder) + where TPageType : PageBase + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.Services.IsServiceTypeRegistered()) + { + throw new InvalidOperationException( + "Default pages have already been registered. Multiple registrations are not allowed." + ); + } + + _ = builder + .Services.AddForgingBlazorServices() + .AddMarkdownServices() + .AddSingleton() + .AddSingleton>(); + + builder.Services.TryAddKeyedSingleton>(string.Empty); + + return builder; + } + + /// + /// Adds a content segment to the application with the default type. + /// + /// + /// The instance to configure. + /// Cannot be . + /// + /// + /// The URL segment identifier for the content section. + /// Cannot be or whitespace. + /// + /// + /// An instance for further segment configuration. + /// + /// + /// This is a convenience method that calls + /// with as the type parameter. + /// + /// Thrown when is . + /// Thrown when is or whitespace. + /// + public static ISegmentBuilder AddSegment(this IApplicationBuilder builder, string segment) => + builder.AddSegment(segment); + + /// + /// Adds a content segment to the application with a custom page type. + /// + /// + /// The custom page type that inherits from . + /// + /// + /// The instance to configure. + /// Cannot be . + /// + /// + /// The URL segment identifier for the content section. + /// Cannot be or whitespace. + /// + /// + /// An instance for further segment configuration. + /// + /// + /// + /// This method configures a content segment in the application with custom page types. + /// The segment parameter defines the URL path component where pages will be accessible. + /// + /// + /// Use the returned to add validation rules + /// or customize segment behavior. + /// + /// + /// Thrown when is . + /// Thrown when is or whitespace. + /// + /// + public static ISegmentBuilder AddSegment(this IApplicationBuilder builder, string segment) + where TPageType : PageBase + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(segment); + + _ = builder.Services.AddForgingBlazorServices(); + + var segmentBuilder = new SegmentBuilder(builder, segment); + return segmentBuilder; + } + + /// + /// Internal marker class used to track that default content has been registered. + /// + /// + /// + /// This sealed class implements to provide a type-safe marker + /// for tracking that default pages have been configured in the service collection. + /// + /// + /// The marker is registered as a singleton in the service collection by + /// to prevent duplicate default content registrations. Before registering defaults, the builder + /// checks if this marker already exists using . + /// + /// + /// + /// + private sealed class DefaultContentMarker : IStartUpMarker; +} diff --git a/src/NetEvolve.ForgingBlazor/Builders/BlogSegmentBuilder.cs b/src/NetEvolve.ForgingBlazor/Builders/BlogSegmentBuilder.cs new file mode 100644 index 0000000..a56ab4a --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Builders/BlogSegmentBuilder.cs @@ -0,0 +1,148 @@ +namespace NetEvolve.ForgingBlazor.Builders; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NetEvolve.ForgingBlazor; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; +using NetEvolve.ForgingBlazor.Services; + +/// +/// Provides the default implementation of for configuring blog segments in a ForgingBlazor application. +/// +/// +/// The blog post type that inherits from . +/// +/// +/// +/// This sealed class enables configuration of blog segments with custom validation rules and options. +/// It implements both and +/// to provide blog-specific and general segment configuration capabilities. +/// +/// +/// Instances of this class are created through the extension method +/// and should not be instantiated directly. +/// +/// +/// +/// +/// +internal sealed class BlogSegmentBuilder : IBlogSegmentBuilder + where TBlogPage : BlogPostBase +{ + /// + /// Stores the parent application builder instance. + /// + /// + /// This field holds a reference to the parent for registering blog segment-specific services. + /// + private readonly IApplicationBuilder _builder; + + /// + /// Stores the URL segment identifier for this blog section. + /// + /// + /// This field holds the segment identifier (e.g., blog, posts) that determines + /// the URL path and content directory for blog posts in this segment. + /// + private readonly string _segment; + + /// + /// Gets the service collection for registering additional services related to the blog segment. + /// + /// + /// An instance from the parent . + /// + public IServiceCollection Services => _builder.Services; + + /// + /// Initializes a new instance of the class with the specified application builder and segment. + /// + /// + /// The instance that represents the parent application being configured. + /// Cannot be . + /// + /// + /// The URL segment identifier for this blog section (e.g., blog, posts). + /// Cannot be or whitespace. + /// + /// + /// The priority level for this content registration. Higher values indicate higher priority. + /// Default value is 0. Must be greater than or equal to 0. + /// + /// + /// + /// This constructor performs the following operations: + /// + /// Validates that is not + /// Validates that is not or whitespace + /// Validates that is not less than 0 + /// Registers a new with segment and priority settings + /// Calls to register markdown services + /// Registers a keyed singleton instance with the segment as key + /// + /// + /// + /// Thrown when is . + /// Thrown when is or whitespace. + /// Thrown when is less than 0. + internal BlogSegmentBuilder(IApplicationBuilder builder, string segment, int priority = 0) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(segment); + ArgumentOutOfRangeException.ThrowIfLessThan(priority, 0); + + _builder = builder; + _segment = segment; + + Services + .AddSingleton( + new ContentRegistration { Segment = segment, Priority = priority } + ) + .AddMarkdownServices() + .TryAddKeyedSingleton>(segment); + } + + /// + /// Adds a validation rule for blog posts in this segment. + /// + /// + /// The instance that defines validation logic for blog posts. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// + /// This method registers the validation as a keyed singleton service using the segment identifier as the key. + /// The validation will be applied to blog posts within this specific segment during content processing. + /// + /// + /// Multiple validation rules can be added to the same segment by calling this method multiple times. + /// + /// + /// Thrown when is . + /// + public IBlogSegmentBuilder WithValidation(IValidation validation) + { + ArgumentNullException.ThrowIfNull(validation); + + _ = _builder.Services.AddKeyedSingleton(_segment, validation); + + return this; + } + + /// + /// Explicitly implements by delegating to the public method. + /// + /// + /// The instance that defines validation logic for blog posts. + /// Cannot be . + /// + /// + /// The same builder instance cast to for method chaining. + /// + ISegmentBuilder ISegmentBuilder.WithValidation(IValidation validation) => + WithValidation(validation); +} diff --git a/src/NetEvolve.ForgingBlazor/Builders/SegmentBuilder.cs b/src/NetEvolve.ForgingBlazor/Builders/SegmentBuilder.cs new file mode 100644 index 0000000..45a03d0 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Builders/SegmentBuilder.cs @@ -0,0 +1,122 @@ +namespace NetEvolve.ForgingBlazor.Builders; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NetEvolve.ForgingBlazor; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; +using NetEvolve.ForgingBlazor.Services; + +/// +/// Provides the default implementation of for configuring content segments in a ForgingBlazor application. +/// +/// +/// The page type that inherits from . +/// +/// +/// +/// This sealed class enables configuration of content segments with custom validation rules and options. +/// It provides a fluent API for registering validators and configuring segment-specific services. +/// +/// +/// Instances of this class are created through the extension method +/// and should not be instantiated directly. +/// +/// +/// +/// +internal sealed class SegmentBuilder : ISegmentBuilder + where TPageType : PageBase +{ + /// + /// Stores the parent application builder instance. + /// + /// + /// This field holds a reference to the parent for registering segment-specific services. + /// + private readonly IApplicationBuilder _builder; + + /// + /// Stores the URL segment identifier for this content section. + /// + /// + /// This field holds the segment identifier (e.g., blog, docs) that determines + /// the URL path and content directory for pages in this segment. + /// + private readonly string _segment; + + /// + /// Gets the service collection for registering additional services related to the segment. + /// + /// + /// An instance from the parent . + /// + public IServiceCollection Services => _builder.Services; + + /// + /// Initializes a new instance of the class with the specified application builder and segment. + /// + /// + /// The instance that represents the parent application being configured. + /// Cannot be . + /// + /// + /// The URL segment identifier for this content section (e.g., blog, docs). + /// Cannot be or whitespace. + /// + /// + /// + /// This constructor performs the following operations: + /// + /// Validates that is not + /// Validates that is not or whitespace + /// Registers a new with segment and priority settings + /// Registers a keyed singleton instance with the segment as key + /// + /// + /// + /// Thrown when is . + /// Thrown when is or whitespace. + internal SegmentBuilder(IApplicationBuilder builder, string segment) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(segment); + + _builder = builder; + _segment = segment; + + Services + .AddSingleton(new ContentRegistration { Segment = segment, Priority = 0 }) + .TryAddKeyedSingleton>(segment); + } + + /// + /// Adds a validation rule for pages in this segment. + /// + /// + /// The instance that defines validation logic for pages. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// + /// This method registers the validation as a keyed singleton service using the segment identifier as the key. + /// The validation will be applied to pages within this specific segment during content processing. + /// + /// + /// Multiple validation rules can be added to the same segment by calling this method multiple times. + /// + /// + /// Thrown when is . + /// + public ISegmentBuilder WithValidation(IValidation validation) + { + ArgumentNullException.ThrowIfNull(validation); + + _ = _builder.Services.AddKeyedSingleton(_segment, validation); + + return this; + } +} diff --git a/src/NetEvolve.ForgingBlazor/Commands/CommandBuild.cs b/src/NetEvolve.ForgingBlazor/Commands/CommandBuild.cs new file mode 100644 index 0000000..12bf088 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Commands/CommandBuild.cs @@ -0,0 +1,137 @@ +namespace NetEvolve.ForgingBlazor.Commands; + +using System; +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetEvolve.ForgingBlazor.Configurations; +using NetEvolve.ForgingBlazor.Extensibility; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Commands; + +/// +/// Provides the build command implementation for building and generating static content from a Forging Blazor application. +/// +/// +/// +/// This sealed class implements the build command that processes pages and generates static site output based on +/// configuration options. It registers standard options for environment, drafts, future content, logging, and output paths. +/// +/// +/// The command uses the service provider to transfer services and invokes the content register to collect and process all registered content. +/// +/// +/// +/// +internal sealed partial class CommandBuild : Command, IStartUpMarker +{ + /// + /// Stores the service provider for transferring services during command execution. + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// Stores the logger instance for logging command execution details. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class with the specified service provider. + /// + /// + /// The instance providing access to registered application services. + /// This is used to transfer services for command execution. + /// + /// + /// The instance for logging command execution details. + /// + public CommandBuild(IServiceProvider serviceProvider, ILogger logger) + : base("build", "Builds and generates static content for a Forging Blazor application.") + { + _serviceProvider = serviceProvider; + _logger = logger; + + Add(CommandOptions.ContentPath); + Add(CommandOptions.Environment); + Add(CommandOptions.IncludeDrafts); + Add(CommandOptions.IncludeFuture); + Add(CommandOptions.ProjectPath); + Add(CommandOptions.OutputPath); + + SetAction(ExecuteAsync); + } + + /// + /// Executes the build command asynchronously. + /// + /// + /// The containing parsed command-line arguments. + /// Cannot be . + /// + /// + /// A token that can be used to request cancellation of the build operation. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains an exit code: 0 for success, 1 for failure. + /// + /// + /// + /// This method performs the following steps: + /// + /// Transfers all non-startup services from the parent service provider + /// Builds a new service provider from the transferred services + /// Retrieves the service + /// Invokes asynchronously + /// Returns 0 on success, 1 on any exception + /// + /// + /// + private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var environment = parseResult.GetValue(CommandOptions.Environment); + var projectPath = parseResult.GetValue(CommandOptions.ProjectPath); + + ArgumentException.ThrowIfNullOrWhiteSpace(environment); + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + + var services = new ServiceCollection() + .TransferAllServices(_serviceProvider) + .AddSingleton(_ => ConfigurationLoader.Load(environment, projectPath)); + + _ = services + .ConfigureOptions() + .Configure(siteconfig => + { + var contentPath = parseResult.GetValue(CommandOptions.ContentPath); + if (!string.IsNullOrWhiteSpace(contentPath)) + { + siteconfig.ContentPath = contentPath; + } + }); + + var serviceProvider = services.BuildServiceProvider(); + + var register = serviceProvider.GetRequiredService(); + + try + { + await register.CollectAsync(cancellationToken).ConfigureAwait(false); + + return 0; + } + catch (Exception ex) + { + LogUnhandledException(ex); + return 1; + } + } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Error, + Message = "An unhandled exception occurred during the build process." + )] + private partial void LogUnhandledException(Exception ex); +} diff --git a/src/NetEvolve.ForgingBlazor/Commands/CommandCli.cs b/src/NetEvolve.ForgingBlazor/Commands/CommandCli.cs new file mode 100644 index 0000000..5fd0bb2 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Commands/CommandCli.cs @@ -0,0 +1,59 @@ +namespace NetEvolve.ForgingBlazor.Commands; + +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Commands; + +/// +/// Provides the root CLI command for the ForgingBlazor command-line interface. +/// +/// +/// +/// This sealed class implements the root command that serves as the entry point for the ForgingBlazor CLI. +/// It aggregates all registered sub-commands and provides access to the complete command hierarchy. +/// All available sub-commands are automatically registered from the service provider during initialization. +/// +/// +/// If no sub-commands are registered in the service provider, an is thrown during instantiation. +/// +/// +/// +internal sealed class CommandCli : RootCommand, IStartUpMarker +{ + /// + /// Initializes a new instance of the class with the specified service provider. + /// + /// + /// The instance providing access to registered sub-commands. + /// Must contain registered instances to populate the root command. + /// Cannot be . + /// + /// + /// + /// This constructor retrieves all registered commands from the service provider using + /// and adds them as sub-commands to the root command. + /// + /// + /// Additionally, the option is added as a global option to all commands. + /// + /// + /// Thrown when no sub-commands are registered in the service provider. + public CommandCli(IServiceProvider serviceProvider) + : base("Command-line interface for managing Forging Blazor applications.") + { + var subCommands = serviceProvider.GetServices().ToArray(); + + if (subCommands is null || subCommands.Length == 0) + { + throw new InvalidOperationException("No sub-commands registered"); + } + + foreach (var command in subCommands) + { + Add(command); + } + + Add(CommandOptions.LogLevel); + } +} diff --git a/src/NetEvolve.ForgingBlazor/Commands/CommandCreate.cs b/src/NetEvolve.ForgingBlazor/Commands/CommandCreate.cs new file mode 100644 index 0000000..137c82e --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Commands/CommandCreate.cs @@ -0,0 +1,90 @@ +namespace NetEvolve.ForgingBlazor.Commands; + +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Commands; +using static NetEvolve.ForgingBlazor.Extensibility.Commands.CommandOptions; + +/// +/// Provides the create command implementation for creating new pages in a Forging Blazor application. +/// +/// +/// +/// This sealed class implements the create command that generates new page files in the specified project. +/// It registers options for specifying the project path and output destination for the new page. +/// +/// +/// The command transfers services from the parent provider to create a new service scope for page creation. +/// +/// +/// +/// +internal sealed class CommandCreate : Command, IStartUpMarker +{ + /// + /// Stores the service provider for transferring services during command execution. + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class with the specified service provider. + /// + /// + /// The instance providing access to registered application services. + /// This is used to transfer services for command execution. + /// Cannot be . + /// + /// + /// + /// This constructor initializes the create command with: + /// + /// option for specifying the project directory + /// option for specifying the output location for the new page + /// Action handler via + /// + /// + /// + public CommandCreate(IServiceProvider serviceProvider) + : base("create", "Creates a new page for a Forging Blazor application.") + { + _serviceProvider = serviceProvider; + + Add(ProjectPath); + Add(OutputPath); + + SetAction(ExecuteAsync); + } + + /// + /// Executes the create command asynchronously. + /// + /// + /// The containing parsed command-line arguments. + /// Cannot be . + /// + /// + /// A token that can be used to request cancellation of the create operation. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains 0 on success or completion. + /// + /// + /// + /// This method performs the following steps: + /// + /// Transfers all non-startup services from the parent service provider + /// Builds a new service provider from the transferred services + /// Executes page creation logic (currently returns 0) + /// + /// + /// + private Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + _ = new ServiceCollection().TransferAllServices(_serviceProvider); + return Task.FromResult(0); + } +} diff --git a/src/NetEvolve.ForgingBlazor/Commands/CommandExample.cs b/src/NetEvolve.ForgingBlazor/Commands/CommandExample.cs new file mode 100644 index 0000000..8b68ab7 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Commands/CommandExample.cs @@ -0,0 +1,93 @@ +namespace NetEvolve.ForgingBlazor.Commands; + +using System; +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Commands; +using static NetEvolve.ForgingBlazor.Extensibility.Commands.CommandOptions; + +/// +/// Provides the example command implementation for creating example pages based on ForgingBlazor configuration. +/// +/// +/// +/// This sealed class implements the example command that generates a folder structure with sample pages +/// demonstrating the capabilities of the current ForgingBlazor configuration. This is useful for users +/// learning the framework or setting up starter content. +/// +/// +/// The command transfers services from the parent provider to create a new service scope for example generation. +/// +/// +/// +/// +internal sealed class CommandExample : Command, IStartUpMarker +{ + /// + /// Stores the service provider for transferring services during command execution. + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class with the specified service provider. + /// + /// + /// The instance providing access to registered application services. + /// This is used to transfer services for command execution. + /// Cannot be . + /// + /// + /// + /// This constructor initializes the example command with: + /// + /// option for specifying the project directory + /// option for specifying the output location for example pages + /// Action handler via + /// + /// + /// + public CommandExample(IServiceProvider serviceProvider) + : base("example", "Creates a folder structure with example pages for a Forging Blazor application.") + { + _serviceProvider = serviceProvider; + + Add(ProjectPath); + Add(OutputPath); + + SetAction(ExecuteAsync); + } + + /// + /// Executes the example command asynchronously. + /// + /// + /// The containing parsed command-line arguments. + /// Cannot be . + /// + /// + /// A token that can be used to request cancellation of the example generation operation. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains 0 on success or completion. + /// + /// + /// + /// This method performs the following steps: + /// + /// Transfers all non-startup services from the parent service provider + /// Builds a new service provider from the transferred services + /// Executes example generation logic (currently returns 0) + /// + /// + /// + private Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + _ = new ServiceCollection().TransferAllServices(_serviceProvider); + + return Task.FromResult(0); + } +} diff --git a/src/NetEvolve.ForgingBlazor/Commands/CommandServe.cs b/src/NetEvolve.ForgingBlazor/Commands/CommandServe.cs new file mode 100644 index 0000000..f877737 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Commands/CommandServe.cs @@ -0,0 +1,96 @@ +namespace NetEvolve.ForgingBlazor.Commands; + +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Commands; +using static NetEvolve.ForgingBlazor.Extensibility.Commands.CommandOptions; + +/// +/// Provides the serve command implementation for serving a Forging Blazor application. +/// +/// +/// +/// This sealed class implements the serve command that runs the ForgingBlazor application in a development or +/// preview server mode. It registers standard options for environment, drafts, future content, logging, and output paths. +/// +/// +/// The command transfers services from the parent provider to create a new service scope for serving the application. +/// +/// +/// +/// +internal sealed class CommandServe : Command, IStartUpMarker +{ + /// + /// Stores the service provider for transferring services during command execution. + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class with the specified service provider. + /// + /// + /// The instance providing access to registered application services. + /// This is used to transfer services for command execution. + /// Cannot be . + /// + /// + /// + /// This constructor initializes the serve command with: + /// + /// option for specifying the environment (development, staging, production) + /// option for including draft pages + /// option for including future-dated content + /// option for specifying the project directory + /// option for specifying the output location + /// Action handler via + /// + /// + /// + public CommandServe(IServiceProvider serviceProvider) + : base("serve", "Starts a development server for a Forging Blazor application.") + { + _serviceProvider = serviceProvider; + + Add(Environment); + Add(IncludeDrafts); + Add(IncludeFuture); + Add(ProjectPath); + Add(OutputPath); + + SetAction(ExecuteAsync); + } + + /// + /// Executes the serve command asynchronously. + /// + /// + /// The containing parsed command-line arguments. + /// Cannot be . + /// + /// + /// A token that can be used to request cancellation of the serve operation. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains 0 on success or completion. + /// + /// + /// + /// This method performs the following steps: + /// + /// Transfers all non-startup services from the parent service provider + /// Builds a new service provider from the transferred services + /// Executes the serve logic (currently returns 0) + /// + /// + /// + private Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + _ = new ServiceCollection().TransferAllServices(_serviceProvider); + return Task.FromResult(0); + } +} diff --git a/src/NetEvolve.ForgingBlazor/Configurations/ConfigurationLoader.cs b/src/NetEvolve.ForgingBlazor/Configurations/ConfigurationLoader.cs new file mode 100644 index 0000000..dd59a17 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Configurations/ConfigurationLoader.cs @@ -0,0 +1,29 @@ +namespace NetEvolve.ForgingBlazor.Configurations; + +using System.Reflection; +using Microsoft.Extensions.Configuration; +using NetEvolve.ForgingBlazor.Extensibility; + +internal static class ConfigurationLoader +{ + public static IConfiguration Load(string? environment, string projectPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + + if (string.IsNullOrWhiteSpace(environment)) + { + environment = Defaults.Environment; + } + + var configuration = new ConfigurationBuilder() + .SetBasePath(Path.GetFullPath(projectPath)) + .AddJsonFile("forgingblazor.json", optional: true, reloadOnChange: true) + .AddYamlFile("forgingblazor.yaml", optional: true, reloadOnChange: true) + .AddYamlFile("forgingblazor.yml", optional: true, reloadOnChange: true) + .AddYamlFile($"forgingblazor.{environment}.yaml", optional: true, reloadOnChange: true) + .AddYamlFile($"forgingblazor.{environment}.yml", optional: true, reloadOnChange: true) + .AddJsonFile($"forgingblazor.{environment}.json", optional: true, reloadOnChange: true); + + return configuration.Build(); + } +} diff --git a/src/NetEvolve.ForgingBlazor/Configurations/SiteConfiguration.cs b/src/NetEvolve.ForgingBlazor/Configurations/SiteConfiguration.cs new file mode 100644 index 0000000..47c6a62 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Configurations/SiteConfiguration.cs @@ -0,0 +1,47 @@ +namespace NetEvolve.ForgingBlazor.Configurations; + +using System.ComponentModel; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using NetEvolve.ForgingBlazor.Extensibility; +using YamlDotNet.Serialization; + +/// +/// Provides configuration settings for the ForgingBlazor site. +/// +/// +/// This class holds the core configuration options for a ForgingBlazor application, +/// including paths and other site-wide settings. +/// +public class SiteConfiguration +{ + /// + /// Gets or sets the root path where content files are stored for the ForgingBlazor application. + /// + /// + /// A representing the file system path where markdown content and other + /// content files are located. If , a default path (typically 'Content') is used. + /// + /// + /// This path is relative to the application's working directory. It should point to a directory + /// containing subdirectories for different content segments (pages, blog posts, etc.). + /// + [DefaultValue(DefaultPaths.Content)] + [Description(ContentPathDescription)] + [JsonPropertyName("contentpath")] + [YamlMember(Alias = "contentpath", Description = ContentPathDescription)] + public string? ContentPath { get; set; } = DefaultPaths.Content; + private const string ContentPathDescription = """ + Defines the root path where content files are stored for the ForgingBlazor application. + """; +} + +internal sealed class SiteConfigurationConfigure : IConfigureOptions +{ + private readonly IConfiguration _configuration; + + public SiteConfigurationConfigure(IConfiguration configuration) => _configuration = configuration; + + public void Configure(SiteConfiguration options) => _configuration.Bind(options); +} diff --git a/src/NetEvolve.ForgingBlazor/Models/BlogPost.cs b/src/NetEvolve.ForgingBlazor/Models/BlogPost.cs new file mode 100644 index 0000000..7f6ac8e --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Models/BlogPost.cs @@ -0,0 +1,13 @@ +namespace NetEvolve.ForgingBlazor.Models; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Represents a concrete blog post implementation for the ForgingBlazor framework. +/// +/// +/// This sealed record provides the standard blog post type with all common blogging properties inherited from , +/// including slug, title, publication date, author, and tags. Use this type for typical blog post scenarios +/// where no additional custom properties are required. +/// +public sealed record BlogPost : BlogPostBase { } diff --git a/src/NetEvolve.ForgingBlazor/Models/Page.cs b/src/NetEvolve.ForgingBlazor/Models/Page.cs new file mode 100644 index 0000000..97ce860 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Models/Page.cs @@ -0,0 +1,14 @@ +namespace NetEvolve.ForgingBlazor.Models; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Represents a concrete page implementation for the ForgingBlazor framework. +/// +/// +/// This sealed record provides the standard page type with all fundamental page properties inherited from , +/// including slug, title, and optional link title. Use this type for general-purpose pages +/// where no additional custom properties or blog-specific metadata (such as publication date, author, or tags) are required. +/// For blog posts, use instead. +/// +public sealed record Page : PageBase { } diff --git a/src/NetEvolve.ForgingBlazor/NetEvolve.ForgingBlazor.csproj b/src/NetEvolve.ForgingBlazor/NetEvolve.ForgingBlazor.csproj new file mode 100644 index 0000000..24e8805 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/NetEvolve.ForgingBlazor.csproj @@ -0,0 +1,17 @@ + + + $(_ProjectTargetFrameworks) + + true + + + + + + + + + + + + diff --git a/src/NetEvolve.ForgingBlazor/Options/BlogContentOptions.cs b/src/NetEvolve.ForgingBlazor/Options/BlogContentOptions.cs new file mode 100644 index 0000000..9dbd957 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Options/BlogContentOptions.cs @@ -0,0 +1,54 @@ +namespace NetEvolve.ForgingBlazor.Options; + +using System.Text; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides configuration options for blog content segments in a ForgingBlazor application. +/// +/// +/// The blog post type that inherits from . +/// +/// +/// This sealed class extends with blog-specific configuration options, +/// including pagination settings that control how blog posts are displayed and navigated. +/// +/// +/// +public sealed class BlogContentOptions : ContentOptionsBase + where TBlogType : BlogPostBase +{ + /// + /// Gets or sets the number of pagination links to display on either side of the current page. + /// + /// + /// An value representing the pagination display count. The default value is 5. + /// + /// + /// For example, if set to 5 and the current page is 10, pagination links will display pages 5-15. + /// + public int PaginationDisplayCount { get; set; } = 5; + + /// + /// Gets or sets the format string template used to generate pagination URLs or display text. + /// + /// + /// A instance representing the pagination format template. + /// The default value is "{0}", which represents the page number. + /// + /// + /// + /// This format string is used with to generate pagination elements. + /// The placeholder {0} will be replaced with the page number. + /// + /// + /// Example formats: + /// + /// "{0}" - Simple page number + /// "page/{0}" - URL-style format + /// "Page {0}" - Display text with label + /// + /// + /// + public CompositeFormat PaginationFormat { get; set; } = CompositeFormat.Parse("{0}"); +} diff --git a/src/NetEvolve.ForgingBlazor/Options/ContentOptionsBase.cs b/src/NetEvolve.ForgingBlazor/Options/ContentOptionsBase.cs new file mode 100644 index 0000000..a169b6d --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Options/ContentOptionsBase.cs @@ -0,0 +1,52 @@ +namespace NetEvolve.ForgingBlazor.Options; + +using System.Text; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides base configuration options for content segments in a ForgingBlazor application. +/// +/// +/// The page type that inherits from . +/// +/// +/// +/// This abstract class serves as the foundation for content configuration options, +/// defining common properties shared by all content types including pages and blog posts. +/// +/// +/// Derived classes such as , , +/// and extend this base class with type-specific configuration options. +/// +/// +/// +/// +/// +/// +public abstract class ContentOptionsBase + where TPageType : PageBase +{ + /// + /// Gets the of the content being configured. + /// + /// + /// A instance representing . + /// + /// + /// This property is used internally to identify and process the specific page type + /// associated with these configuration options during content generation and validation. + /// + public Type ContentType { get; } = typeof(TPageType); + + /// + /// Gets or sets the character encoding used for reading and writing content files. + /// + /// + /// An instance representing the file encoding. The default value is . + /// + /// + /// UTF-8 encoding is used by default as it supports all Unicode characters and is widely compatible. + /// Change this property if content files use a different encoding (e.g., UTF-16, ASCII, or locale-specific encodings). + /// + public Encoding FileEncoding { get; set; } = Encoding.UTF8; +} diff --git a/src/NetEvolve.ForgingBlazor/Options/DefaultContentOptions.cs b/src/NetEvolve.ForgingBlazor/Options/DefaultContentOptions.cs new file mode 100644 index 0000000..a9f29bd --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Options/DefaultContentOptions.cs @@ -0,0 +1,23 @@ +namespace NetEvolve.ForgingBlazor.Options; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides configuration options for default content (pages) in a ForgingBlazor application. +/// +/// +/// The page type that inherits from . +/// +/// +/// +/// This sealed class represents the configuration for default content pages that don't belong to a specific segment or blog section. +/// It inherits all base configuration options from without adding additional properties. +/// +/// +/// Use this options class when configuring default pages through the AddDefaultContent extension method. +/// +/// +/// +/// +public sealed class DefaultContentOptions : ContentOptionsBase + where TPageType : PageBase; diff --git a/src/NetEvolve.ForgingBlazor/Options/SegmentContentOptions.cs b/src/NetEvolve.ForgingBlazor/Options/SegmentContentOptions.cs new file mode 100644 index 0000000..c550c51 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Options/SegmentContentOptions.cs @@ -0,0 +1,23 @@ +namespace NetEvolve.ForgingBlazor.Options; + +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides configuration options for content segments in a ForgingBlazor application. +/// +/// +/// The page type that inherits from . +/// +/// +/// +/// This sealed class represents the configuration for content pages that belong to a specific segment. +/// It inherits all base configuration options from without adding additional properties. +/// +/// +/// Use this options class when configuring content segments through the AddSegment extension method. +/// +/// +/// +/// +public sealed class SegmentContentOptions : ContentOptionsBase + where TPageType : PageBase; diff --git a/src/NetEvolve.ForgingBlazor/ServiceCollectionExtensions.cs b/src/NetEvolve.ForgingBlazor/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..cf5f683 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/ServiceCollectionExtensions.cs @@ -0,0 +1,149 @@ +namespace NetEvolve.ForgingBlazor; + +using System.CommandLine; +using Markdig; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Commands; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Services; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +/// +/// Provides extension methods for to register and configure ForgingBlazor services. +/// +internal static class ServiceCollectionExtensions +{ + /// + /// Registers the core ForgingBlazor services including CLI commands and content registration. + /// + /// + /// The to register services into. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// + /// This method registers all core ForgingBlazor services, including: + /// + /// The (implemented by ) + /// Sub-commands: , , , + /// The service (implemented by ) + /// + /// + /// + /// If services have already been registered (identified by the presence of the marker), + /// the method returns immediately without registering duplicate services. + /// + /// + /// Thrown when is . + internal static IServiceCollection AddForgingBlazorServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + if (!services.IsServiceTypeRegistered()) + { + _ = services + .AddSingleton() + // Register RootCommand + .AddSingleton() + // Register all Standard Commands + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + // Register core services + .AddSingleton(); + } + + return services; + } + + /// + /// Internal marker class used to track that core ForgingBlazor services have been registered. + /// + /// + /// + /// This sealed class implements to prevent duplicate service registrations + /// and identify startup-specific services during the filtering process. + /// + /// + /// The marker is registered as a singleton in the service collection to indicate that core services + /// have already been initialized. + /// + /// + private sealed class MarkerCoreServices : IStartUpMarker; + + /// + /// Registers markdown processing services including Markdig pipeline and YAML deserialization. + /// + /// + /// The to register services into. + /// Cannot be . + /// + /// + /// The same instance for method chaining. + /// + /// + /// + /// This method registers the following services: + /// + /// A configured with advanced extensions and YAML front matter support + /// An configured with camelCase naming convention and property ignoring + /// + /// + /// + /// If services have already been registered (identified by the presence of the marker), + /// the method returns immediately without registering duplicate services. + /// + /// + /// Thrown when is . + internal static IServiceCollection AddMarkdownServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + if (!services.IsServiceTypeRegistered()) + { + _ = services + .AddSingleton() + // Register Markdown services + .AddSingleton(new MarkdownPipelineBuilder().UseAdvancedExtensions().UseYamlFrontMatter().Build()) + .AddSingleton( + new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build() + ); + } + return services; + } + + private sealed class MarkerMarkdownServices : IStartUpMarker; + + /// + /// Determines whether a specific service type has been registered in the service collection. + /// + /// + /// The service type to check for registration. Cannot be . + /// + /// + /// The to check for service registration. + /// Cannot be . + /// + /// + /// if the service type is registered; otherwise, . + /// + /// + /// + /// This utility method checks whether a service type has already been registered by comparing + /// the property against the provided type parameter. + /// + /// + /// This method is commonly used to prevent duplicate service registrations and to conditionally register + /// services only if they have not been previously registered. + /// + /// + internal static bool IsServiceTypeRegistered(this IServiceCollection builder) + where T : class => builder.Any(x => x.ServiceType == typeof(T)); +} diff --git a/src/NetEvolve.ForgingBlazor/Services/ContentRegister.cs b/src/NetEvolve.ForgingBlazor/Services/ContentRegister.cs new file mode 100644 index 0000000..c289135 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Services/ContentRegister.cs @@ -0,0 +1,254 @@ +namespace NetEvolve.ForgingBlazor.Services; + +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides the default implementation of for the ForgingBlazor framework. +/// +/// +/// +/// This sealed partial class serves as the main content registration service for the ForgingBlazor dependency injection container. +/// It orchestrates the collection of content from multiple registered content segments and manages the registration of collected pages. +/// +/// +/// The class uses partial declarations to separate the main implementation logic from the logging method definitions +/// which are generated by the logging source generator. +/// +/// +/// +/// +/// +internal sealed partial class ContentRegister : IContentRegister +{ + /// + /// Stores the logger instance for logging content registration operations. + /// + /// + /// This field holds the logger used to record diagnostic messages about content collection and registration operations. + /// + private readonly ILogger _logger; + + /// + /// Stores the service provider for resolving content collectors and registrations. + /// + /// + /// This field holds the service provider used to retrieve content registrations and keyed content collectors + /// during the content collection phase. + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// Stores the collection of registered pages across all content segments. + /// + /// + /// + /// This concurrent collection is thread-safe and allows multiple content collectors + /// to register pages simultaneously without synchronization overhead. + /// + /// + /// Pages are added to this collection via the method + /// which is called by content collectors during the collection phase. + /// + /// + private readonly ConcurrentBag _registeredPages = []; + + /// + /// Initializes a new instance of the class with the specified dependencies. + /// + /// + /// The instance for logging registration operations. + /// Cannot be . + /// + /// + /// The for resolving content collectors and registrations. + /// Cannot be . + /// + public ContentRegister(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + /// + /// Asynchronously collects content from all registered content segments using their associated collectors. + /// + /// + /// A token that can be used to request cancellation of the collection operation. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous content collection operation. + /// + /// + /// + /// This method performs the following steps: + /// + /// Retrieves all instances from the service provider + /// Calls to set exclude paths for default registrations + /// Creates a concurrent dictionary to cache collectors by segment + /// Iterates through registrations ordered by priority (descending) + /// For each registration, retrieves the appropriate keyed collectors + /// Executes each collector to collect and register content asynchronously + /// Logs start/completion messages for each collection operation + /// + /// + /// + /// The method uses + /// to efficiently process multiple files in parallel during content collection. + /// + /// + public async ValueTask CollectAsync(CancellationToken cancellationToken) + { + var registrations = _serviceProvider.GetServices().ToArray(); + + var contentCollectors = new ConcurrentDictionary>( + StringComparer.Ordinal + ); + + foreach (var registration in UpdateRegistrations(registrations)) + { + var collectors = contentCollectors.GetOrAdd( + registration.Segment, + _serviceProvider.GetKeyedServices + ); + + foreach (var collector in collectors) + { + var collectorTypeFullName = GetCollectorName(collector); + LogStartingContentCollection(registration.Segment, collectorTypeFullName); + await collector.CollectAsync(this, registration, cancellationToken).ConfigureAwait(false); + LogCompletedContentCollection(registration.Segment, collectorTypeFullName); + } + } + } + + /// + /// Registers a page with the content register. + /// + /// + /// The page type that inherits from . + /// + /// + /// The page instance to register. Cannot be . + /// + /// + /// This method adds the page to the internal collection of registered pages. + /// It is thread-safe and can be called concurrently by multiple content collectors. + /// + public void Register(TPageType page) + where TPageType : PageBase => _registeredPages.Add(page); + + /// + /// Updates the content registrations by configuring exclude paths for the default registration. + /// + /// + /// The array of content registrations to update. + /// Cannot be . + /// + /// + /// An ordered array of content registrations, sorted by priority in descending order. + /// + /// + /// + /// This method identifies the default registration (implementing ) if present + /// and sets its exclude paths to include all segments from other registrations, preventing the default collector + /// from processing content that belongs to specific segments. + /// + /// + /// The returned array is ordered by priority () in descending order + /// to ensure content collectors are executed in the correct sequence, with highest priority collectors running first. + /// + /// + private static IContentRegistration[] UpdateRegistrations(IContentRegistration[] registrations) + { + var defaultRegistrations = + registrations.OfType().FirstOrDefault() + ?? throw new InvalidOperationException( + "No default registration found. At least one registration must implement IDefaultRegistration." + ); + var otherRegistrations = registrations.Except([defaultRegistrations!]).ToArray(); + + var excludePaths = otherRegistrations.Select(registration => registration.Segment).ToArray(); + defaultRegistrations.SetExcludePaths(excludePaths); + + return [.. registrations.OrderByDescending(r => r.Priority)]; + } + + private readonly ConcurrentDictionary _collectorNameCache = new(); + + private string GetCollectorName(IContentCollector collector) + { + var collectorType = collector.GetType(); + + return _collectorNameCache.GetOrAdd(collectorType, GetTypeName); + + static string GetTypeName(Type type) + { + if (type.IsGenericType) + { + return type.Name.Split('`')[0] + + "<" + + string.Join(", ", type.GetGenericArguments().Select(x => GetTypeName(x)).ToArray()) + + ">"; + } + + return type.Name; + } + } + + /// + /// Logs that content collection has started for a specific segment and collector. + /// + /// + /// The name or identifier of the content segment being processed (e.g., blog, docs). + /// + /// + /// The type name of the content collector being used. + /// + /// + /// + /// This is a logging method generated by the logging source generator via the + /// annotation. + /// + /// + /// It logs at level with EventId = 0. + /// + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "Starting content collection for segment '{Segment}' using collector '{CollectorType}'." + )] + private partial void LogStartingContentCollection(string segment, string collectorType); + + /// + /// Logs that content collection has completed for a specific segment and collector. + /// + /// + /// The name or identifier of the content segment that was processed (e.g., blog, docs). + /// + /// + /// The type name of the content collector that was used. + /// + /// + /// + /// This is a logging method generated by the logging source generator via the + /// annotation. + /// + /// + /// It logs at level with EventId = 1. + /// + /// + [LoggerMessage( + EventId = 1, + Level = LogLevel.Debug, + Message = "Completed content collection for segment '{Segment}' using collector '{CollectorType}'." + )] + private partial void LogCompletedContentCollection(string segment, string collectorType); +} diff --git a/src/NetEvolve.ForgingBlazor/Services/ContentRegistration.cs b/src/NetEvolve.ForgingBlazor/Services/ContentRegistration.cs new file mode 100644 index 0000000..2d89065 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Services/ContentRegistration.cs @@ -0,0 +1,66 @@ +namespace NetEvolve.ForgingBlazor.Services; + +using System; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides a generic implementation of for custom page types. +/// +/// The page type being registered, which must derive from . +/// +/// This class allows registration of custom page types with the ForgingBlazor framework. +/// It serves as a reusable registration container for any page type that inherits from . +/// +/// +/// +internal sealed class ContentRegistration : IContentRegistration + where TPageType : PageBase +{ + /// + /// Gets the type of the page registered with the ForgingBlazor framework. + /// + /// + /// A instance representing . + /// + public Type PageType { get; } = typeof(TPageType); + + /// + /// Gets or sets the URL segment identifier for this content registration. + /// + /// + /// A representing the URL path segment where content is located. + /// Default value is an empty string. + /// + /// + /// The segment defines the subdirectory within the content path where files for this + /// page type are located. For example, a segment value of "blog" would look for content + /// in the "Content/blog" directory. + /// + public string Segment { get; set; } = ""; + + /// + /// Gets or sets the list of paths to exclude from content collection for this registration. + /// + /// + /// A read-only list of relative paths to exclude, or if no exclusions are defined. + /// + /// + /// This property allows fine-grained control over which subdirectories within the segment + /// should be skipped during content collection. + /// + public IReadOnlyList? ExcludePaths { get; set; } + + /// + /// Gets or sets the priority of this content registration in the collection pipeline. + /// + /// + /// An integer value where higher numbers indicate higher priority. + /// Default value is 0. + /// + /// + /// Content registrations are processed in descending priority order. Higher priority registrations + /// are executed before lower priority ones, allowing specific content types to be collected first. + /// + public int Priority { get; set; } +} diff --git a/src/NetEvolve.ForgingBlazor/Services/DefaultContentRegistration.cs b/src/NetEvolve.ForgingBlazor/Services/DefaultContentRegistration.cs new file mode 100644 index 0000000..05da8c2 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Services/DefaultContentRegistration.cs @@ -0,0 +1,78 @@ +namespace NetEvolve.ForgingBlazor.Services; + +using System; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +/// +/// Provides the default implementation of for registering the default page type. +/// +/// The default page type being registered, which must derive from . +/// +/// This class is used to register the default page type when the application is configured with default pages. +/// It differs from only semantically to identify it as the default registration. +/// +/// +/// +internal sealed class DefaultContentRegistration : IDefaultRegistration + where TPageType : PageBase +{ + /// + /// Gets the type of the default page registered with the ForgingBlazor framework. + /// + /// + /// A instance representing . + /// + public Type PageType { get; } = typeof(TPageType); + + /// + /// Gets the URL segment identifier for the default content. + /// + /// + /// An empty representing that this is the default (root) content registration. + /// + /// + /// Default registrations use an empty string as the segment identifier to indicate they handle + /// content that doesn't belong to any specific segment. + /// + public string Segment { get; } = ""; + + /// + /// Gets the list of paths to exclude from default content collection. + /// + /// + /// A read-only list of segment paths that should be excluded from this default registration. + /// + /// + /// This property is set by the method and contains the segments + /// of all other registered content types, preventing the default collector from processing + /// content that belongs to specific segments. + /// + public IReadOnlyList? ExcludePaths { get; internal set; } + + /// + /// Gets the priority of this content registration in the collection pipeline. + /// + /// + /// Returns -1, indicating this is the lowest priority registration and should be executed last. + /// + /// + /// Default registrations have the lowest priority to ensure they only collect content that wasn't + /// handled by more specific segment registrations. + /// + public int Priority => -1; + + /// + /// Sets the paths to exclude from default content collection. + /// + /// + /// The enumerable collection of segment paths to exclude. + /// Cannot be . + /// + /// + /// This method is called during content registration initialization to configure which paths + /// should be skipped by the default content collector. Typically, this includes all paths + /// that have specific segment registrations. + /// + public void SetExcludePaths(IEnumerable excludePaths) => ExcludePaths = [.. excludePaths]; +} diff --git a/src/NetEvolve.ForgingBlazor/Services/MarkdownContentCollector.cs b/src/NetEvolve.ForgingBlazor/Services/MarkdownContentCollector.cs new file mode 100644 index 0000000..46b3ba7 --- /dev/null +++ b/src/NetEvolve.ForgingBlazor/Services/MarkdownContentCollector.cs @@ -0,0 +1,348 @@ +namespace NetEvolve.ForgingBlazor.Services; + +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Markdig; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NetEvolve.ForgingBlazor.Configurations; +using NetEvolve.ForgingBlazor.Extensibility; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; +using YamlDotNet.Serialization; + +/// +/// Provides the default implementation of for collecting markdown-based content in ForgingBlazor applications. +/// +/// +/// The page type that inherits from . +/// +/// +/// +/// This sealed partial class implements content collection from markdown files with YAML front matter. +/// It parses markdown files, extracts front matter metadata, and registers the processed content with the content register service. +/// +/// +/// The class uses Markdig for markdown parsing and YamlDotNet for YAML deserialization. +/// All markdown files (*.md) in the specified segment directory are processed asynchronously. +/// +/// +/// +/// +internal sealed partial class MarkdownContentCollector : IContentCollector + where TPageType : PageBase +{ + /// + /// Stores the logger instance for logging collection operations. + /// + /// + /// This field holds the instance used for recording diagnostic messages + /// during markdown content collection. It is automatically populated by the logging source generator framework + /// to enable structured logging through the methods. + /// + private readonly ILogger> _logger; + + /// + /// Stores the options monitor for accessing current site configuration settings. + /// + /// + /// This field holds the instance that provides access to the current + /// site configuration. The options monitor allows the content collector to access configuration settings + /// needed during the markdown content collection process. + /// + private readonly IOptionsMonitor _optionsMonitorSiteConfiguration; + + /// + /// Stores the markdown pipeline instance configured for markdown parsing with extensions including YAML front matter support. + /// + /// + /// This field holds the that was configured with advanced extensions and YAML front matter support. + /// The pipeline is used during markdown file processing to parse content and extract front matter. + /// + private readonly MarkdownPipeline _pipeline; + + /// + /// Stores the YAML deserializer instance for converting YAML front matter into strongly-typed page objects. + /// + /// + /// This field holds the that is configured to deserialize YAML content + /// into page instances of type . + /// + private readonly IDeserializer _deserializer; + + /// + /// Gets the priority of this content collector in the collection pipeline. + /// + /// + /// Returns , indicating this collector has the highest priority + /// and should be executed first among registered content collectors. + /// + public int Priority => int.MaxValue; + + /// + /// Initializes a new instance of the class with the specified dependencies. + /// + /// + /// The instance for logging collection operations. + /// Cannot be . + /// + /// + /// The for accessing current site configuration settings. + /// Cannot be . + /// + /// + /// The instance configured for markdown parsing with extensions. + /// Cannot be . + /// + /// + /// The instance for deserializing YAML front matter into page objects. + /// Cannot be . + /// + /// + /// This constructor stores references to the provided dependencies for use during content collection. + /// The pipeline and deserializer are used to process markdown files with YAML front matter. + /// + public MarkdownContentCollector( + ILogger> logger, + IOptionsMonitor optionsMonitorSiteConfiguration, + MarkdownPipeline pipeline, + IDeserializer deserializer + ) + { + _logger = logger; + _optionsMonitorSiteConfiguration = optionsMonitorSiteConfiguration; + _pipeline = pipeline; + _deserializer = deserializer; + } + + /// + /// Asynchronously collects markdown content from the segment directory and registers it with the content register. + /// + /// + /// The instance to register collected content with. + /// Cannot be . + /// + /// + /// The instance containing configuration for the content segment. + /// Cannot be . + /// + /// + /// A token that can be used to request cancellation of the collection operation. + /// Defaults to if not specified. + /// + /// + /// A that represents the asynchronous content collection operation. + /// + /// + /// + /// This method performs the following steps: + /// + /// Retrieves the content directory path from the site configuration + /// Matches all markdown files (*.md) in the segment directory + /// Processes each markdown file asynchronously to extract YAML front matter and content + /// Registers processed pages with the content register + /// + /// + /// + /// Markdown files must contain YAML front matter at the beginning (delimited by ---) with properties + /// that match the specified . The remaining content after the front matter + /// is stored as the page content. + /// + /// + /// Files named _index.md are treated as index pages, and an _index.md file in the root segment directory + /// is treated as the home page. + /// + /// + /// Thrown when or is . + /// Thrown when the segment directory does not exist. + public async ValueTask CollectAsync( + IContentRegister contentRegister, + IContentRegistration registration, + CancellationToken cancellationToken + ) + { + var siteConfiguration = _optionsMonitorSiteConfiguration.CurrentValue; + + var contentDirectory = new DirectoryInfo(siteConfiguration.ContentPath ?? DefaultPaths.Content); + var contentPath = Path.Combine(contentDirectory.FullName, registration.Segment); + var segmentDirectory = new DirectoryInfo(contentPath); + + if (!segmentDirectory.Exists) + { + throw new DirectoryNotFoundException( + $"The content directory '{segmentDirectory.FullName}' does not exist." + ); + } + + var matcher = new Matcher().AddInclude("**/*.md"); + + if (registration.ExcludePaths?.Any() == true) + { + foreach (var excludePath in registration.ExcludePaths) + { + _ = matcher.AddExclude($"{excludePath}/**"); + } + } + + var result = matcher.Execute(new DirectoryInfoWrapper(segmentDirectory)); + + if (!result.HasMatches) + { + LogNoItemsFound(registration.Segment); + return; + } + + LogCollectedItems(registration.Segment, result.Files.Count()); + + await Parallel + .ForEachAsync( + result.Files.Select(file => new FileInfo(Path.Combine(segmentDirectory.FullName, file.Path))), + cancellationToken, + async (fileInfo, token) => + { + ArgumentNullException.ThrowIfNull(fileInfo); + + try + { + // MARTIN: Get Encoding from Settings + var rawContent = await File.ReadAllTextAsync(fileInfo.FullName, Encoding.UTF8, token) + .ConfigureAwait(false); + var markdownDocument = Markdown.Parse(rawContent, _pipeline); + + var frontMatterBlock = markdownDocument.Descendants().FirstOrDefault(); + if (frontMatterBlock is null) + { + LogFileMissingFrontMatter(fileInfo.FullName); + return; + } + + var frontMatterLines = frontMatterBlock.Lines.ToString(); + var pageContent = _deserializer.Deserialize(frontMatterLines); + + var frontMatterEnd = rawContent.IndexOf( + "---", + frontMatterBlock.Span.End - 4, + StringComparison.Ordinal + ); + + pageContent.Content = rawContent[(frontMatterEnd + 3)..].TrimStart(); + + var isIndexPage = fileInfo.Name.Equals("_index.md", StringComparison.OrdinalIgnoreCase); + pageContent.IsIndexPage = isIndexPage; + pageContent.IsHomePage = + isIndexPage + && fileInfo.Directory!.FullName.Equals( + segmentDirectory.FullName, + StringComparison.OrdinalIgnoreCase + ); + + pageContent.RelativeLink = Path.GetRelativePath( + contentDirectory.FullName, + fileInfo.DirectoryName! + ); + + contentRegister.Register(pageContent); + } + catch (Exception ex) + { + LogErrorProcessingFile(fileInfo.FullName, ex); + } + } + ) + .ConfigureAwait(false); + } + + /// + /// Logs that a specific number of items have been collected from a segment. + /// + /// + /// The name or identifier of the content segment being collected (e.g., blog, docs). + /// + /// + /// The number of items (markdown files) collected from the segment. + /// + /// + /// + /// This is a logging method generated by the logging source generator via the + /// annotation. + /// + /// + /// It logs at level with EventId = 0. + /// + /// + [LoggerMessage(EventId = 0, Level = LogLevel.Debug, Message = "Collected {Count} items from segment '{Segment}'.")] + private partial void LogCollectedItems(string segment, int count); + + /// + /// Logs that no items were found in a segment. + /// + /// + /// The name or identifier of the content segment that was empty (e.g., blog, docs). + /// + /// + /// + /// This is a logging method generated by the logging source generator via the + /// annotation. + /// + /// + /// It logs at level with EventId = 1. + /// + /// + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "No items found in segment '{Segment}'.")] + private partial void LogNoItemsFound(string segment); + + /// + /// Logs that a file does not contain valid YAML front matter and will be skipped. + /// + /// + /// The full file system path to the file that is missing valid front matter. + /// + /// + /// + /// This is a logging method generated by the logging source generator via the + /// annotation. + /// + /// + /// It logs at level with EventId = 2. + /// + /// + [LoggerMessage( + EventId = 2, + Level = LogLevel.Warning, + Message = "File '{FilePath}' does not contain valid YAML front matter and will be skipped." + )] + private partial void LogFileMissingFrontMatter(string filePath); + + /// + /// Logs that an error occurred while processing a specific file. + /// + /// + /// The full file system path to the file that encountered an error. + /// + /// + /// The that occurred during file processing. + /// + /// + /// + /// This is a logging method generated by the logging source generator via the + /// annotation. + /// + /// + /// It logs at level with EventId = 3 and includes exception details. + /// + /// + [LoggerMessage( + EventId = 3, + Level = LogLevel.Error, + Message = "An error occurred while processing the file '{FilePath}'." + )] + private partial void LogErrorProcessingFile(string filePath, Exception ex); +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Console/NetEvolve.ForgingBlazor.Tests.Console.csproj b/tests/NetEvolve.ForgingBlazor.Tests.Console/NetEvolve.ForgingBlazor.Tests.Console.csproj new file mode 100644 index 0000000..b3976b7 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Console/NetEvolve.ForgingBlazor.Tests.Console.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + false + true + + + + + + + + + + + diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Console/Program.cs b/tests/NetEvolve.ForgingBlazor.Tests.Console/Program.cs new file mode 100644 index 0000000..00320fa --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Console/Program.cs @@ -0,0 +1,19 @@ +using NetEvolve.ForgingBlazor; +using NetEvolve.ForgingBlazor.Logging; + +var arguments = args; + +if (arguments.Length == 0) +{ + arguments = ["build", "--log-level", "trace"]; +} + +var builder = ApplicationBuilder.CreateDefaultBuilder(arguments).WithLogging(); + +_ = builder.AddBlogSegment("posts"); + +var app = builder.Build(); + +var exitCode = await app.RunAsync().ConfigureAwait(false); + +Environment.ExitCode = exitCode; diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/AssemblyAttributes.cs b/tests/NetEvolve.ForgingBlazor.Tests.Integration/AssemblyAttributes.cs new file mode 100644 index 0000000..2250882 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using TUnit.Core.Executors; + +[assembly: Culture("en-US")] diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/Commands/CommandBuildTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Commands/CommandBuildTests.cs new file mode 100644 index 0000000..f268cc4 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Commands/CommandBuildTests.cs @@ -0,0 +1,24 @@ +namespace NetEvolve.ForgingBlazor.Tests.Integration.Commands; + +public sealed class CommandBuildTests +{ + [Test] + [MethodDataSource(nameof(GetBuildArguments))] + public async ValueTask Build_DefaultArguments_GeneratesStaticContent(string[] args) + { + using var directory = new TempDirectory(); + + if (args is { Length: > 0 }) + { + args = [.. args, directory.Path, "--content-path", "_setup/content"]; + } + else + { + args = ["build", "--content-path", "_setup/content"]; + } + + await Helper.VerifyStaticContent(directory.Path, args).ConfigureAwait(false); + } + + public static IEnumerable> GetBuildArguments => [() => [], () => ["build", "--output-path"]]; +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/Commands/CommandHelpTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Commands/CommandHelpTests.cs new file mode 100644 index 0000000..06d0dd4 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Commands/CommandHelpTests.cs @@ -0,0 +1,46 @@ +namespace NetEvolve.ForgingBlazor.Tests.Integration.Commands; + +using System.Threading.Tasks; +using NetEvolve.ForgingBlazor; + +public class CommandHelpTests +{ + [Test] + [MethodDataSource(nameof(GetHelpArguments))] + public async ValueTask Help_Theory_Expected(string[] args) + { + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var app = builder.Build(); + await using var output = new StringWriter(); + await using var error = new StringWriter(); + + if (app is Application application) + { + application.InvocationConfiguration = new() { Error = error, Output = output }; + } + + _ = await app.RunAsync().ConfigureAwait(false); + + _ = await Verify( + new + { + args, + error, + output, + } + ) + .DontIgnoreEmptyCollections() + .HashParameters(); + } + + public static IEnumerable> GetHelpArguments => + [ + () => [], + () => ["-h"], + () => ["create", "--help"], + () => ["build", "--help"], + () => ["example", "--help"], + () => ["serve", "--help"], + ]; +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/Configuration/ConfigurationLoaderTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Configuration/ConfigurationLoaderTests.cs new file mode 100644 index 0000000..94f7f2a --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Configuration/ConfigurationLoaderTests.cs @@ -0,0 +1,36 @@ +namespace NetEvolve.ForgingBlazor.Tests.Integration.Configuration; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetEvolve.ForgingBlazor.Configurations; + +public sealed class ConfigurationLoaderTests +{ + [Test] + [MethodDataSource(nameof(GetConfigurationData))] + public async ValueTask Load_ConfigurationFile_Expected(string projectPath, string? environment) + { + var services = new ServiceCollection() + .AddSingleton(_ => ConfigurationLoader.Load(environment, projectPath)) + .ConfigureOptions(); + var serviceProvider = services.BuildServiceProvider(); + + var siteConfiguration = serviceProvider.GetService>(); + + _ = await Verify(new { siteConfiguration = siteConfiguration?.Value, environment }) + .DontIgnoreEmptyCollections() + .UseParameters(projectPath, environment) + .HashParameters(); + } + + internal static IEnumerable<(string, string?)> GetConfigurationData => + [ + ("_setup", null), + ("_setup/JsonOnly", "Development"), + ("_setup/JsonOnly", ""), + ("_setup/Mixed", "Development"), + ("_setup/Mixed", " "), + ("_setup/YamlOnly", "Development"), + ("_setup/YmlOnly", "Development"), + ]; +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/Helper.cs b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Helper.cs new file mode 100644 index 0000000..a7bfc2a --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Helper.cs @@ -0,0 +1,24 @@ +namespace NetEvolve.ForgingBlazor.Tests.Integration; + +using Microsoft.Extensions.Logging; +using NetEvolve.ForgingBlazor.Logging; + +internal static class Helper +{ + public static async ValueTask VerifyStaticContent(string directoryPath, string[] args) + { + var builder = ApplicationBuilder + .CreateDefaultBuilder(args) + .WithLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Debug)); + + var app = builder.Build(); + + var exitCode = await app.RunAsync().ConfigureAwait(false); + + using (Assert.Multiple()) + { + _ = await Assert.That(exitCode).IsEqualTo(0); + _ = await VerifyDirectory(directoryPath).HashParameters(); + } + } +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/NetEvolve.ForgingBlazor.Tests.Integration.csproj b/tests/NetEvolve.ForgingBlazor.Tests.Integration/NetEvolve.ForgingBlazor.Tests.Integration.csproj new file mode 100644 index 0000000..92c8de2 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/NetEvolve.ForgingBlazor.Tests.Integration.csproj @@ -0,0 +1,35 @@ + + + $(_TestTargetFrameworks) + Exe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/Predefined.cs b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Predefined.cs new file mode 100644 index 0000000..a08e14d --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/Predefined.cs @@ -0,0 +1,26 @@ +namespace NetEvolve.ForgingBlazor.Tests.Integration; + +using System.IO; +using System.Runtime.CompilerServices; +using VerifyTests; + +internal static class Predefined +{ + [ModuleInitializer] + public static void Init() + { + DerivePathInfo( + (sourceFile, projectDirectory, method, type) => + { + var snapshots = Path.Combine(projectDirectory, "_snapshots", Namer.TargetFrameworkNameAndVersion); + _ = Directory.CreateDirectory(snapshots); + return new(snapshots, type.Name, method.Name); + } + ); + + VerifierSettings.SortJsonObjects(); + VerifierSettings.SortPropertiesAlphabetically(); + + VerifierSettings.AutoVerify(includeBuildServer: false, throwException: true); + } +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/JsonOnly/forgingblazor.Development.json b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/JsonOnly/forgingblazor.Development.json new file mode 100644 index 0000000..30bbed8 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/JsonOnly/forgingblazor.Development.json @@ -0,0 +1,3 @@ +{ + "contentpath": "jsononly-development" +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/JsonOnly/forgingblazor.json b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/JsonOnly/forgingblazor.json new file mode 100644 index 0000000..33d974a --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/JsonOnly/forgingblazor.json @@ -0,0 +1,3 @@ +{ + "contentpath": "jsononly" +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.json b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.json new file mode 100644 index 0000000..9aceff1 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.json @@ -0,0 +1,3 @@ +{ + "contentpath": "mixed-json" +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.yaml b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.yaml new file mode 100644 index 0000000..46834d5 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.yaml @@ -0,0 +1 @@ +contentpath: mixed-yaml diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.yml b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.yml new file mode 100644 index 0000000..2484126 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/Mixed/forgingblazor.yml @@ -0,0 +1 @@ +contentpath: mixed-yml diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YamlOnly/forgingblazor.Development.yaml b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YamlOnly/forgingblazor.Development.yaml new file mode 100644 index 0000000..7b8d600 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YamlOnly/forgingblazor.Development.yaml @@ -0,0 +1 @@ +contentpath: yamlonly-development diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YamlOnly/forgingblazor.yaml b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YamlOnly/forgingblazor.yaml new file mode 100644 index 0000000..d74d66d --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YamlOnly/forgingblazor.yaml @@ -0,0 +1 @@ +contentpath: yamlonly diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YmlOnly/forgingblazor.Development.yml b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YmlOnly/forgingblazor.Development.yml new file mode 100644 index 0000000..b71fcba --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YmlOnly/forgingblazor.Development.yml @@ -0,0 +1 @@ +contentpath: ymlonly-development diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YmlOnly/forgingblazor.yml b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YmlOnly/forgingblazor.yml new file mode 100644 index 0000000..916fab7 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/YmlOnly/forgingblazor.yml @@ -0,0 +1 @@ +contentpath: ymlonly diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/content/.gitstay b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_setup/content/.gitstay new file mode 100644 index 0000000..e69de29 diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_25a03f3b48369ce7.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_25a03f3b48369ce7.verified.txt new file mode 100644 index 0000000..5872d49 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_25a03f3b48369ce7.verified.txt @@ -0,0 +1,21 @@ +{ + args: [ + create, + --help + ], + error: , + output: +Description: + Creates a new page for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration create [options] + +Options: + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_3cb5690aa64d0539.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_3cb5690aa64d0539.verified.txt new file mode 100644 index 0000000..10250ee --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_3cb5690aa64d0539.verified.txt @@ -0,0 +1,25 @@ +{ + args: [ + build, + --help + ], + error: , + output: +Description: + Builds and generates static content for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration build [options] + +Options: + --content-path Specifies the absolute or relative path to the content directory [default: content] + -e, --environment Specifies the execution environment (e.g., Development, Staging, Production) [default: Production] + --include-drafts Includes draft pages in the build output + --include-future Includes pages with future publication dates in the build output + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_7482efceccfc847a.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_7482efceccfc847a.verified.txt new file mode 100644 index 0000000..4c26efa --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_7482efceccfc847a.verified.txt @@ -0,0 +1,24 @@ +{ + args: [ + serve, + --help + ], + error: , + output: +Description: + Starts a development server for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration serve [options] + +Options: + -e, --environment Specifies the execution environment (e.g., Development, Staging, Production) [default: Production] + --include-drafts Includes draft pages in the build output + --include-future Includes pages with future publication dates in the build output + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_a46afb280312df84.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_a46afb280312df84.verified.txt new file mode 100644 index 0000000..a279962 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_a46afb280312df84.verified.txt @@ -0,0 +1,26 @@ +{ + args: [], + error: +Required command was not provided. + +, + output: +Description: + Command-line interface for managing Forging Blazor applications. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration [command] [options] + +Options: + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + -?, -h, --help Show help and usage information + --version Show version information + +Commands: + build Builds and generates static content for a Forging Blazor application. + create Creates a new page for a Forging Blazor application. + example Creates a folder structure with example pages for a Forging Blazor application. + serve Starts a development server for a Forging Blazor application. + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_cf823c7fea89c954.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_cf823c7fea89c954.verified.txt new file mode 100644 index 0000000..d9ae882 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_cf823c7fea89c954.verified.txt @@ -0,0 +1,25 @@ +{ + args: [ + -h + ], + error: , + output: +Description: + Command-line interface for managing Forging Blazor applications. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration [command] [options] + +Options: + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + -?, -h, --help Show help and usage information + --version Show version information + +Commands: + build Builds and generates static content for a Forging Blazor application. + create Creates a new page for a Forging Blazor application. + example Creates a folder structure with example pages for a Forging Blazor application. + serve Starts a development server for a Forging Blazor application. + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_d2a59b5932a503ab.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_d2a59b5932a503ab.verified.txt new file mode 100644 index 0000000..3a2b11b --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Help_Theory_Expected.CommandHelpTests_d2a59b5932a503ab.verified.txt @@ -0,0 +1,21 @@ +{ + args: [ + example, + --help + ], + error: , + output: +Description: + Creates a folder structure with example pages for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration example [options] + +Options: + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_08ffc25126b8290c.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_08ffc25126b8290c.verified.txt new file mode 100644 index 0000000..bab422c --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_08ffc25126b8290c.verified.txt @@ -0,0 +1,6 @@ +{ + environment: , + siteConfiguration: { + ContentPath: mixed-yml + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_1e66c80510432a1f.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_1e66c80510432a1f.verified.txt new file mode 100644 index 0000000..b111306 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_1e66c80510432a1f.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: ymlonly-development + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_30eead1d144b9b04.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_30eead1d144b9b04.verified.txt new file mode 100644 index 0000000..1d0dde5 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_30eead1d144b9b04.verified.txt @@ -0,0 +1,6 @@ +{ + environment: , + siteConfiguration: { + ContentPath: mixed-yml + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_39c5d4b00f3d95fe.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_39c5d4b00f3d95fe.verified.txt new file mode 100644 index 0000000..39d32a1 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_39c5d4b00f3d95fe.verified.txt @@ -0,0 +1,6 @@ +{ + environment: , + siteConfiguration: { + ContentPath: jsononly + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_50c4e65b4ced94ed.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_50c4e65b4ced94ed.verified.txt new file mode 100644 index 0000000..81ae535 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_50c4e65b4ced94ed.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: yamlonly-development + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_66e3813db9b23dd3.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_66e3813db9b23dd3.verified.txt new file mode 100644 index 0000000..e49bafa --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_66e3813db9b23dd3.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: mixed-yml + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_79406228a905b7de.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_79406228a905b7de.verified.txt new file mode 100644 index 0000000..0cef415 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_79406228a905b7de.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: jsononly-development + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_dea15dee9c681199.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_dea15dee9c681199.verified.txt new file mode 100644 index 0000000..ca0fdd0 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet10_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_dea15dee9c681199.verified.txt @@ -0,0 +1,3 @@ +{ + siteConfiguration: {} +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_25a03f3b48369ce7.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_25a03f3b48369ce7.verified.txt new file mode 100644 index 0000000..5872d49 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_25a03f3b48369ce7.verified.txt @@ -0,0 +1,21 @@ +{ + args: [ + create, + --help + ], + error: , + output: +Description: + Creates a new page for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration create [options] + +Options: + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_3cb5690aa64d0539.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_3cb5690aa64d0539.verified.txt new file mode 100644 index 0000000..10250ee --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_3cb5690aa64d0539.verified.txt @@ -0,0 +1,25 @@ +{ + args: [ + build, + --help + ], + error: , + output: +Description: + Builds and generates static content for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration build [options] + +Options: + --content-path Specifies the absolute or relative path to the content directory [default: content] + -e, --environment Specifies the execution environment (e.g., Development, Staging, Production) [default: Production] + --include-drafts Includes draft pages in the build output + --include-future Includes pages with future publication dates in the build output + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_7482efceccfc847a.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_7482efceccfc847a.verified.txt new file mode 100644 index 0000000..4c26efa --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_7482efceccfc847a.verified.txt @@ -0,0 +1,24 @@ +{ + args: [ + serve, + --help + ], + error: , + output: +Description: + Starts a development server for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration serve [options] + +Options: + -e, --environment Specifies the execution environment (e.g., Development, Staging, Production) [default: Production] + --include-drafts Includes draft pages in the build output + --include-future Includes pages with future publication dates in the build output + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_a46afb280312df84.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_a46afb280312df84.verified.txt new file mode 100644 index 0000000..a279962 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_a46afb280312df84.verified.txt @@ -0,0 +1,26 @@ +{ + args: [], + error: +Required command was not provided. + +, + output: +Description: + Command-line interface for managing Forging Blazor applications. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration [command] [options] + +Options: + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + -?, -h, --help Show help and usage information + --version Show version information + +Commands: + build Builds and generates static content for a Forging Blazor application. + create Creates a new page for a Forging Blazor application. + example Creates a folder structure with example pages for a Forging Blazor application. + serve Starts a development server for a Forging Blazor application. + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_cf823c7fea89c954.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_cf823c7fea89c954.verified.txt new file mode 100644 index 0000000..d9ae882 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_cf823c7fea89c954.verified.txt @@ -0,0 +1,25 @@ +{ + args: [ + -h + ], + error: , + output: +Description: + Command-line interface for managing Forging Blazor applications. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration [command] [options] + +Options: + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + -?, -h, --help Show help and usage information + --version Show version information + +Commands: + build Builds and generates static content for a Forging Blazor application. + create Creates a new page for a Forging Blazor application. + example Creates a folder structure with example pages for a Forging Blazor application. + serve Starts a development server for a Forging Blazor application. + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_d2a59b5932a503ab.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_d2a59b5932a503ab.verified.txt new file mode 100644 index 0000000..3a2b11b --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Help_Theory_Expected.CommandHelpTests_d2a59b5932a503ab.verified.txt @@ -0,0 +1,21 @@ +{ + args: [ + example, + --help + ], + error: , + output: +Description: + Creates a folder structure with example pages for a Forging Blazor application. + +Usage: + NetEvolve.ForgingBlazor.Tests.Integration example [options] + +Options: + --project-path Specifies the path to the ForgingBlazor project to process [default: {CurrentDirectory}] + --output-path Specifies the absolute or relative path for the build output + -?, -h, --help Show help and usage information + -l, --log-level Specifies the minimum logging level (Trace, Debug, Information, Warning, Error, Critical, None) [default: Information] + + +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_08ffc25126b8290c.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_08ffc25126b8290c.verified.txt new file mode 100644 index 0000000..bab422c --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_08ffc25126b8290c.verified.txt @@ -0,0 +1,6 @@ +{ + environment: , + siteConfiguration: { + ContentPath: mixed-yml + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_1e66c80510432a1f.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_1e66c80510432a1f.verified.txt new file mode 100644 index 0000000..b111306 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_1e66c80510432a1f.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: ymlonly-development + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_30eead1d144b9b04.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_30eead1d144b9b04.verified.txt new file mode 100644 index 0000000..1d0dde5 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_30eead1d144b9b04.verified.txt @@ -0,0 +1,6 @@ +{ + environment: , + siteConfiguration: { + ContentPath: mixed-yml + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_39c5d4b00f3d95fe.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_39c5d4b00f3d95fe.verified.txt new file mode 100644 index 0000000..39d32a1 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_39c5d4b00f3d95fe.verified.txt @@ -0,0 +1,6 @@ +{ + environment: , + siteConfiguration: { + ContentPath: jsononly + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_50c4e65b4ced94ed.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_50c4e65b4ced94ed.verified.txt new file mode 100644 index 0000000..81ae535 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_50c4e65b4ced94ed.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: yamlonly-development + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_66e3813db9b23dd3.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_66e3813db9b23dd3.verified.txt new file mode 100644 index 0000000..e49bafa --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_66e3813db9b23dd3.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: mixed-yml + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_79406228a905b7de.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_79406228a905b7de.verified.txt new file mode 100644 index 0000000..0cef415 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_79406228a905b7de.verified.txt @@ -0,0 +1,6 @@ +{ + environment: Development, + siteConfiguration: { + ContentPath: jsononly-development + } +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_dea15dee9c681199.verified.txt b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_dea15dee9c681199.verified.txt new file mode 100644 index 0000000..ca0fdd0 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Integration/_snapshots/DotNet9_0/Load_ConfigurationFile_Expected.ConfigurationLoaderTests_dea15dee9c681199.verified.txt @@ -0,0 +1,3 @@ +{ + siteConfiguration: {} +} \ No newline at end of file diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Unit/ApplicationBuilderExtensionsTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Unit/ApplicationBuilderExtensionsTests.cs new file mode 100644 index 0000000..7fb403b --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Unit/ApplicationBuilderExtensionsTests.cs @@ -0,0 +1,266 @@ +namespace NetEvolve.ForgingBlazor.Tests.Unit; + +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +public sealed class ApplicationBuilderExtensionsTests +{ + [Test] + public void AddDefaultContent_WithNullBuilder_ThrowsArgumentNullException() + { + IApplicationBuilder builder = null!; + + _ = Assert.Throws(() => builder.AddDefaultContent()); + } + + [Test] + public async Task AddDefaultContent_RegistersDefaultContentServices() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + var result = builder.AddDefaultContent(); + + _ = await Assert.That(result).IsNotNull(); + _ = await Assert.That(builder.Services.Any(x => x.ServiceType == typeof(IContentRegistration))).IsTrue(); + } + + [Test] + public async Task AddDefaultContent_RegistersForgingBlazorServices() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = builder.AddDefaultContent(); + + var hasContentRegister = builder.Services.Any(x => x.ServiceType == typeof(IContentRegister)); + _ = await Assert.That(hasContentRegister).IsTrue(); + } + + [Test] + public async Task AddDefaultContent_RegistersMarkdownServices() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = builder.AddDefaultContent(); + + var hasMarkdownPipeline = builder.Services.Any(x => x.ServiceType == typeof(Markdig.MarkdownPipeline)); + _ = await Assert.That(hasMarkdownPipeline).IsTrue(); + } + + [Test] + public void AddDefaultContent_CalledTwice_ThrowsInvalidOperationException() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = builder.AddDefaultContent(); + _ = Assert.Throws(() => builder.AddDefaultContent()); + } + + [Test] + public void AddDefaultContentGeneric_WithNullBuilder_ThrowsArgumentNullException() + { + IApplicationBuilder builder = null!; + + _ = Assert.Throws(() => builder.AddDefaultContent()); + } + + [Test] + public async Task AddDefaultContentGeneric_RegistersDefaultContentWithCustomPageType() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + var result = builder.AddDefaultContent(); + + _ = await Assert.That(result).IsNotNull(); + _ = await Assert.That(builder.Services.Any(x => x.ServiceType == typeof(IContentRegistration))).IsTrue(); + } + + [Test] + public void AddDefaultContentGeneric_CalledTwice_ThrowsInvalidOperationException() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = builder.AddDefaultContent(); + _ = Assert.Throws(() => builder.AddDefaultContent()); + } + + [Test] + public void AddSegment_WithNullBuilder_ThrowsArgumentNullException() + { + IApplicationBuilder builder = null!; + + _ = Assert.Throws(() => builder.AddSegment("test")); + } + + [Test] + [Arguments(null!)] + [Arguments("")] + [Arguments(" ")] + public void AddSegment_WithNullOrWhiteSpaceSegment_ThrowsArgumentException(string? segment) + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = Assert.Throws(() => builder.AddSegment(segment)); + } + + [Test] + public async Task AddSegment_WithValidSegment_ReturnsSegmentBuilder() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var result = builder.AddSegment("blog"); + + _ = await Assert.That(result).IsNotNull(); + } + + [Test] + public async Task AddSegment_RegistersForgingBlazorServices() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = builder.AddSegment("test"); + + var hasContentRegister = builder.Services.Any(x => x.ServiceType == typeof(IContentRegister)); + _ = await Assert.That(hasContentRegister).IsTrue(); + } + + [Test] + public void AddSegmentGeneric_WithNullBuilder_ThrowsArgumentNullException() + { + IApplicationBuilder builder = null!; + + _ = Assert.Throws(() => builder.AddSegment("test")); + } + + [Test] + [Arguments(null!)] + [Arguments("")] + [Arguments(" ")] + public void AddSegmentGeneric_WithNullOrWhiteSpaceSegment_ThrowsArgumentException(string? segment) + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = Assert.Throws(() => builder.AddSegment(segment)); + } + + [Test] + public async Task AddSegmentGeneric_WithValidSegment_ReturnsSegmentBuilder() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var result = builder.AddSegment("docs"); + + _ = await Assert.That(result).IsNotNull(); + } + + [Test] + public void AddBlogSegment_WithNullBuilder_ThrowsArgumentNullException() + { + IApplicationBuilder builder = null!; + + _ = Assert.Throws(() => builder.AddBlogSegment("blog")); + } + + [Test] + [Arguments(null!)] + [Arguments("")] + [Arguments(" ")] + public void AddBlogSegment_WithNullOrWhiteSpaceSegment_ThrowsArgumentException(string? segment) + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = Assert.Throws(() => builder.AddBlogSegment(segment)); + } + + [Test] + public async Task AddBlogSegment_WithValidSegment_ReturnsBlogSegmentBuilder() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var result = builder.AddBlogSegment("blog"); + + _ = await Assert.That(result).IsNotNull(); + } + + [Test] + public void AddBlogSegmentGeneric_WithNullBuilder_ThrowsArgumentNullException() + { + IApplicationBuilder builder = null!; + + _ = Assert.Throws(() => builder.AddBlogSegment("blog")); + } + + [Test] + [Arguments(null!)] + [Arguments("")] + [Arguments(" ")] + public void AddBlogSegmentGeneric_WithNullOrWhiteSpaceSegment_ThrowsArgumentException(string? segment) + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = Assert.Throws(() => builder.AddBlogSegment(segment)); + } + + [Test] + public async Task AddBlogSegmentGeneric_WithValidSegment_ReturnsBlogSegmentBuilder() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var result = builder.AddBlogSegment("posts"); + + _ = await Assert.That(result).IsNotNull(); + } + + [Test] + public async Task AddDefaultContent_RegistersContentCollector() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = builder.AddDefaultContent(); + + var hasContentCollector = builder.Services.Any(x => x.ServiceType == typeof(IContentCollector)); + _ = await Assert.That(hasContentCollector).IsTrue(); + } + + [Test] + public async Task AddSegment_MultipleSegments_RegistersAll() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var result1 = builder.AddSegment("docs"); + var result2 = builder.AddSegment("guides"); + + _ = await Assert.That(result1).IsNotNull(); + _ = await Assert.That(result2).IsNotNull(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812", + Justification = "Used as type parameter in tests" + )] + private sealed record TestPage : PageBase; + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812", + Justification = "Used as type parameter in tests" + )] + private sealed record TestBlogPost : BlogPostBase; +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Unit/ApplicationBuilderTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Unit/ApplicationBuilderTests.cs new file mode 100644 index 0000000..59b61e3 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Unit/ApplicationBuilderTests.cs @@ -0,0 +1,203 @@ +namespace NetEvolve.ForgingBlazor.Tests.Unit; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using NetEvolve.ForgingBlazor.Extensibility.Models; + +public sealed class ApplicationBuilderTests +{ + [Test] + public async Task CreateDefaultBuilder_EmptyArguments_ReturnsOne() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + var app = builder.Build(); + + var exitCode = await app.RunAsync(); + + _ = await Assert.That(exitCode).IsEqualTo(1); + } + + [Test] + public async Task CreateEmptyBuilder_EmptyArguments_ReturnsOne() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args).AddDefaultContent(); + var app = builder.Build(); + + var exitCode = await app.RunAsync(); + + _ = await Assert.That(exitCode).IsEqualTo(1); + } + + [Test] + public async Task Constructor_WithArgs_CreatesInstanceWithServices() + { + var args = new[] { "arg1", "arg2" }; + + var builder = new ApplicationBuilder(args); + + _ = await Assert.That(builder.Services).IsNotNull(); + } + + [Test] + public async Task CreateDefaultBuilder_WithArgs_ReturnsBuilderWithServices() + { + var args = new[] { "test" }; + + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = await Assert.That(builder).IsNotNull(); + _ = await Assert.That(builder.Services).IsNotNull(); + } + + [Test] + public async Task CreateDefaultBuilder_RegistersDefaultServices() + { + var args = Array.Empty(); + + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var hasContentRegistration = builder.Services.Any(x => x.ServiceType == typeof(IContentRegistration)); + _ = await Assert.That(hasContentRegistration).IsTrue(); + } + + [Test] + public async Task CreateDefaultBuilderGeneric_WithCustomPageType_ReturnsBuilder() + { + var args = Array.Empty(); + + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = await Assert.That(builder).IsNotNull(); + _ = await Assert.That(builder.Services).IsNotNull(); + } + + [Test] + public async Task CreateDefaultBuilderGeneric_RegistersDefaultServicesWithCustomPageType() + { + var args = Array.Empty(); + + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var hasContentRegistration = builder.Services.Any(x => x.ServiceType == typeof(IContentRegistration)); + _ = await Assert.That(hasContentRegistration).IsTrue(); + } + + [Test] + public async Task CreateEmptyBuilder_WithArgs_ReturnsBuilderWithEmptyServices() + { + var args = new[] { "test" }; + + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = await Assert.That(builder).IsNotNull(); + _ = await Assert.That(builder.Services).IsNotNull(); + _ = await Assert.That(builder.Services.Count).IsEqualTo(0); + } + + [Test] + public void Build_WithoutContentRegistration_ThrowsInvalidOperationException() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args); + + _ = Assert.Throws(() => builder.Build()); + } + + [Test] + public async Task Build_WithContentRegistration_CreatesApplication() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var app = builder.Build(); + + _ = await Assert.That(app).IsNotNull(); + } + + [Test] + public async Task Build_WithEmptyBuilderAndDefaultContent_CreatesApplication() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args).AddDefaultContent(); + + var app = builder.Build(); + + _ = await Assert.That(app).IsNotNull(); + } + + [Test] + public async Task Build_AddsNullLoggerWhenNotRegistered() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateEmptyBuilder(args).AddDefaultContent(); + + var app = builder.Build(); + + _ = await Assert.That(app).IsNotNull(); + } + + [Test] + public async Task Build_PreservesExistingLoggerFactory() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + using var customLoggerFactory = LoggerFactory.Create(b => b.AddConsole()); + _ = builder.Services.AddSingleton(customLoggerFactory); + + var app = builder.Build(); + + _ = await Assert.That(app).IsNotNull(); + } + + [Test] + public async Task Services_CanBeModified() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + _ = builder.Services.AddSingleton(); + + var hasTestService = builder.Services.Any(x => x.ServiceType == typeof(ITestService)); + _ = await Assert.That(hasTestService).IsTrue(); + } + + [Test] + public async Task Build_CreatesApplicationWithCommandLineArgs() + { + var args = new[] { "build" }; + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var app = builder.Build(); + var exitCode = await app.RunAsync(); + + _ = await Assert.That(app).IsNotNull(); + // The build command without proper context returns 1, not 0 + _ = await Assert.That(exitCode).IsEqualTo(1); + } + + [Test] + public async Task Build_IncludesServiceDescriptorsInProvider() + { + var args = Array.Empty(); + var builder = ApplicationBuilder.CreateDefaultBuilder(args); + + var app = builder.Build(); + + _ = await Assert.That(app).IsNotNull(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812", + Justification = "Used as type parameter in tests" + )] + private sealed record TestPage : PageBase; + + private interface ITestService; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used for DI tests")] + private sealed class TestService : ITestService; +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Unit/Configuration/ConfigurationLoaderTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Unit/Configuration/ConfigurationLoaderTests.cs new file mode 100644 index 0000000..17c3ab6 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Unit/Configuration/ConfigurationLoaderTests.cs @@ -0,0 +1,13 @@ +namespace NetEvolve.ForgingBlazor.Tests.Unit.Configuration; + +using NetEvolve.ForgingBlazor.Configurations; + +public sealed class ConfigurationLoaderTests +{ + [Test] + [Arguments(null!)] + [Arguments(" ")] + [Arguments("")] + public void Load_ConfigurationFile_ThrowsArgumentException_WhenProjectPathIsNullOrWhiteSpace(string? projectPath) => + _ = Assert.Throws(() => ConfigurationLoader.Load(null, projectPath)); +} diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Unit/NetEvolve.ForgingBlazor.Tests.Unit.csproj b/tests/NetEvolve.ForgingBlazor.Tests.Unit/NetEvolve.ForgingBlazor.Tests.Unit.csproj new file mode 100644 index 0000000..3f760ba --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Unit/NetEvolve.ForgingBlazor.Tests.Unit.csproj @@ -0,0 +1,20 @@ + + + $(_TestTargetFrameworks) + Exe + + + + + + + + + + + + + + + + diff --git a/tests/NetEvolve.ForgingBlazor.Tests.Unit/ServiceCollectionExtensionsTests.cs b/tests/NetEvolve.ForgingBlazor.Tests.Unit/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..f469c41 --- /dev/null +++ b/tests/NetEvolve.ForgingBlazor.Tests.Unit/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,159 @@ +namespace NetEvolve.ForgingBlazor.Tests.Unit; + +using System.CommandLine; +using Markdig; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.ForgingBlazor.Extensibility.Abstractions; +using YamlDotNet.Serialization; + +public sealed class ServiceCollectionExtensionsTests +{ + [Test] + public void AddForgingBlazorServices_WithNullServices_ThrowsArgumentNullException() + { + IServiceCollection services = null!; + + _ = Assert.Throws(() => services.AddForgingBlazorServices()); + } + + [Test] + public async Task AddForgingBlazorServices_RegistersCoreServices() + { + var services = new ServiceCollection(); + + var result = services.AddForgingBlazorServices(); + + _ = await Assert.That(result).IsNotNull(); + _ = await Assert.That(services.Any(x => x.ServiceType == typeof(RootCommand))).IsTrue(); + _ = await Assert.That(services.Any(x => x.ServiceType == typeof(IContentRegister))).IsTrue(); + } + + [Test] + public async Task AddForgingBlazorServices_RegistersCommands() + { + var services = new ServiceCollection(); + + _ = services.AddForgingBlazorServices(); + + var commandDescriptors = services.Where(x => x.ServiceType == typeof(Command)).ToList(); + _ = await Assert.That(commandDescriptors.Count).IsGreaterThanOrEqualTo(4); + } + + [Test] + public async Task AddForgingBlazorServices_CalledTwice_DoesNotDuplicateServices() + { + var services = new ServiceCollection(); + + _ = services.AddForgingBlazorServices(); + var countAfterFirst = services.Count; + + _ = services.AddForgingBlazorServices(); + var countAfterSecond = services.Count; + + _ = await Assert.That(countAfterFirst).IsEqualTo(countAfterSecond); + } + + [Test] + public void AddMarkdownServices_WithNullServices_ThrowsArgumentNullException() + { + IServiceCollection services = null!; + + _ = Assert.Throws(() => services.AddMarkdownServices()); + } + + [Test] + public async Task AddMarkdownServices_RegistersMarkdownPipeline() + { + var services = new ServiceCollection(); + + var result = services.AddMarkdownServices(); + + _ = await Assert.That(result).IsNotNull(); + _ = await Assert.That(services.Any(x => x.ServiceType == typeof(MarkdownPipeline))).IsTrue(); + } + + [Test] + public async Task AddMarkdownServices_RegistersYamlDeserializer() + { + var services = new ServiceCollection(); + + _ = services.AddMarkdownServices(); + + var hasDeserializer = services.Any(x => x.ServiceType == typeof(IDeserializer)); + _ = await Assert.That(hasDeserializer).IsTrue(); + } + + [Test] + public async Task AddMarkdownServices_CalledTwice_DoesNotDuplicateServices() + { + var services = new ServiceCollection(); + + _ = services.AddMarkdownServices(); + var countAfterFirst = services.Count; + + _ = services.AddMarkdownServices(); + var countAfterSecond = services.Count; + + _ = await Assert.That(countAfterFirst).IsEqualTo(countAfterSecond); + } + + [Test] + public async Task IsServiceTypeRegistered_WithRegisteredService_ReturnsTrue() + { + var services = new ServiceCollection(); + _ = services.AddSingleton(); + + var result = services.IsServiceTypeRegistered(); + + _ = await Assert.That(result).IsTrue(); + } + + [Test] + public async Task IsServiceTypeRegistered_WithUnregisteredService_ReturnsFalse() + { + var services = new ServiceCollection(); + + var result = services.IsServiceTypeRegistered(); + + _ = await Assert.That(result).IsFalse(); + } + + [Test] + public async Task IsServiceTypeRegistered_WithEmptyCollection_ReturnsFalse() + { + var services = new ServiceCollection(); + + var result = services.IsServiceTypeRegistered(); + + _ = await Assert.That(result).IsFalse(); + } + + [Test] + public async Task AddMarkdownServices_ConfiguresMarkdownPipelineWithExtensions() + { + var services = new ServiceCollection(); + + _ = services.AddMarkdownServices(); + await using var provider = services.BuildServiceProvider(); + var pipeline = provider.GetService(); + + _ = await Assert.That(pipeline).IsNotNull(); + } + + [Test] + public async Task AddMarkdownServices_ConfiguresYamlDeserializerWithCamelCase() + { + var services = new ServiceCollection(); + + _ = services.AddMarkdownServices(); + await using var provider = services.BuildServiceProvider(); + var deserializer = provider.GetService(); + + _ = await Assert.That(deserializer).IsNotNull(); + } + + private interface ITestService; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", Justification = "Used for DI tests")] + private sealed class TestService : ITestService; +}