From 356df3f3ae972c4f65cd93cddc3f65f42b5d4f62 Mon Sep 17 00:00:00 2001 From: s97712 Date: Sat, 13 Sep 2025 02:44:56 +0800 Subject: [PATCH] feat: Add support for .razor.ts and .entry.ts files This commit introduces a new file naming convention to better distinguish between component-specific scripts and shared modules. - : For TypeScript files tightly coupled with a Razor component. The generator creates a partial class for the component with an injected property. - : For standalone TypeScript modules that can be shared across the application. The generator creates a standard C# class that can be registered and injected via DI. This change deprecates the generic use of files, promoting a more structured and explicit approach to organizing TypeScript code in Blazor projects. --- .../MethodExtractorTests.cs | 2 +- .../ResolveGeneratorTests.cs | 160 ++++++++---------- BlazorTS.SourceGenerator.Tests/TestBase.cs | 5 +- BlazorTS.SourceGenerator/DllResolver.cs | 2 +- BlazorTS.SourceGenerator/ResolveGenerator.cs | 121 +++++++++---- .../BlazorTS.TestPackage.csproj | 3 +- BlazorTS.TestPackage/SourceGeneratorTest.cs | 35 ++-- BlazorTS.TestPackage/TestComponent.razor.ts | 3 + .../{TestFunctions.ts => TestModule.entry.ts} | 0 README.md | 87 +++++++--- README_CN.md | 78 ++++++--- ...\204TypeScript\350\257\255\346\263\225.md" | 10 +- 12 files changed, 310 insertions(+), 196 deletions(-) create mode 100644 BlazorTS.TestPackage/TestComponent.razor.ts rename BlazorTS.TestPackage/{TestFunctions.ts => TestModule.entry.ts} (100%) diff --git a/BlazorTS.SourceGenerator.Tests/MethodExtractorTests.cs b/BlazorTS.SourceGenerator.Tests/MethodExtractorTests.cs index 3e2f677..2020cdb 100644 --- a/BlazorTS.SourceGenerator.Tests/MethodExtractorTests.cs +++ b/BlazorTS.SourceGenerator.Tests/MethodExtractorTests.cs @@ -44,7 +44,7 @@ public void Extract_EmptyCode_ReturnsEmpty() public void Extract_NullCode_ReturnsEmpty() { // Act & Assert - Assert.Empty(MethodExtractor.Extract(null)); + Assert.Empty(MethodExtractor.Extract(null!)); } [Fact] diff --git a/BlazorTS.SourceGenerator.Tests/ResolveGeneratorTests.cs b/BlazorTS.SourceGenerator.Tests/ResolveGeneratorTests.cs index a1f3214..a70b522 100644 --- a/BlazorTS.SourceGenerator.Tests/ResolveGeneratorTests.cs +++ b/BlazorTS.SourceGenerator.Tests/ResolveGeneratorTests.cs @@ -16,134 +16,110 @@ public void ResolveGenerator_Create_DoesNotThrow() } [Fact] - public void ResolveGenerator_WithSingleTsFile_GeneratesOutput() + public void ResolveGenerator_WithRazorTsFile_GeneratesPartialClass() { // Arrange var generator = new ResolveGenerator(); var tsContent = "export function greet(name: string): string { return `Hello, ${name}!`; }"; - var tsFile = CreateAdditionalText("Demo.ts", tsContent); + var tsFile = CreateAdditionalText("Components/Pages/MyComponent.razor.ts", tsContent); // Act - var generatorResult = RunGenerator(generator, new[] { tsFile }); + var generatorResult = RunGenerator(generator, new[] { tsFile }, "/test/project/", "TestApp"); // Assert - Assert.True(generatorResult.GeneratedSources.Length >= 0); - } + Assert.Single(generatorResult.GeneratedSources); + var generatedSource = generatorResult.GeneratedSources.Single(); + Assert.Equal("TestApp.Components.Pages.MyComponent.razor.g.cs", generatedSource.HintName); + var code = generatedSource.SourceText.ToString(); + Assert.Contains("namespace TestApp.Components.Pages;", code); + Assert.Contains("public partial class MyComponent", code); + Assert.Contains("[Inject] public TSInterop Scripts { get; set; } = null!;", code); + Assert.Contains("public class TSInterop(ScriptBridge invoker)", code); + Assert.Contains("public async Task greet(string name)", code); + } [Fact] - public void ResolveGenerator_VerifyGeneratedWrapperCode_CorrectStructure() + public void ResolveGenerator_WithEntryTsFile_GeneratesStandardClassAndExtension() { // Arrange var generator = new ResolveGenerator(); - var tsContent = "export function greet(name: string): string { return `Hello, ${name}!`; }"; - var tsFile = CreateAdditionalText("Demo.ts", tsContent); + var tsContent = "export function doWork(): void {}"; + var tsFile = CreateAdditionalText("Services/MyService.entry.ts", tsContent); // Act - var generatorResult = RunGenerator(generator, new[] { tsFile }); - - // Assert - 应该生成两个文件:包装类和服务扩展 - Assert.True(generatorResult.GeneratedSources.Length >= 2); - - // 检查包装类代码 - var wrapperSource = generatorResult.GeneratedSources - .FirstOrDefault(s => s.HintName.Contains("Demo.ts.module.g.cs")); - Assert.NotNull(wrapperSource.SourceText); - - var wrapperCode = wrapperSource.SourceText.ToString(); - - // 验证包装类结构 - Assert.Contains("public partial class Demo", wrapperCode); - Assert.Contains("public class TSInterop(ScriptBridge invoker)", wrapperCode); - Assert.Contains("public async Task greet(string name)", wrapperCode); - Assert.Contains("return await invoker.InvokeAsync", wrapperCode); - Assert.Contains("new object?[] { name }", wrapperCode); - // 验证使用实例方法解析路径 - Assert.Contains("private readonly string url = invoker.ResolveNS(typeof(", wrapperCode); - } + var generatorResult = RunGenerator(generator, new[] { tsFile }, "/test/project/", "TestApp"); - [Fact] - public void ResolveGenerator_VerifyServiceExtensionCode_CorrectRegistration() - { - // Arrange - var generator = new ResolveGenerator(); - var tsFile1 = CreateAdditionalText("Utils.ts", "function helper(): void {}"); - var tsFile2 = CreateAdditionalText("Api.ts", "function fetch(): Promise { return Promise.resolve(); }"); + // Assert + Assert.Equal(2, generatorResult.GeneratedSources.Length); - // Act - var generatorResult = RunGenerator(generator, new[] { tsFile1, tsFile2 }, "/test/project/", "MyApp"); - - // Assert - 检查服务扩展代码 - var serviceSource = generatorResult.GeneratedSources - .FirstOrDefault(s => s.HintName.Contains("ServiceCollectionExtensions.g.cs")); - Assert.NotNull(serviceSource.SourceText); - - var serviceCode = serviceSource.SourceText.ToString(); - - // 验证服务注册 - Assert.Contains("namespace BlazorTS.SourceGenerator.Extensions", serviceCode); - Assert.Contains("public static class ServiceCollectionExtensions", serviceCode); - Assert.Contains("AddBlazorTSScripts", serviceCode); - Assert.Contains("Utils.TSInterop", serviceCode); - Assert.Contains("Api.TSInterop", serviceCode); + // Check generated class + var classSource = generatorResult.GeneratedSources.FirstOrDefault(s => s.HintName.EndsWith(".entry.g.cs")); + Assert.NotNull(classSource.SourceText); + Assert.Equal("TestApp.Services.MyService.entry.g.cs", classSource.HintName); + var classCode = classSource.SourceText.ToString(); + Assert.Contains("namespace TestApp.Services;", classCode); + Assert.Contains("public class MyService(ScriptBridge invoker)", classCode); + Assert.DoesNotContain("public partial class", classCode); + Assert.DoesNotContain("[Inject]", classCode); + Assert.Contains("public async Task doWork()", classCode); + + // Check service extension + var extensionSource = generatorResult.GeneratedSources.FirstOrDefault(s => s.HintName.EndsWith("Extensions.g.cs")); + Assert.NotNull(extensionSource.SourceText); + var extensionCode = extensionSource.SourceText.ToString(); + Assert.Contains("public static IServiceCollection AddBlazorTSScripts(this IServiceCollection services)", extensionCode); + Assert.Contains("services.AddScoped();", extensionCode); } [Fact] - public void ResolveGenerator_VerifyTypeConversion_CorrectMapping() + public void ResolveGenerator_WithUnsupportedTsFile_GeneratesNothing() { // Arrange var generator = new ResolveGenerator(); - var tsContent = @" -export function processData( - text: string, - count: number, - flag: boolean, - data: any -): number { - return 42; -}"; - var tsFile = CreateAdditionalText("TypeTest.ts", tsContent); + var tsContent = "export function greet(name: string): string { return `Hello, ${name}!`; }"; + var tsFile = CreateAdditionalText("Demo.ts", tsContent); // Act - var generatorResult = RunGenerator(generator, new[] { tsFile }, "/test/project/", "TypeTests"); - + var generatorResult = RunGenerator(generator, new[] { tsFile }); + // Assert - var wrapperSource = generatorResult.GeneratedSources - .FirstOrDefault(s => s.HintName.Contains("TypeTest.ts.module.g.cs")); - Assert.NotNull(wrapperSource.SourceText); - - var wrapperCode = wrapperSource.SourceText.ToString(); - - // 验证类型转换 - Assert.Contains("string text", wrapperCode); // string -> string - Assert.Contains("double count", wrapperCode); // number -> double - Assert.Contains("bool flag", wrapperCode); // boolean -> bool - Assert.Contains("object? data", wrapperCode); // any -> object? - Assert.Contains("Task", wrapperCode); // number -> Task + Assert.Empty(generatorResult.GeneratedSources); } [Fact] - public void ResolveGenerator_VerifyVoidFunction_TaskReturnType() + public void ResolveGenerator_WithMixedFiles_GeneratesCorrectly() { // Arrange var generator = new ResolveGenerator(); - var tsContent = "export function initialize(): void { console.log('init'); }"; - var tsFile = CreateAdditionalText("Init.ts", tsContent); + var razorContent = "export function razorFunc(): void {}"; + var entryContent = "export function entryFunc(): void {}"; + var razorFile = CreateAdditionalText("Components/MyPage.razor.ts", razorContent); + var entryFile = CreateAdditionalText("Api/Client.entry.ts", entryContent); // Act - var generatorResult = RunGenerator(generator, new[] { tsFile }, "/test/project/", "InitApp"); - + var generatorResult = RunGenerator(generator, new[] { razorFile, entryFile }, "/test/project/", "MyApp"); + // Assert - var wrapperSource = generatorResult.GeneratedSources - .FirstOrDefault(s => s.HintName.Contains("Init.ts.module.g.cs")); - Assert.NotNull(wrapperSource.SourceText); - - var wrapperCode = wrapperSource.SourceText.ToString(); - - // 验证void函数返回Task而不是Task - Assert.Contains("public async Task initialize()", wrapperCode); - Assert.Contains("await invoker.InvokeAsync", wrapperCode); - Assert.DoesNotContain("Task", wrapperCode); + Assert.Equal(3, generatorResult.GeneratedSources.Length); + + // Razor file check + var razorSource = generatorResult.GeneratedSources.Single(s => s.HintName == "MyApp.Components.MyPage.razor.g.cs"); + var razorCode = razorSource.SourceText.ToString(); + Assert.Contains("public partial class MyPage", razorCode); + Assert.Contains("public async Task razorFunc()", razorCode); + + // Entry file check + var entrySource = generatorResult.GeneratedSources.Single(s => s.HintName == "MyApp.Api.Client.entry.g.cs"); + var entryCode = entrySource.SourceText.ToString(); + Assert.Contains("public class Client(ScriptBridge invoker)", entryCode); + Assert.Contains("public async Task entryFunc()", entryCode); + + // Extension check + var extensionSource = generatorResult.GeneratedSources.Single(s => s.HintName.EndsWith("Extensions.g.cs")); + var extensionCode = extensionSource.SourceText.ToString(); + Assert.Contains("services.AddScoped();", extensionCode); + Assert.DoesNotContain("MyPage", extensionCode); } } \ No newline at end of file diff --git a/BlazorTS.SourceGenerator.Tests/TestBase.cs b/BlazorTS.SourceGenerator.Tests/TestBase.cs index bc0033d..daf778b 100644 --- a/BlazorTS.SourceGenerator.Tests/TestBase.cs +++ b/BlazorTS.SourceGenerator.Tests/TestBase.cs @@ -54,9 +54,10 @@ protected static GeneratorRunResult RunGenerator( /// /// 创建AdditionalText测试对象 /// - protected static AdditionalText CreateAdditionalText(string path, string content) + protected static AdditionalText CreateAdditionalText(string path, string content, string projectDir = "/test/project/") { - return new TestAdditionalText(path, content); + var fullPath = Path.Combine(projectDir, path); + return new TestAdditionalText(fullPath, content); } /// diff --git a/BlazorTS.SourceGenerator/DllResolver.cs b/BlazorTS.SourceGenerator/DllResolver.cs index 83a3b0a..f8cff4c 100644 --- a/BlazorTS.SourceGenerator/DllResolver.cs +++ b/BlazorTS.SourceGenerator/DllResolver.cs @@ -58,7 +58,7 @@ public IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath /// /// 获取TypeScript解析器Native路径 - 从build property或Compilation的AssemblyMetadata获取 /// - public static string? GetTypeScriptParserNativePath(Compilation compilation, AnalyzerConfigOptionsProvider optionsProvider = null) + public static string? GetTypeScriptParserNativePath(Compilation compilation, AnalyzerConfigOptionsProvider? optionsProvider = null) { try { diff --git a/BlazorTS.SourceGenerator/ResolveGenerator.cs b/BlazorTS.SourceGenerator/ResolveGenerator.cs index 045fc16..b68ec6e 100755 --- a/BlazorTS.SourceGenerator/ResolveGenerator.cs +++ b/BlazorTS.SourceGenerator/ResolveGenerator.cs @@ -64,7 +64,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // TypeScript文件处理 var tsFilesProvider = context .AdditionalTextsProvider - .Where(static file => file.Path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + .Where(static file => file.Path.EndsWith(".razor.ts", StringComparison.OrdinalIgnoreCase) || file.Path.EndsWith(".entry.ts", StringComparison.OrdinalIgnoreCase)) .Select(static (file, _) => file.Path); // 注册诊断输出 @@ -79,10 +79,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return; } - Helper.Log(spc, $"Found {paths.Length} TypeScript files"); + Helper.Log(spc, $"Found {paths.Length} BlazorTS files"); foreach (var path in paths) { - Helper.Log(spc, $"TypeScript file: {path}"); + Helper.Log(spc, $"BlazorTS file: {path}"); } }); @@ -99,18 +99,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var razorJsFiles = context .AdditionalTextsProvider - .Where(static file => file.Path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + .Where(static file => file.Path.EndsWith(".razor.ts", StringComparison.OrdinalIgnoreCase) || file.Path.EndsWith(".entry.ts", StringComparison.OrdinalIgnoreCase)) .Combine(metaProvider); var razorContents = razorJsFiles .Select((info, cancellationToken) => { var (additionalText, meta) = info; - + var (className, namespaceName, isRazorComponent) = ExtractFileInfo(additionalText.Path, meta.ns ?? string.Empty, meta.dir ?? string.Empty); var content = additionalText.GetText(cancellationToken)?.ToString() ?? string.Empty; - var path = Path.GetRelativePath(meta.dir ?? string.Empty, additionalText.Path); - - return (Path: Path.Join(meta.ns, path), Content: content); + return (ClassName: className, NamespaceName: namespaceName, IsRazorComponent: isRazorComponent, Content: content); }) .Collect(); @@ -119,52 +117,85 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { var (files, meta) = data; var nativePath = meta.nativePath; - - // 检测nativePath是否有效 + if (string.IsNullOrEmpty(nativePath)) { - Helper.Log(spc, $"Skipping TypeScript processing - invalid nativePath: {nativePath}"); + Helper.Log(spc, $"Skipping BlazorTS processing - invalid nativePath: {nativePath}"); return; } - Helper.Log(spc, $"Processing {files.Length} TypeScript files with nativePath: {nativePath}"); - - var names = files - .Select(item => Generate(spc, item.Path, item.Content)) - .ToList(); + Helper.Log(spc, $"Processing {files.Length} BlazorTS files with nativePath: {nativePath}"); + var entryModuleNames = new List(); + + foreach (var file in files) { - var extensionCode = GenerateExtension(spc, names); - var fileName = $"BlazorTS.SourceGenerator.Extensions.ServiceCollectionExtensions.g.cs"; + var fullName = Generate(spc, file.ClassName, file.NamespaceName, file.IsRazorComponent, file.Content); + if (!file.IsRazorComponent) + { + entryModuleNames.Add(fullName); + } + } + + if (entryModuleNames.Any()) + { + var extensionCode = GenerateExtension(spc, entryModuleNames); + var fileName = "BlazorTS.SourceGenerator.Extensions.ServiceCollectionExtensions.g.cs"; spc.AddSource(fileName, SourceText.From(extensionCode, Encoding.UTF8)); - Helper.Log(spc, $"Generated service collection extension with {names.Count} services"); + Helper.Log(spc, $"Generated service collection extension for {entryModuleNames.Count} entry modules."); } }); } - private static string Generate(SourceProductionContext spc, string path, string content) + private static (string className, string namespaceName, bool isRazorComponent) ExtractFileInfo(string filePath, string rootNamespace, string projectDir) { - var ns = string.Join(".", Path.GetDirectoryName(path)!.Split(Path.DirectorySeparatorChar)); - var className = Path.GetFileName(path)[0..^".ts".Length]; - var methods = MethodExtractor.Extract(content); + var relativePath = Path.GetRelativePath(projectDir, filePath); + var fileName = Path.GetFileName(relativePath); + var directoryPath = Path.GetDirectoryName(relativePath)?.Replace(Path.DirectorySeparatorChar, '.'); + + var namespaceParts = new List { rootNamespace }; + if (!string.IsNullOrEmpty(directoryPath) && directoryPath != ".") + { + namespaceParts.Add(directoryPath); + } + var finalNamespace = string.Join(".", namespaceParts); - Helper.Log(spc, $"Generating wrapper for {className} with {methods.Count()} methods"); + if (fileName.EndsWith(".razor.ts")) + { + var className = fileName[0..^".razor.ts".Length]; + return (className, finalNamespace, isRazorComponent: true); + } + if (fileName.EndsWith(".entry.ts")) { - var fileName = $"{ns}.{className}.ts.module.g.cs"; - var code = GenerateWrapper(ns, className, methods); - spc.AddSource(fileName, SourceText.From(code, Encoding.UTF8)); + var className = fileName[0..^".entry.ts".Length]; + return (className, finalNamespace, isRazorComponent: false); } + + throw new InvalidOperationException($"Unsupported file type: {filePath}"); + } + + private static string Generate(SourceProductionContext spc, string className, string ns, bool isRazorComponent, string content) + { + var methods = MethodExtractor.Extract(content); + Helper.Log(spc, $"Generating wrapper for '{ns}.{className}' with {methods.Count()} methods. IsRazorComponent: {isRazorComponent}"); + + var code = GenerateWrapper(ns, className, methods, isRazorComponent); + var fileHint = isRazorComponent ? $"{ns}.{className}.razor.g.cs" : $"{ns}.{className}.entry.g.cs"; + spc.AddSource(fileHint, SourceText.From(code, Encoding.UTF8)); + return $"{ns}.{className}"; } - private static string GenerateWrapper(string ns, string className, IEnumerable methods) + private static string GenerateWrapper(string ns, string className, IEnumerable methods, bool isRazorComponent) { var fullName = $"{ns}.{className}"; - var code = $@"#nullable enable + if (isRazorComponent) + { + return $@"#nullable enable using BlazorTS; using Microsoft.AspNetCore.Components; @@ -179,13 +210,28 @@ public class TSInterop(ScriptBridge invoker) private readonly string url = invoker.ResolveNS(typeof({fullName})); {methods.Select(GenerateMethod).ToDelimitedString("\n")} - }} }} #nullable restore +"; + } + else + { + return $@"#nullable enable +using BlazorTS; +using Microsoft.AspNetCore.Components; + +namespace {ns}; + +public class {className}(ScriptBridge invoker) +{{ + private readonly string url = invoker.ResolveNS(typeof({fullName})); + {methods.Select(GenerateMethod).ToDelimitedString("\n")} +}} +#nullable restore "; - return code; + } } private static string GenerateMethod(TSFunction function) @@ -228,9 +274,13 @@ private static string ConvertType(string tsType) }; } - private static string GenerateExtension(SourceProductionContext spc, List names) + private static string GenerateExtension(SourceProductionContext spc, List entryModuleNames) { - var code = @$"#nullable enable + var servicesRegistration = entryModuleNames + .Select(name => $" services.AddScoped<{name}>();") + .ToDelimitedString("\n"); + + return $@"#nullable enable using Microsoft.Extensions.DependencyInjection; namespace BlazorTS.SourceGenerator.Extensions @@ -239,16 +289,13 @@ public static class ServiceCollectionExtensions {{ public static IServiceCollection AddBlazorTSScripts(this IServiceCollection services) {{ - {names - .Select(name => $@"services.AddScoped<{name}.TSInterop>();").ToDelimitedString("\n")} +{servicesRegistration} return services; }} }} }} #nullable restore - "; - return code; } diff --git a/BlazorTS.TestPackage/BlazorTS.TestPackage.csproj b/BlazorTS.TestPackage/BlazorTS.TestPackage.csproj index b57c4ef..d3ad9f4 100644 --- a/BlazorTS.TestPackage/BlazorTS.TestPackage.csproj +++ b/BlazorTS.TestPackage/BlazorTS.TestPackage.csproj @@ -26,7 +26,8 @@ - + + diff --git a/BlazorTS.TestPackage/SourceGeneratorTest.cs b/BlazorTS.TestPackage/SourceGeneratorTest.cs index da98b1f..c13033b 100644 --- a/BlazorTS.TestPackage/SourceGeneratorTest.cs +++ b/BlazorTS.TestPackage/SourceGeneratorTest.cs @@ -3,26 +3,39 @@ namespace BlazorTS.TestPackage { - public partial class TestFunctions { } - public class SourceGeneratorTests + public partial class TestComponent { } + public class SourceGeneratorTest { [Fact] - public void Test_Generated_Functions_Exist() + public void Test_Generated_Functions_Exist_For_TestModule() { - // 使用反射测试确保正确生成函数 - 引用实际的生成类 - var testFunctionsType = typeof(TestFunctions); + var assembly = Assembly.GetExecutingAssembly(); + var testModuleType = assembly.GetType("BlazorTS.TestPackage.TestModule"); + Assert.NotNull(testModuleType); - // 获取TSInterop嵌套类 - var tsInteropType = testFunctionsType.GetNestedType("TSInterop"); - Assert.NotNull(tsInteropType); - - // 验证主要函数已生成 - var methods = tsInteropType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + var methods = testModuleType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); var methodNames = methods.Select(m => m.Name).ToArray(); Assert.Contains("hello", methodNames); Assert.Contains("add", methodNames); Assert.Contains("greet", methodNames); + Assert.Contains("arrowFunction", methodNames); + Assert.Contains("asyncArrowFunction", methodNames); + Assert.Contains("functionExpression", methodNames); + Assert.Contains("defaultFunction", methodNames); + } + + [Fact] + public void Test_Generated_Functions_Exist_For_TestComponent() + { + var testComponentType = typeof(TestComponent); + var tsInteropType = testComponentType.GetNestedType("TSInterop"); + Assert.NotNull(tsInteropType); + + var methods = tsInteropType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + var methodNames = methods.Select(m => m.Name).ToArray(); + + Assert.Contains("componentFunc", methodNames); } } } \ No newline at end of file diff --git a/BlazorTS.TestPackage/TestComponent.razor.ts b/BlazorTS.TestPackage/TestComponent.razor.ts new file mode 100644 index 0000000..648fa05 --- /dev/null +++ b/BlazorTS.TestPackage/TestComponent.razor.ts @@ -0,0 +1,3 @@ +export function componentFunc(): string { + return "Hello from component!"; +} \ No newline at end of file diff --git a/BlazorTS.TestPackage/TestFunctions.ts b/BlazorTS.TestPackage/TestModule.entry.ts similarity index 100% rename from BlazorTS.TestPackage/TestFunctions.ts rename to BlazorTS.TestPackage/TestModule.entry.ts diff --git a/README.md b/README.md index e0a7bea..a0ab8b3 100644 --- a/README.md +++ b/README.md @@ -61,50 +61,82 @@ Install-Package Microsoft.TypeScript.MSBuild # (optional) Add the following to your `.csproj` file to ensure TypeScript files are processed correctly: ```xml - + - + + ``` -## 🚀 Quick Start: Binding a TypeScript Module to a Razor Component +## 🚀 File Naming Conventions -The core strength of BlazorTS is its ability to seamlessly "bind" a TypeScript file to a Razor component as its dedicated script module. This is achieved through **file naming conventions** and **partial classes**. +BlazorTS supports two types of TypeScript files to provide a flexible modularization scheme: -### 1. Create the Component and its TypeScript Module +### 1. Razor Component Scripts (`.razor.ts`) -Let's assume we have a `Counter` component. +These files are bound to a specific Razor component for component-level script logic. + +- **Naming Convention**: `MyComponent.razor.ts` must be paired with `MyComponent.razor`. +- **Generated Output**: Automatically generates a `partial class` for `MyComponent` and injects a `TSInterop` instance named `Scripts`. +- **Usage**: Directly call TypeScript functions via the `@inject`ed `Scripts` property within the component. + +**Example:** + +**`Components/Pages/Counter.razor.ts`** +```typescript +// Dedicated module for the Counter.razor component +export function increment(count: number): number { + console.log("Incrementing count from TypeScript module!"); + return count + 1; +} +``` **`Components/Pages/Counter.razor`** ```csharp @page "/counter" @rendermode InteractiveServer -@* Declare this component as a partial class to merge with the generated code *@ @code { - public partial class Counter - { - private int currentCount = 0; + private int currentCount = 0; - private async Task HandleClick() - { - // Directly call the `Scripts` property injected by BlazorTS - currentCount = await Scripts.IncrementCount(currentCount); - } + private async Task HandleClick() + { + // Directly call the `Scripts` property injected by BlazorTS + currentCount = await Scripts.increment(currentCount); } } ``` -**`Components/Pages/Counter.ts`** -Create a TypeScript file with the same name as the Razor component. +### 2. Standalone Feature Modules (`.entry.ts`) + +These files are used to define common TypeScript modules that can be shared across multiple components or services. + +- **Naming Convention**: `my-utils.entry.ts` or `api.entry.ts`. +- **Generated Output**: Generates a standard C# class (e.g., `MyUtils` or `Api`) that needs to be manually registered and injected. +- **Usage**: Register the service in `Program.cs` and use it where needed via dependency injection. + +**Example:** + +**`Services/Formatter.entry.ts`** ```typescript -// This file is the dedicated module for the Counter.razor component -export function IncrementCount(count: number): number { - console.log("Incrementing count from TypeScript module!"); - return count + 1; +export function formatCurrency(amount: number): string { + return `$${amount.toFixed(2)}`; } ``` +**`Program.cs`** +```csharp +// Automatically finds and registers all services generated from .entry.ts files +builder.Services.AddBlazorTSScripts(); +``` + +**`MyComponent.razor`** +```csharp +@inject TestApp.Services.Formatter Formatter + +

