diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bbc8992 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,391 @@ +root = true + +# All files +[*] +charset = utf-8 +trim_trailing_whitespace = false + +# Indentation and spacing +indent_style = space +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +# XML files +[*.{xml,csproj,props,targets,ruleset,config,nuspec,resx}] +indent_size = 2 + +# JSON files +[*.{json,jsonc}] +indent_size = 2 + +# YAML files +[*.{yaml,yml}] +indent_size = 2 + +# HTML files +[*.{html,htm,css,scss,js,jsx,ts,tsx}] +indent_size = 2 + +# C# files +[*.cs] + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md new file mode 100644 index 0000000..272cbd7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md new file mode 100644 index 0000000..24473de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d145665 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,53 @@ +## Purpose + +* ... + +## Does this introduce a breaking change? + +``` +[ ] Yes +[ ] No +``` + +## Pull Request Type +What kind of change does this Pull Request introduce? + + +``` +[ ] Bugfix +[ ] New feature +[ ] Refactoring (no functional changes, no api changes) +[ ] Documentation content changes +[ ] Other... Please describe: +``` + +## README updated? + +The top-level readme for this repo contains a link to each sample in the repo. If you're adding a new sample did you update the readme? + +``` +[ ] Yes +[ ] No +[ ] N/A +``` + +## How to Test +* Get the code + +``` +git clone [repo-address] +cd [repo-name] +git checkout [branch-name] +``` + +* Test the code + +``` +``` + +## What to Check +Verify that the following are valid +* ... + +## Other Information + \ No newline at end of file diff --git a/.github/agents/expert-dotnet-software-engineer.agent.md b/.github/agents/expert-dotnet-software-engineer.agent.md new file mode 100644 index 0000000..363122b --- /dev/null +++ b/.github/agents/expert-dotnet-software-engineer.agent.md @@ -0,0 +1,58 @@ +--- +description: "Provide expert .NET software engineering guidance using modern software design patterns." +name: "Expert .NET software engineer mode instructions" +tools: ["agent", "edit", "execute", "read", "search", "todo", "vscode", "web", "microsoft-docs/*"] +--- + +# Agent Overview + +You are in expert software engineer mode in .NET. Your task is to provide expert software engineering guidance using modern software design patterns as if you were a leader in the field. + +## Core Objectives + +You will provide: + +- insights, best practices and recommendations for .NET software engineering as if you were Anders Hejlsberg, the original architect of C# and a key figure in the development of .NET as well as Mads Torgersen, the lead designer of C#. +- general software engineering guidance and best-practices, clean code and modern software design, as if you were Robert C. Martin (Uncle Bob), a renowned software engineer and author of "Clean Code" and "The Clean Coder". +- DevOps and CI/CD best practices, as if you were Jez Humble, co-author of "Continuous Delivery" and "The DevOps Handbook". +- Testing and test automation best practices, as if you were Kent Beck, the creator of Extreme Programming (XP) and a pioneer in Test-Driven Development (TDD). + +## .NET-Specific Guidance + +For .NET-specific guidance, focus on the following areas: + +- **Design Patterns**: Use and explain modern design patterns such as Async/Await, Dependency Injection, Repository Pattern, Unit of Work, CQRS, Event Sourcing and of course the Gang of Four patterns. +- **SOLID Principles**: Emphasize the importance of SOLID principles in software design, ensuring that code is maintainable, scalable, and testable. +- **Testing**: Advocate for Test-Driven Development (TDD) and Behavior-Driven Development (BDD) practices, using frameworks like xUnit, NUnit, or MSTest. +- **Performance**: Provide insights on performance optimization techniques, including memory management, asynchronous programming, and efficient data access patterns. +- **Security**: Highlight best practices for securing .NET applications, including authentication, authorization, and data protection. + +## Test Codes + +- Write tests that cover both positive and negative scenarios. +- Ensure tests are isolated, repeatable, and independent of external systems. +- Always use xUnit as the testing framework. + - Use [Fact] for simple test cases. + - Use [Theory] with [InlineData] for parameterized test cases as many times as possible. +- Always use NSubstitute as the mocking framework. +- Always use Shouldly as the assertion library. +- Always use BUnit for Blazor component testing. +- Always use descriptive test method names that clearly indicate the purpose of the test. + - Test method names should follow the pattern: Given_[Conditions]_When_[MethodNameToInvoke]_Then_It_Should_[ExpectedBehaviour]. +- In the code, always use comments to separate the Arrange, Act, and Assert sections of the test method. + +## Plan-First Approach + +- Begin by outlining a detailed migration plan for each Astro component, including its purpose, functionality, and how it maps to Blazor. +- Create a todo list of tasks required to complete the migration for each component. +- Wait for approval of each task list before proceeding with implementation. +- When necessary, hand off complex tasks to specialized subagents for further analysis or implementation. + +### Research and Reference + +- Utilize official documentation for Blazor components to ensure accurate translations of features and functionalities. +- Reference community best practices and patterns for both frameworks. +- The plugins are based on the types from the following URLs: + - https://github.com/getscissorhands/Scissorhands.NET/tree/vnext/src/ScissorHands.Plugin + - https://github.com/getscissorhands/Scissorhands.NET/tree/vnext/src/ScissorHands.Core/Manifests + - https://github.com/getscissorhands/Scissorhands.NET/tree/vnext/src/ScissorHands.Core/Models diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..14aeb80 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,157 @@ +name: Build, Test and Publish + +on: + push: + branches: + - main + - feat/* + - hotfix/* + tags: + - 'v*' + pull_request: + branches: + - main + +env: + GH_PACKAGE_USERNAME: '${{ secrets.GH_PACKAGE_USERNAME }}' + GH_PACKAGE_TOKEN: '${{ secrets.GH_PACKAGE_TOKEN }}' + +jobs: + build_and_test: + name: Build and test packages + strategy: + matrix: + os: [ 'windows-latest', 'macos-latest', 'ubuntu-latest' ] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.x' + + - name: Check package version + id: version + shell: pwsh + run: | + $version = $env:GITHUB_REF -replace 'refs/tags/v', '' + if ($($env:GITHUB_REF).StartsWith('refs/tags/v') -eq $false) { + $version = "1.0.0" + } + + "value=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Update package version + shell: pwsh + run: | + $csprojPaths = (Get-ChildItem -Path ./src/*.csproj -Recurse).FullName + $csprojPaths | ForEach-Object { + $csprojPath = $_ + [xml]$xmlDoc = Get-Content -Path $csprojPath + + # Find and replace the Version node value + $versionNode = $xmlDoc.SelectSingleNode("//PropertyGroup/Version") + if ($versionNode -ne $null) { + $versionNode.InnerText = "${{ steps.version.outputs.value }}" + Write-Host "Version updated to: $($versionNode.InnerText) in file $csprojPath" + } else { + Write-Host "Version node not found in file $csprojPath!" + } + + # Save it with .xml extension + $xmlDoc.Save($csprojPath) + Write-Host "File saved: $csprojPath" + } + + - name: Clear NuGet local cache + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + dotnet nuget locals all --clear + + - name: Restore NuGet packages + shell: pwsh + run: | + dotnet restore + + - name: Build solution + shell: pwsh + run: | + dotnet build -c Release -p:Version=${{ steps.version.outputs.value }} + + - name: Run unit tests + shell: pwsh + run: | + dotnet test -c Release --no-build --verbosity normal + + - name: Pack NuGet package + if: ${{ startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' }} + shell: pwsh + run: | + dotnet pack ./src/ScissorHands.Plugin.GoogleAnalytics -c Release -o published --include-symbols -p:Version=${{ steps.version.outputs.value }} + + - name: Upload Artifact + if: ${{ startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' }} + uses: actions/upload-artifact@v5 + with: + name: artifacts + path: ./published/ + + release: + name: Release packages + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + needs: + - build_and_test + + runs-on: ubuntu-latest + + permissions: + packages: write + contents: write + + steps: + - name: Check package version + id: version + shell: pwsh + run: | + $version = $env:GITHUB_REF -replace 'refs/tags/v', '' + if ($($env:GITHUB_REF).StartsWith('refs/tags/v') -eq $false) { + $version = "1.0.0" + } + + "value=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Download Artifact + uses: actions/download-artifact@v6 + with: + name: artifacts + path: ./published + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.x' + + - name: Create Release to GitHub + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + tag_name: "v${{ steps.version.outputs.value }}" + files: ./published/*.* + generate_release_notes: true + + - name: Release to GitHub Package Registry + shell: bash + run: | + dotnet nuget push ./published/*.nupkg \ + --source "https://nuget.pkg.github.com/getscissorhands/index.json" \ + --api-key "${{ secrets.GITHUB_TOKEN }}" + + # - name: Release to NuGet + # shell: bash + # run: | + # dotnet nuget push ./published/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index ce89292..df173b9 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,8 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Repository specific +.DS_Store +preview/ +Preview.sln diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b6a09b3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "markdownlint.config": { + "MD028": false, + "MD025": { + "front_matter_title": "" + } + }, + "chat.tools.terminal.autoApprove": { + ".specify/scripts/bash/": true, + ".specify/scripts/powershell/": true, + "/^dotnet\\b.*$/": true, + "/^git\\b.*$/": true, + "/^npm\\b.*$/": true, + "/^pwsh\\b.*$/": true, + "awk": true, + "cat": true, + "cd": true, + "curl": true, + "grep": true, + "jq": true, + "mv": true, + "mkdir": true, + "pushd": true, + "popd": true, + "pwd": true, + "sed": true, + "Get-ChildItem": true, + "Get-Content": true, + "Get-Item": true, + "Invoke-RestMethod": true, + "Invoke-WebRequest": true, + "New-Item": true, + "Push-Location": true, + "Pop-Location": true, + "Select-String": true, + "Test-Path": true, + "Where-Object": true, + "Write-Host": true + } +} \ No newline at end of file diff --git a/README.md b/README.md index 5f3b384..2f1bfcb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ -# plugins -Collection of plugins for ScissorHands.NET +# ScissorHands Plugins + +Collection of the official plugins for ScissorHands.NET + +## List of Plugins + +| Name | Description | +|-------------------------------------------------------------------------|--------------------------------| +| [Google Analytics](./src/ScissorHands.Plugin.GoogleAnalytics/README.md) | Google Analytics script plugin | + +## Build Your Plugin + +1. Set environment variables for GitHub NuGet Package Registry. + + ```bash + # zsh/bash + export GH_PACKAGE_USERNAME="" + export GH_PACKAGE_TOKEN="" + ``` + + ```powershell + # PowerShell + $env:GH_PACKAGE_USERNAME = "" + $env:GH_PACKAGE_TOKEN = "" + ``` + +1. Create a class library. + + ```bash + dotnet new classlib -n MyAwesomeScissorHandsPlugin + ``` + +1. Add a NuGet package. + + ```bash + dotnet add package ScissorHands.Plugin --prerelease + ``` + +1. Create a plugin class inheriting the `ContentPlugin` class. + + ```csharp + public class MyAwesomeScissorHandsPlugin : ContentPlugin + { + public override string Name => "My Awesome ScissorHands Plugin"; + + public override async Task PreMarkdownAsync(ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + { + // ADD LOGIC HERE + } + + public override async Task PostMarkdownAsync(ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + { + // ADD LOGIC HERE + } + + public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + { + // ADD LOGIC HERE + } + } + ``` + +## Issues? + +If you find any issues, please [report them](../../issues). diff --git a/ScissorHandsPlugins.sln b/ScissorHandsPlugins.sln new file mode 100644 index 0000000..01c04d8 --- /dev/null +++ b/ScissorHandsPlugins.sln @@ -0,0 +1,56 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.GoogleAnalytics", "src\ScissorHands.Plugin.GoogleAnalytics\ScissorHands.Plugin.GoogleAnalytics.csproj", "{CB30DD74-0080-424B-A248-E9B2E7746CE2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.GoogleAnalytics.Tests", "test\ScissorHands.Plugin.GoogleAnalytics.Tests\ScissorHands.Plugin.GoogleAnalytics.Tests.csproj", "{8EDFD210-29DC-433A-970A-FB8BD53CCAFB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Debug|x64.ActiveCfg = Debug|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Debug|x64.Build.0 = Debug|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Debug|x86.ActiveCfg = Debug|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Debug|x86.Build.0 = Debug|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|Any CPU.Build.0 = Release|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x64.ActiveCfg = Release|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x64.Build.0 = Release|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x86.ActiveCfg = Release|Any CPU + {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x86.Build.0 = Release|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|x64.Build.0 = Debug|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|x86.Build.0 = Debug|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|Any CPU.Build.0 = Release|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x64.ActiveCfg = Release|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x64.Build.0 = Release|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x86.ActiveCfg = Release|Any CPU + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CB30DD74-0080-424B-A248-E9B2E7746CE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8EDFD210-29DC-433A-970A-FB8BD53CCAFB} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + EndGlobalSection +EndGlobal diff --git a/assets/ScissorHands.png b/assets/ScissorHands.png new file mode 100644 index 0000000..21f653c Binary files /dev/null and b/assets/ScissorHands.png differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..fe7e453 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..95c59a7 --- /dev/null +++ b/nuget.config @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs new file mode 100644 index 0000000..a3a9363 --- /dev/null +++ b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs @@ -0,0 +1,46 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.GoogleAnalytics; + +/// +/// This represents the plugin entity for Google Analytics. +/// +public class GoogleAnalyticsPlugin : ContentPlugin +{ + private const string GOOGLE_ANALYTICS_SCRIPT = """ + + + + """; + + private const string PLACEHOLDER = ""; + + /// + public override string Name => "Google Analytics"; + + /// + public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var measurementId = manifest.Options!.TryGetValue("MeasurementId", out var id) + ? id as string + : default; + if (measurementId is null) + { + return html; + } + + var script = GOOGLE_ANALYTICS_SCRIPT.Replace("{{MEASUREMENT_ID}}", measurementId); + + html = html.Replace(PLACEHOLDER, $"\n{script}\n", StringComparison.OrdinalIgnoreCase); + + return await Task.FromResult(html); + } +} diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/README.md b/src/ScissorHands.Plugin.GoogleAnalytics/README.md new file mode 100644 index 0000000..27ff259 --- /dev/null +++ b/src/ScissorHands.Plugin.GoogleAnalytics/README.md @@ -0,0 +1,53 @@ +# ScissorHands.NET: Google Analytics Plugin + +This plugin renders [Google Analytics](https://analytics.google.com) script. + +## GitHub Nuget Package Registry + +1. Set environment variables for GitHub NuGet Package Registry. + + ```bash + # zsh/bash + export GH_PACKAGE_USERNAME="" + export GH_PACKAGE_TOKEN="" + ``` + + ```powershell + # PowerShell + $env:GH_PACKAGE_USERNAME = "" + $env:GH_PACKAGE_TOKEN = "" + ``` + +## Getting Started + +1. Assuming that you've got a running [ScissorHands.NET](https://github.com/getscissorhands/Scissorhands.NET) app. +1. Update the plugin section of `appsettings.json` to add options. `MeasurementId` can be obtained from [Google Analytics](https://analytics.google.com) website. + + ```jsonc + { + ... + "Plugins": [ + { + "Name": "Google Analytics", + "Options": { + "MeasurementId": "G-XXXXXXXX" + } + } + ] + } + ``` + +1. Add a NuGet package. + + ```bash + dotnet add package ScissorHands.Plugin.GoogleAnalytics --prerelease + ``` + +1. Add the placeholder, ``, to `MainLayout.razor`. + + ```html + + + + + ``` diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj b/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj new file mode 100644 index 0000000..7412bfe --- /dev/null +++ b/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj @@ -0,0 +1,65 @@ + + + + net10.0 + enable + enable + + latest + + ScissorHands.Plugin.GoogleAnalytics + ScissorHands.Plugin.GoogleAnalytics + + + + true + True + + ScissorHands.Plugin.GoogleAnalytics + 1.0.0 + + ScissorHands.Plugin.GoogleAnalytics + Justin Yoo + GetScissorHands + ScissorHands.Plugin.GoogleAnalytics + ScissorHands.Plugin.GoogleAnalytics: Google Analytics plugin for ScissorHands.NET + This is a library that uses Google Analytics for ScissorHands.NET + © GetScissorHands. All rights reserved. + justinyoo + + https://github.com/getscissorhands/plugins + ScissorHands.png + https://raw.githubusercontent.com/getscissorhands/plugins/refs/heads/main/assets/ScissorHands.png + README.md + scissorhands;plugin;static-site;google-analytics + + MIT + True + + https://github.com/getscissorhands/plugins + git + + True + snupkg + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + + diff --git a/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs b/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs new file mode 100644 index 0000000..f48c65c --- /dev/null +++ b/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs @@ -0,0 +1,230 @@ +using System.Text.RegularExpressions; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.GoogleAnalytics.Tests; + +public class GoogleAnalyticsPluginTests +{ + private static readonly Regex GoogleTagRegex = new("", RegexOptions.Compiled); + + [Theory] + [InlineData("Google Analytics")] + public void When_Instantiated_Then_Name_Should_Be(string name) + { + // Arrange + var plugin = new GoogleAnalyticsPlugin(); + + // Act + var result = plugin.Name; + + // Assert + result.ShouldBe(name); + } + + [Theory] + [InlineData(typeof(TaskCanceledException))] + public void Given_CancellationToken_When_PostHtmlAsync_Invoked_Then_It_Should_Throw_TaskCanceledException(Type exception) + { + // Arrange + var plugin = new GoogleAnalyticsPlugin(); + var html = string.Empty; + var document = new ContentDocument(); + var manifest = new PluginManifest(); + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // Act + Func func = async () => await plugin.PostHtmlAsync(html, document, manifest, cancellationTokenSource.Token); + + // Assert + func.ShouldThrow(exception); + } + + [Theory] + [InlineData("Test")] + public async Task Given_InvalidOption_When_PostHtmlAsync_Invoked_Then_It_Should_Return_OriginalHtml(string html) + { + // Arrange + var plugin = new GoogleAnalyticsPlugin(); + var document = new ContentDocument(); + var manifest = new PluginManifest + { + Options = new Dictionary + { + { "SomeOtherKey", "SomeValue" } + } + }; + + // Act + var result = await plugin.PostHtmlAsync(html, document, manifest); + + // Assert + result.ShouldBe(html); + } + + [Theory] + [InlineData("Test")] + public async Task Given_NoMeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Return_OriginalHtml(string html) + { + // Arrange + var plugin = new GoogleAnalyticsPlugin(); + var document = new ContentDocument(); + var manifest = new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", null! } + } + }; + + // Act + var result = await plugin.PostHtmlAsync(html, document, manifest); + + // Assert + result.ShouldBe(html); + } + + [Theory] + [InlineData("Test", "G-XXXXXXXXXX")] + public async Task Given_MeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Replace_Placeholder(string html, string measurementId) + { + // Arrange + var plugin = new GoogleAnalyticsPlugin(); + var document = new ContentDocument(); + var manifest = new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", measurementId } + } + }; + + // Act + var result = await plugin.PostHtmlAsync(html, document, manifest); + + // Assert + result.ShouldContain($"https://www.googletagmanager.com/gtag/js?id={measurementId}"); + result.ShouldContain($"gtag('config', '{measurementId}');"); + result.ShouldNotContain(""); + result.ShouldNotContain("{{MEASUREMENT_ID}}"); + } + + [Theory] + [InlineData("Test", "G-XXXXXXXXXX")] + public async Task Given_MeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Insert_GoogleTagScript(string html, string measurementId) + { + // Arrange + var plugin = new GoogleAnalyticsPlugin(); + var document = new ContentDocument(); + var manifest = new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", measurementId } + } + }; + + // Act + var result = await plugin.PostHtmlAsync(html, document, manifest); + + // Assert + result.ShouldContain(""); + result.ShouldContain("