From 6162d7bf1158145e2809532a52dd41846787595b Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Tue, 16 Dec 2025 23:34:05 +1100 Subject: [PATCH 1/2] Initial code commit --- .editorconfig | 391 ++++++++++++++++++ .github/ISSUE_TEMPLATE/BUG_REPORT.md | 38 ++ .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md | 20 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/PULL_REQUEST_TEMPLATE.md | 53 +++ .../expert-dotnet-software-engineer.agent.md | 58 +++ .github/workflows/main.yaml | 153 +++++++ .gitignore | 5 + .vscode/settings.json | 40 ++ README.md | 67 ++- ScissorHandsPlugins.sln | 56 +++ assets/ScissorHands.png | Bin 0 -> 29454 bytes global.json | 7 + nuget.config | 15 + .../GoogleAnalyticsPlugin.cs | 46 +++ .../README.md | 53 +++ ...ScissorHands.Plugin.GoogleAnalytics.csproj | 65 +++ .../GoogleAnalyticsPluginTests.cs | 230 +++++++++++ ...rHands.Plugin.GoogleAnalytics.Tests.csproj | 36 ++ 19 files changed, 1332 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/BUG_REPORT.md create mode 100644 .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/agents/expert-dotnet-software-engineer.agent.md create mode 100644 .github/workflows/main.yaml create mode 100644 .vscode/settings.json create mode 100644 ScissorHandsPlugins.sln create mode 100644 assets/ScissorHands.png create mode 100644 global.json create mode 100644 nuget.config create mode 100644 src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs create mode 100644 src/ScissorHands.Plugin.GoogleAnalytics/README.md create mode 100644 src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj create mode 100644 test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs create mode 100644 test/ScissorHands.Plugin.GoogleAnalytics.Tests/ScissorHands.Plugin.GoogleAnalytics.Tests.csproj 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..0ac9c8c --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,153 @@ +name: Build, Test and Publish + +on: + push: + branches: + - main + - feat/* + - hotfix/* + tags: + - 'v*' + pull_request: + branches: + - main + +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 0000000000000000000000000000000000000000..21f653c4c82539076e74797773341d34ec83eee4 GIT binary patch literal 29454 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 + + + + + + + + + + + + + + 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("