@Formatter.formatCurrency(123.45)

+``` + ### 2. Configure `tsconfig.json` To enable Blazor to find the compiled JS file, we need to configure `tsconfig.json` to preserve the directory structure. @@ -116,17 +148,16 @@ To enable Blazor to find the compiled JS file, we need to configure `tsconfig.js "noEmitOnError": true, "removeComments": false, "target": "es2015", - // Use "rootDir" and "outDir" together to preserve the source directory structure in the output directory "rootDir": ".", "outDir": "wwwroot/js" }, "include": [ - // Only include .ts files in the project - "**/*.ts" + "**/*.razor.ts", + "**/*.entry.ts" ] } ``` -> With this configuration, `Components/Pages/Counter.ts` will be compiled to `wwwroot/js/Components/Pages/Counter.js`. +> With this configuration, `Components/Pages/Counter.razor.ts` will be compiled to `wwwroot/js/Components/Pages/Counter.js`. ### 3. Register Services @@ -139,7 +170,7 @@ using Microsoft.Extensions.DependencyInjection; // Register BlazorTS core services (includes default path resolver) builder.Services.AddBlazorTS(); -// Automatically finds and registers all generated TSInterop services +// Automatically finds and registers all services generated from .entry.ts files builder.Services.AddBlazorTSScripts(); ``` @@ -148,7 +179,7 @@ builder.Services.AddBlazorTSScripts(); Now, run your Blazor application. When you click the button: 1. The `HandleClick` method in `Counter.razor` is called. 2. It directly accesses the `Scripts` property, which is automatically generated by BlazorTS. -3. The `Scripts.IncrementCount` call executes the corresponding function in `Counter.ts`. +3. The `Scripts.increment` call executes the corresponding function in `Counter.razor.ts`. Behind the scenes, BlazorTS generates the following `partial class` code for you and merges it with your `Counter.razor.cs`: @@ -164,7 +195,7 @@ public partial class Counter public class TSInterop(ScriptBridge invoker) { // ... implementation details ... - public async Task IncrementCount(double count) + public async Task increment(double count) { // ... calls JS ... } diff --git a/README_CN.md b/README_CN.md index c6407ee..82ecfaf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -61,50 +61,85 @@ Install-Package Microsoft.TypeScript.MSBuild # (可选) 在 `.csproj` 文件中添加以下配置,以确保 TypeScript 文件被正确处理: ```xml - + - + + ``` -## 🚀 快速开始:将 TypeScript 模块绑定到 Razor 组件 +## 🚀 文件命名约定 -BlazorTS 的核心优势在于能够将一个 TypeScript 文件无缝地“绑定”到一个 Razor 组件上,作为其专属的脚本模块。这是通过**文件命名约定**和 **partial class** 实现的。 +BlazorTS 支持两种 TypeScript 文件类型,以提供灵活的模块化方案: -### 1. 创建组件及其 TypeScript 模块 +### 1. Razor 组件脚本 (`.razor.ts`) -假设我们有一个 `Counter` 组件。 +这种文件与特定的 Razor 组件绑定,用于组件级别的脚本逻辑。 + +- **命名约定**: `MyComponent.razor.ts` 必须与 `MyComponent.razor` 配对。 +- **生成结果**: 自动为 `MyComponent` 生成一个 `partial class`,并注入一个名为 `Scripts` 的 `TSInterop` 实例。 +- **使用方式**: 在组件内通过 `@inject` 的 `Scripts` 属性直接调用 TypeScript 函数。 + +**示例:** + +**`Components/Pages/Counter.razor.ts`** +```typescript +// Counter.razor 组件的专属模块 +export function increment(count: number): number { + console.log("Incrementing count from TypeScript module!"); + return count + 1; +} +``` **`Components/Pages/Counter.razor`** ```csharp @page "/counter" @rendermode InteractiveServer -@* 将这个组件声明为 partial class,以便与生成的代码合并 *@ @code { - public partial class Counter + public partial class Counter // 必须是 partial class { private int currentCount = 0; private async Task HandleClick() { // 直接调用由 BlazorTS 注入的 Scripts 属性 - currentCount = await Scripts.IncrementCount(currentCount); + currentCount = await Scripts.increment(currentCount); } } } ``` -**`Components/Pages/Counter.ts`** -创建一个与 Razor 组件同名的 TypeScript 文件。 +### 2. 独立功能模块 (`.entry.ts`) + +这种文件用于定义可被多个组件或服务共享的通用 TypeScript 模块。 + +- **命名约定**: `my-utils.entry.ts` 或 `api.entry.ts`。 +- **生成结果**: 生成一个标准的 C# 类(例如 `MyUtils` 或 `Api`),需要手动注册和注入。 +- **使用方式**: 在 `Program.cs` 中注册服务,然后在需要的地方通过依赖注入使用。 + +**示例:** + +**`Services/Formatter.entry.ts`** ```typescript -// 这个文件是 Counter.razor 组件的专属模块 -export function IncrementCount(count: number): number { - console.log("Incrementing count from TypeScript module!"); - return count + 1; +export function formatCurrency(amount: number): string { + return `$${amount.toFixed(2)}`; } ``` +**`Program.cs`** +```csharp +// 自动查找并注册所有 .entry.ts 生成的服务 +builder.Services.AddBlazorTSScripts(); +``` + +**`MyComponent.razor`** +```csharp +@inject TestApp.Services.Formatter Formatter + +

@Formatter.formatCurrency(123.45)

+``` + ### 2. 配置 `tsconfig.json` 为了让 Blazor 能够找到编译后的 JS 文件,我们需要配置 `tsconfig.json` 以保留目录结构。 @@ -116,17 +151,16 @@ export function IncrementCount(count: number): number { "noEmitOnError": true, "removeComments": false, "target": "es2015", - // "rootDir" 和 "outDir" 配合使用,以在输出目录中保留源目录结构 "rootDir": ".", "outDir": "wwwroot/js" }, "include": [ - // 仅包含项目中的 .ts 文件 - "**/*.ts" + "**/*.razor.ts", + "**/*.entry.ts" ] } ``` -> 这样配置后,`Components/Pages/Counter.ts` 将被编译到 `wwwroot/js/Components/Pages/Counter.js`。 +> 这样配置后,`Components/Pages/Counter.razor.ts` 将被编译到 `wwwroot/js/Components/Pages/Counter.js`。 ### 4. 注册服务 @@ -139,7 +173,7 @@ using Microsoft.Extensions.DependencyInjection; // 注册 BlazorTS 核心服务(包含默认路径解析器) builder.Services.AddBlazorTS(); -// 自动查找并注册所有生成的 TSInterop 服务 +// 自动查找并注册所有 .entry.ts 生成的服务 builder.Services.AddBlazorTSScripts(); ``` @@ -148,7 +182,7 @@ builder.Services.AddBlazorTSScripts(); 现在,运行你的 Blazor 应用。当你点击按钮时: 1. `Counter.razor` 中的 `HandleClick` 方法被调用。 2. 它直接访问 `Scripts` 属性,这是 BlazorTS 自动生成的。 -3. `Scripts.IncrementCount` 调用会执行 `Counter.ts` 中的相应函数。 +3. `Scripts.increment` 调用会执行 `Counter.razor.ts` 中的相应函数。 BlazorTS 在后台为你生成了如下的 `partial class` 代码,并将其与你的 `Counter.razor.cs` 合并: @@ -164,7 +198,7 @@ public partial class Counter public class TSInterop(ScriptBridge invoker) { // ... 实现细节 ... - public async Task IncrementCount(double count) + public async Task increment(double count) { // ... 调用 JS ... } diff --git "a/docs/\346\224\257\346\214\201\347\232\204TypeScript\350\257\255\346\263\225.md" "b/docs/\346\224\257\346\214\201\347\232\204TypeScript\350\257\255\346\263\225.md" index f577763..49fcb21 100644 --- "a/docs/\346\224\257\346\214\201\347\232\204TypeScript\350\257\255\346\263\225.md" +++ "b/docs/\346\224\257\346\214\201\347\232\204TypeScript\350\257\255\346\263\225.md" @@ -173,16 +173,24 @@ export default function defaultFunction() { } 完整的 TypeScript 文件示例: +**`MyComponent.razor.ts`** ```typescript // ✅ 支持的函数都会被生成为 C# 方法 +export function showMessage(message: string): void { + alert(message); +} +``` + +**`utils.entry.ts`** +```typescript export function calculateTotal(items: number[]): number { return items.reduce((sum, item) => sum + item, 0); } export async function saveData(data: any): Promise { try { - await api.save(data); + // await api.save(data); return true; } catch { return false;