diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index 62f3035b..028404e0 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -138,6 +138,7 @@ jobs: ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }} AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }} + TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }} with: inlineScript: | az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }} @@ -157,7 +158,8 @@ jobs: AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \ AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ - AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring + AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ + TryDotNet__Origin=$TRYDOTNET_ORIGIN - name: Logout of Azure CLI if: always() @@ -230,6 +232,7 @@ jobs: ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }} ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }} AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }} + TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }} with: inlineScript: | az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }} @@ -249,7 +252,8 @@ jobs: AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \ AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ - AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring + AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ + TryDotNet__Origin=$TRYDOTNET_ORIGIN - name: Logout of Azure CLI diff --git a/.gitignore b/.gitignore index bc7b55e0..b5450468 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ wwwroot/Chapters EssentialCSharp.Web/wwwroot/Chapters EssentialCSharp.Web/wwwroot/sitemap.xml EssentialCSharp.Web/Chapters/ +EssentialCSharp.Web/ListingSourceCode Utilities/EssentialCSharp.Web/Chapters/ Utilities/EssentialCSharp.Web/wwwroot/sitemap.xml Utilities/EssentialCSharp.Web/wwwroot/Chapters/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 2f6a3e69..7073c1ed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,6 +45,7 @@ + diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index c88a3867..88e40aa3 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -15,6 +15,7 @@ + @@ -35,4 +36,15 @@ + + + + + + + + PreserveNewest + + + diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs new file mode 100644 index 00000000..1944d309 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Http.Json; +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Tests; + +public class ListingSourceCodeControllerTests +{ + [Fact] + public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(1, result.ChapterNumber); + Assert.Equal(1, result.ListingNumber); + Assert.NotEmpty(result.FileExtension); + Assert.NotEmpty(result.Content); + } + + + [Fact] + public async Task GetListing_WithInvalidChapter_Returns404() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetListing_WithInvalidListing_Returns404() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List? results = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(results); + Assert.NotEmpty(results); + + // Verify all results are from chapter 1 + Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); + + // Verify results are ordered by listing number + Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); + + // Verify each listing has required properties + Assert.All(results, r => + { + Assert.NotEmpty(r.FileExtension); + Assert.NotEmpty(r.Content); + }); + } + + [Fact] + public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() + { + // Arrange + using WebApplicationFactory factory = new(); + HttpClient client = factory.CreateClient(); + + // Act + using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List? results = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(results); + Assert.Empty(results); + } +} diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs new file mode 100644 index 00000000..f4a065ea --- /dev/null +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -0,0 +1,147 @@ +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Moq; +using Moq.AutoMock; + +namespace EssentialCSharp.Web.Tests; + +public class ListingSourceCodeServiceTests +{ + [Fact] + public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListing() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + ListingSourceCodeResponse? result = await service.GetListingAsync(1, 1); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.ChapterNumber); + Assert.Equal(1, result.ListingNumber); + Assert.Equal("cs", result.FileExtension); + Assert.NotEmpty(result.Content); + } + + [Fact] + public async Task GetListingAsync_WithInvalidChapter_ReturnsNull() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + ListingSourceCodeResponse? result = await service.GetListingAsync(999, 1); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetListingAsync_WithInvalidListing_ReturnsNull() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + ListingSourceCodeResponse? result = await service.GetListingAsync(1, 999); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtension() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act - Get an XML file (01.02.xml exists in Chapter 1) + ListingSourceCodeResponse? result = await service.GetListingAsync(1, 2); + + // Assert + Assert.NotNull(result); + Assert.Equal("xml", result.FileExtension); + } + + [Fact] + public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + IReadOnlyList results = await service.GetListingsByChapterAsync(1); + + // Assert + Assert.NotEmpty(results); + Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); + Assert.All(results, r => Assert.NotEmpty(r.Content)); + Assert.All(results, r => Assert.NotEmpty(r.FileExtension)); + + // Verify results are ordered + Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); + } + + [Fact] + public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_ExcludesNonListingFiles() + { + // Arrange - Chapter 10 has Employee.cs which doesn't match the pattern + ListingSourceCodeService service = CreateService(); + + // Act + IReadOnlyList results = await service.GetListingsByChapterAsync(10); + + // Assert + Assert.NotEmpty(results); + + // Ensure all results match the {CC}.{LL}.{ext} pattern + Assert.All(results, r => + { + Assert.Equal(10, r.ChapterNumber); + Assert.InRange(r.ListingNumber, 1, 99); + }); + } + + [Fact] + public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList() + { + // Arrange + ListingSourceCodeService service = CreateService(); + + // Act + IReadOnlyList results = await service.GetListingsByChapterAsync(999); + + // Assert + Assert.Empty(results); + } + + private static ListingSourceCodeService CreateService() + { + DirectoryInfo testDataRoot = GetTestDataPath(); + + AutoMocker mocker = new(); + Mock mockWebHostEnvironment = mocker.GetMock(); + mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot.FullName); + mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot.FullName)); + + return mocker.CreateInstance(); + } + + private static DirectoryInfo GetTestDataPath() + { + string baseDirectory = AppContext.BaseDirectory; + string testDataPath = Path.Combine(baseDirectory, "TestData"); + + DirectoryInfo testDataDirectory = new(testDataPath); + + if (!testDataDirectory.Exists) + { + throw new InvalidOperationException($"TestData directory not found at: {testDataDirectory.FullName}"); + } + + return testDataDirectory; + } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs new file mode 100644 index 00000000..1d0e12f4 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.01.cs @@ -0,0 +1,10 @@ +// Test listing 01.01 +using System; + +class Program +{ + static void Main() + { + Console.WriteLine("Hello, World!"); + } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml new file mode 100644 index 00000000..a0ba1622 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.02.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs new file mode 100644 index 00000000..b03ff26b --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter01/01.03.cs @@ -0,0 +1,8 @@ +// Test listing 01.03 +namespace TestNamespace +{ + public class TestClass + { + public int TestProperty { get; set; } + } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs new file mode 100644 index 00000000..9d73d9cd --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.01.cs @@ -0,0 +1,6 @@ +// Test listing 10.01 +public class Employee +{ + public string Name { get; set; } + public int Id { get; set; } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs new file mode 100644 index 00000000..1ddb62b1 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/10.02.cs @@ -0,0 +1,5 @@ +// Test listing 10.02 +public class Manager : Employee +{ + public List DirectReports { get; set; } +} diff --git a/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs new file mode 100644 index 00000000..6cdb6753 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/ListingSourceCode/src/Chapter10/Employee.cs @@ -0,0 +1,6 @@ +// This file should NOT be picked up by the listing pattern +// It doesn't match {CC}.{LL}.{ext} format +public class EmployeeHelper +{ + public static void DoSomething() { } +} diff --git a/EssentialCSharp.Web.Tests/TestData/README.md b/EssentialCSharp.Web.Tests/TestData/README.md new file mode 100644 index 00000000..80baafc5 --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestData/README.md @@ -0,0 +1,46 @@ +# Test Data Directory + +This directory contains test fixtures used by the test suite to ensure tests are isolated and independent of production data. + +## Structure + +``` +TestData/ +└── ListingSourceCode/ + └── src/ + ├── Chapter01/ + │ ├── 01.01.cs + │ ├── 01.02.xml + │ └── 01.03.cs + └── Chapter10/ + ├── 10.01.cs + ├── 10.02.cs + └── Employee.cs # Non-listing file to test filtering +``` + +## Purpose + +Test files in this directory: +- Provide controlled, predictable test data +- Isolate tests from changes to production listing files +- Enable testing of edge cases and error conditions +- Are minimal in size for fast test execution +- Are automatically copied to the output directory during build + +## File Naming Convention + +Listing files follow the pattern: `{CC}.{LL}.{ext}` +- `CC`: Two-digit chapter number (e.g., "01", "10") +- `LL`: Two-digit listing number (e.g., "01", "15") +- `ext`: File extension (e.g., "cs", "xml") + +Files not matching this pattern (like `Employee.cs`) are used to test that the service correctly excludes non-listing files. + +## Build Configuration + +These files are: +- Excluded from compilation via `` +- Included as content via `` +- Copied to output directory with `CopyToOutputDirectory.PreserveNewest` + +See [EssentialCSharp.Web.Tests.csproj](../EssentialCSharp.Web.Tests.csproj) for the full build configuration. diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index b5b8a8f0..f711629b 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,11 +1,15 @@ -using System.Data.Common; +using System.Data.Common; using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.FileProviders; +using Moq.AutoMock; namespace EssentialCSharp.Web.Tests; @@ -51,6 +55,19 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) EssentialCSharpWebContext db = scopedServices.GetRequiredService(); db.Database.EnsureCreated(); + + // Replace IListingSourceCodeService with one backed by TestData + services.RemoveAll(); + + string testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); + var fileProvider = new PhysicalFileProvider(testDataPath); + services.AddSingleton(sp => + { + var mocker = new AutoMocker(); + mocker.Setup(m => m.ContentRootPath).Returns(testDataPath); + mocker.Setup(m => m.ContentRootFileProvider).Returns(fileProvider); + return mocker.CreateInstance(); + }); }); } diff --git a/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs b/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs new file mode 100644 index 00000000..d7d6c644 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ListingSourceCodeController.cs @@ -0,0 +1,42 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ListingSourceCodeController : ControllerBase +{ + private readonly IListingSourceCodeService _ListingSourceCodeService; + private readonly ILogger _Logger; + + public ListingSourceCodeController( + IListingSourceCodeService listingSourceCodeService, + ILogger logger) + { + _ListingSourceCodeService = listingSourceCodeService; + _Logger = logger; + } + + [HttpGet("chapter/{chapter}/listing/{listing}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] + public async Task GetListing(int chapter, int listing) + { + var result = await _ListingSourceCodeService.GetListingAsync(chapter, listing); + + if (result == null) + { + return NotFound(new { error = $"Listing {chapter}.{listing} not found." }); + } + + return Ok(result); + } + + [HttpGet("chapter/{chapter}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] + public async Task GetListingsByChapter(int chapter) + { + var results = await _ListingSourceCodeService.GetListingsByChapterAsync(chapter); + return Ok(results); + } +} diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index c560a13b..519a7c14 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -7,6 +7,14 @@ --> $(NoWarn);CA1873 + + + + + + + + diff --git a/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs new file mode 100644 index 00000000..ac4c3a7e --- /dev/null +++ b/EssentialCSharp.Web/Models/ListingSourceCodeResponse.cs @@ -0,0 +1,7 @@ +namespace EssentialCSharp.Web.Models; + +public record class ListingSourceCodeResponse( + int ChapterNumber, + int ListingNumber, + string FileExtension = "", + string Content = ""); \ No newline at end of file diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index faa7cef2..5df9f38c 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -152,6 +152,7 @@ private static void Main(string[] args) builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); diff --git a/EssentialCSharp.Web/Services/IListingSourceCodeService.cs b/EssentialCSharp.Web/Services/IListingSourceCodeService.cs new file mode 100644 index 00000000..28aa04a0 --- /dev/null +++ b/EssentialCSharp.Web/Services/IListingSourceCodeService.cs @@ -0,0 +1,9 @@ +using EssentialCSharp.Web.Models; + +namespace EssentialCSharp.Web.Services; + +public interface IListingSourceCodeService +{ + Task GetListingAsync(int chapterNumber, int listingNumber); + Task> GetListingsByChapterAsync(int chapterNumber); +} diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs new file mode 100644 index 00000000..e81c14e9 --- /dev/null +++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using EssentialCSharp.Web.Models; +using Microsoft.Extensions.FileProviders; + +namespace EssentialCSharp.Web.Services; + +public partial class ListingSourceCodeService : IListingSourceCodeService +{ + private readonly IWebHostEnvironment _WebHostEnvironment; + private readonly ILogger _Logger; + + public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger logger) + { + _WebHostEnvironment = webHostEnvironment; + _Logger = logger; + } + + public async Task GetListingAsync(int chapterNumber, int listingNumber) + { + string chapterDirectory = $"ListingSourceCode/src/Chapter{chapterNumber:D2}"; + IFileProvider fileProvider = _WebHostEnvironment.ContentRootFileProvider; + IDirectoryContents directoryContents = fileProvider.GetDirectoryContents(chapterDirectory); + + if (!directoryContents.Exists) + { + _Logger.LogWarning("Chapter directory not found: {ChapterDirectory}", chapterDirectory); + return null; + } + + string pattern = $"{chapterNumber:D2}.{listingNumber:D2}.*"; + IFileInfo? matchingFile = directoryContents + .Where(f => !f.IsDirectory) + .FirstOrDefault(f => IsMatch(f.Name, pattern)); + + if (matchingFile == null) + { + _Logger.LogWarning("Listing file not found: {Pattern} in {ChapterDirectory}", pattern, chapterDirectory); + return null; + } + + string content = await ReadFileContentAsync(matchingFile); + string extension = Path.GetExtension(matchingFile.Name).TrimStart('.'); + + return new ListingSourceCodeResponse( + chapterNumber, + listingNumber, + extension, + content + ); + } + + public async Task> GetListingsByChapterAsync(int chapterNumber) + { + string chapterDirectory = $"ListingSourceCode/src/Chapter{chapterNumber:D2}"; + IFileProvider fileProvider = _WebHostEnvironment.ContentRootFileProvider; + IDirectoryContents directoryContents = fileProvider.GetDirectoryContents(chapterDirectory); + + if (!directoryContents.Exists) + { + _Logger.LogWarning("Chapter directory not found: {ChapterDirectory}", chapterDirectory); + return Array.Empty(); + } + + // Regex to match files like "01.01.cs" or "23.15.xml" + Regex listingFileRegex = ListingFilePattern(); + + var matchedFiles = directoryContents + .Where(f => !f.IsDirectory) + .Select(f => new { File = f, Match = listingFileRegex.Match(f.Name) }) + .Where(x => x.Match.Success) + .Select(x => new + { + x.File, + ChapterNumber = int.Parse(x.Match.Groups[1].Value, CultureInfo.InvariantCulture), + ListingNumber = int.Parse(x.Match.Groups[2].Value, CultureInfo.InvariantCulture), + Extension = x.Match.Groups[3].Value + }) + .Where(x => x.ChapterNumber == chapterNumber); + + var results = new List(); + + foreach (var item in matchedFiles) + { + string content = await ReadFileContentAsync(item.File); + + results.Add(new ListingSourceCodeResponse( + item.ChapterNumber, + item.ListingNumber, + item.Extension, + content + )); + } + + return results.OrderBy(r => r.ListingNumber).ToList(); + } + + private static async Task ReadFileContentAsync(IFileInfo file) + { + using Stream stream = file.CreateReadStream(); + using StreamReader reader = new(stream); + return await reader.ReadToEndAsync(); + } + + private static bool IsMatch(string fileName, string pattern) + { + // Convert glob-like pattern to regex (simple version for our use case) + string regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$"; + return Regex.IsMatch(fileName, regexPattern); + } + + [GeneratedRegex(@"^(\d{2})\.(\d{2})\.(\w+)$")] + private static partial Regex ListingFilePattern(); +} diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3c405fc3..60cf43fc 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -6,9 +6,11 @@ @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data @using Microsoft.Extensions.Options +@using Microsoft.Extensions.Configuration @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager @inject IOptions CaptchaOptions +@inject IConfiguration Configuration @using Microsoft.AspNetCore.Components @{ var prodMap = new ImportMapDefinition( @@ -57,6 +59,7 @@ + @*Font Family*@ @@ -468,6 +471,123 @@ } + + +
@* Recursive vue component template for rendering the table of contents. *@ diff --git a/EssentialCSharp.Web/appsettings.json b/EssentialCSharp.Web/appsettings.json index bcfe526a..1944d305 100644 --- a/EssentialCSharp.Web/appsettings.json +++ b/EssentialCSharp.Web/appsettings.json @@ -27,5 +27,8 @@ }, "SiteSettings": { "BaseUrl": "https://essentialcsharp.com" + }, + "TryDotNet": { + "Origin": "" } } \ No newline at end of file diff --git a/EssentialCSharp.Web/wwwroot/css/code-runner.css b/EssentialCSharp.Web/wwwroot/css/code-runner.css new file mode 100644 index 00000000..0354deb3 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/css/code-runner.css @@ -0,0 +1,686 @@ +/* Code Runner Panel - Interactive Code Execution Styles */ + +/* CSS Custom Properties */ +:root { + /* Primary palette */ + --code-runner-primary: #388e3c; + --code-runner-primary-mid: #2e7d32; + --code-runner-primary-dark: #1b5e20; + --code-runner-primary-darker: #0d3d0f; + --code-runner-primary-shadow: rgba(56, 142, 60, 0.3); + --code-runner-primary-shadow-strong: rgba(56, 142, 60, 0.4); + + /* Gradients */ + --code-runner-gradient: linear-gradient(135deg, var(--code-runner-primary) 0%, var(--code-runner-primary-mid) 50%, var(--code-runner-primary-dark) 100%); + --code-runner-gradient-hover: linear-gradient(135deg, var(--code-runner-primary-mid) 0%, var(--code-runner-primary-dark) 50%, var(--code-runner-primary-darker) 100%); + + /* Surface & text */ + --code-runner-surface: #ffffff; + --code-runner-surface-alt: #fafafa; + --code-runner-surface-alt2: #f5f5f5; + --code-runner-surface-header: #f8f9fa; + --code-runner-surface-header-end: #f1f3f4; + --code-runner-text: rgba(0, 0, 0, 0.87); + --code-runner-text-secondary: rgba(0, 0, 0, 0.6); + --code-runner-text-tertiary: rgba(0, 0, 0, 0.54); + --code-runner-border: rgba(0, 0, 0, 0.08); + --code-runner-border-light: rgba(0, 0, 0, 0.06); + --code-runner-hover-bg: rgba(0, 0, 0, 0.04); + --code-runner-hover-bg-strong: rgba(0, 0, 0, 0.08); + + /* Console / output */ + --code-runner-console-bg: #1e1e1e; + --code-runner-console-header-bg: #252526; + --code-runner-console-border: #333333; + --code-runner-console-text: #d4d4d4; + --code-runner-console-text-secondary: rgba(255, 255, 255, 0.7); + --code-runner-console-text-tertiary: rgba(255, 255, 255, 0.5); + --code-runner-console-scroll-thumb: rgba(255, 255, 255, 0.15); + --code-runner-console-scroll-thumb-hover: rgba(255, 255, 255, 0.25); + + /* Error */ + --code-runner-error-color: #f44336; + --code-runner-error-dark: #d32f2f; + --code-runner-error-darker: #c62828; + --code-runner-error-bg-start: #ffebee; + --code-runner-error-bg-end: #ffcdd2; + --code-runner-error-output: #f48771; + --code-runner-error-close-hover-bg: rgba(244, 67, 54, 0.08); + + /* Focus */ + --code-runner-focus-ring: rgba(56, 142, 60, 0.3); + --code-runner-focus-ring-light: rgba(255, 255, 255, 0.3); + + /* Layout */ + --code-runner-panel-width: 600px; + + /* Z-index layers — relative to site overlay layer */ + --code-runner-z-overlay: 1100; + --code-runner-z-panel: 1101; + + /* Easing */ + --code-runner-ease: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Box-sizing scope */ +.code-runner-panel *, +.code-runner-panel *::before, +.code-runner-panel *::after { + box-sizing: border-box; +} + +/* Run button (inline, placed next to listings) */ +.code-runner-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 14px; + margin-inline-start: 8px; + background: var(--code-runner-gradient); + color: white; + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); + flex-shrink: 0; + box-shadow: 0 1px 3px var(--code-runner-primary-shadow); + position: relative; + overflow: hidden; +} + +.code-runner-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s ease; +} + +.code-runner-btn:hover::before { + left: 100%; +} + +.code-runner-btn:hover { + background: var(--code-runner-gradient-hover); + box-shadow: 0 3px 8px var(--code-runner-primary-shadow-strong); +} + +.code-runner-btn:active { + transform: translateY(0); + box-shadow: 0 1px 2px var(--code-runner-primary-shadow); +} + +.code-runner-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.code-runner-btn i { + font-size: 15px; +} + +/* Slide-out panel overlay */ +.code-runner-overlay { + position: fixed; + inset: 0; + /* Opaque fallback for browsers without backdrop-filter support */ + background: rgba(0, 0, 0, 0.6); + z-index: var(--code-runner-z-overlay); + opacity: 0; + visibility: hidden; + /* visibility uses a step delay: hide after fade-out completes */ + transition: opacity 250ms var(--code-runner-ease), + visibility 0s linear 250ms; +} + +@supports (backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px)) { + .code-runner-overlay { + background: rgba(0, 0, 0, 0.4); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + } +} + +.code-runner-overlay.active { + opacity: 1; + visibility: visible; + /* visibility flips immediately on show (0s delay) */ + transition: opacity 250ms var(--code-runner-ease), + visibility 0s linear 0s; +} + +/* Slide-out panel */ +.code-runner-panel { + position: fixed; + top: 0; + inset-inline-end: 0; + bottom: 0; + width: var(--code-runner-panel-width); + max-width: 100vw; + background: var(--code-runner-surface); + box-shadow: + -8px 0 30px rgba(0, 0, 0, 0.12), + -2px 0 8px rgba(0, 0, 0, 0.08); + z-index: var(--code-runner-z-panel); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 350ms var(--code-runner-ease); + border-radius: 16px 0 0 16px; + overflow: hidden; + will-change: transform; + contain: layout style; +} + +.code-runner-overlay.active .code-runner-panel { + transform: translateX(0); +} + +/* Panel header — light gradient, matches chat */ +.code-runner-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 20px; + padding-inline: 24px; + background: linear-gradient(135deg, var(--code-runner-surface-header) 0%, var(--code-runner-surface-header-end) 100%); + border-bottom: 1px solid var(--code-runner-border); + flex-shrink: 0; + position: relative; +} + +.code-runner-header::after { + content: ''; + position: absolute; + bottom: 0; + inset-inline: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, rgba(56, 142, 60, 0.25) 50%, transparent 100%); +} + +.code-runner-title { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--code-runner-text); + letter-spacing: 0.15px; +} + +.code-runner-title i { + font-size: 22px; + color: var(--code-runner-primary); +} + +.code-runner-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--code-runner-hover-bg); + border: none; + border-radius: 50%; + color: var(--code-runner-text-tertiary); + cursor: pointer; + transition: all 200ms var(--code-runner-ease); +} + +.code-runner-close-btn:hover { + background: var(--code-runner-error-close-hover-bg); + color: var(--code-runner-error-color); + transform: rotate(90deg); +} + +.code-runner-close-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-close-btn i { + font-size: 20px; +} + +/* Editor container — top 60% of panel */ +.code-runner-editor-container { + /* 6 : 4 ratio with output container (60% / 40%) */ + flex: 6; + min-height: 0; + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--code-runner-border); + position: relative; +} + +.code-runner-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 10px; + padding-inline: 20px; + background: linear-gradient(135deg, var(--code-runner-surface-alt) 0%, var(--code-runner-surface-alt2) 100%); + border-bottom: 1px solid var(--code-runner-border-light); +} + +.code-runner-editor-header h4 { + margin: 0; + font-size: 13px; + font-weight: 500; + color: var(--code-runner-text-secondary); + letter-spacing: 0.4px; + text-transform: uppercase; +} + +.code-runner-buttons { + display: flex; + gap: 8px; +} + +.code-runner-run-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 20px; + background: var(--code-runner-gradient); + color: white; + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); + box-shadow: 0 2px 6px var(--code-runner-primary-shadow); + position: relative; + overflow: hidden; +} + +.code-runner-run-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s ease; +} + +.code-runner-run-btn:hover::before { + left: 100%; +} + +.code-runner-run-btn:hover { + background: var(--code-runner-gradient-hover); + box-shadow: 0 4px 12px var(--code-runner-primary-shadow-strong); + transform: translateY(-1px); +} + +.code-runner-run-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px var(--code-runner-primary-shadow); +} + +.code-runner-run-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-run-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.code-runner-clear-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 7px 14px; + background: var(--code-runner-hover-bg); + color: var(--code-runner-text-secondary); + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); +} + +.code-runner-clear-btn:hover { + background: var(--code-runner-hover-bg-strong); + color: var(--code-runner-text); +} + +.code-runner-clear-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +/* Monaco editor iframe */ +.code-runner-editor { + flex: 1; + width: 100%; + min-height: 0; + border: none; + display: block; +} + +/* Loading state */ +.code-runner-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--code-runner-text-secondary); + background: var(--code-runner-surface); + z-index: 10; +} + +.code-runner-spinner { + display: inline-block; + width: 36px; + height: 36px; + border: 3px solid var(--code-runner-border); + border-radius: 50%; + border-top-color: var(--code-runner-primary); + animation: code-runner-spin 0.8s ease-in-out infinite; +} + +@keyframes code-runner-spin { + to { + transform: rotate(360deg); + } +} + +.code-runner-loading-text { + margin-top: 16px; + font-size: 14px; + font-weight: 400; + letter-spacing: 0.25px; +} + +/* Error state */ +.code-runner-error { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + background: linear-gradient(135deg, var(--code-runner-error-bg-start) 0%, var(--code-runner-error-bg-end) 100%); + text-align: center; + z-index: 10; +} + +.code-runner-error > i { + font-size: 48px; + margin-bottom: 16px; + color: var(--code-runner-error-dark); + opacity: 0.8; +} + +.code-runner-error p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--code-runner-error-darker); + max-width: 320px; +} + +.code-runner-retry-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 20px; + padding: 8px 22px; + background: var(--code-runner-gradient); + color: white; + border: none; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); + box-shadow: 0 2px 6px var(--code-runner-primary-shadow); +} + +.code-runner-retry-btn:hover { + background: var(--code-runner-gradient-hover); + box-shadow: 0 4px 12px var(--code-runner-primary-shadow-strong); + transform: translateY(-1px); +} + +.code-runner-retry-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px var(--code-runner-primary-shadow); +} + +.code-runner-retry-btn:focus-visible { + outline: 3px solid var(--code-runner-focus-ring); + outline-offset: 2px; +} + +.code-runner-retry-btn i { + font-size: 16px; + color: white; +} + +/* Output console — dark theme, bottom 40% of panel */ +.code-runner-output-container { + /* 4 : 6 ratio with editor container (40% / 60%) */ + flex: 4; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--code-runner-console-bg); +} + +.code-runner-output-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 10px; + padding-inline: 20px; + background: var(--code-runner-console-header-bg); + border-bottom: 1px solid var(--code-runner-console-border); +} + +.code-runner-output-header h4 { + margin: 0; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +/* Scoped specificity: the dark console header forces white text + without needing !important */ +.code-runner-output-container .code-runner-output-header h4 { + color: var(--code-runner-console-text-secondary); +} + +.code-runner-output-header h4 i { + font-size: 15px; + color: var(--code-runner-console-text-tertiary); +} + +.code-runner-clear-output-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: transparent; + color: var(--code-runner-console-text-tertiary); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + font-size: 12px; + cursor: pointer; + transition: all 200ms var(--code-runner-ease); +} + +.code-runner-clear-output-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); + border-color: var(--code-runner-focus-ring-light); +} + +.code-runner-clear-output-btn:focus-visible { + outline: 2px solid var(--code-runner-focus-ring-light); + outline-offset: 2px; +} + +.code-runner-output { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-block: 16px; + padding-inline: 20px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.6; + color: var(--code-runner-console-text); + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + /* Firefox scrollbar */ + scrollbar-width: thin; + scrollbar-color: var(--code-runner-console-scroll-thumb) transparent; +} + +.code-runner-output.error { + color: var(--code-runner-error-output); +} + +/* Chromium / Safari scrollbar */ +.code-runner-output::-webkit-scrollbar { + width: 8px; +} + +.code-runner-output::-webkit-scrollbar-track { + background: transparent; +} + +.code-runner-output::-webkit-scrollbar-thumb { + background: var(--code-runner-console-scroll-thumb); + border-radius: 4px; +} + +.code-runner-output::-webkit-scrollbar-thumb:hover { + background: var(--code-runner-console-scroll-thumb-hover); +} + +/* Responsive */ +@media (max-width: 768px) { + .code-runner-panel { + width: 100%; + border-radius: 0; + } + + .code-runner-header { + padding: 16px; + } + + .code-runner-title { + font-size: 15px; + } + + .code-runner-editor-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding-block: 10px; + padding-inline: 16px; + } + + .code-runner-buttons { + width: 100%; + justify-content: flex-end; + } + + .code-runner-output-header { + padding-block: 10px; + padding-inline: 16px; + } + + .code-runner-output { + padding-block: 12px; + padding-inline: 16px; + } +} + +/* High contrast mode */ +@media (prefers-contrast: high) { + .code-runner-panel { + border-inline-start: 2px solid #000; + } + + .code-runner-header { + border-bottom: 2px solid #000; + } + + .code-runner-run-btn { + border: 2px solid white; + } + + .code-runner-btn { + border: 2px solid white; + } + + .code-runner-clear-output-btn { + border-color: var(--code-runner-console-text-tertiary); + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .code-runner-overlay, + .code-runner-panel, + .code-runner-btn, + .code-runner-run-btn, + .code-runner-clear-btn, + .code-runner-close-btn, + .code-runner-clear-output-btn, + .code-runner-retry-btn { + transition: none; + } + + .code-runner-btn::before, + .code-runner-run-btn::before { + display: none; + } + + .code-runner-spinner { + animation: none; + opacity: 0.6; + } +} + +/* Print — hide everything */ +@media print { + .code-runner-overlay, + .code-runner-btn { + display: none; + } +} diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index 2f94169c..7eefaa29 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -10,6 +10,7 @@ import { import { createVuetify } from "vuetify"; import { useWindowSize } from "vue-window-size"; import { useChatWidget } from "./chat-module.js"; +import { useTryDotNet } from "./trydotnet-module.js"; /** * @typedef {Object} TocItem @@ -333,6 +334,9 @@ const app = createApp({ // Initialize chat functionality const chatWidget = useChatWidget(); + // Initialize TryDotNet code runner functionality + const tryDotNet = useTryDotNet(); + return { previousPageUrl, nextPageUrl, @@ -366,7 +370,8 @@ const app = createApp({ enableTocFilter, isContentPage, - ...chatWidget + ...chatWidget, + ...tryDotNet }; }, }); diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js new file mode 100644 index 00000000..f7cd3689 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -0,0 +1,620 @@ +// TryDotNet Module - Vue.js composable for interactive code execution +import { ref, nextTick, onMounted, onUnmounted } from 'vue'; + +// Timeout durations (ms) +const HEALTH_CHECK_TIMEOUT = 5000; +const SESSION_CREATION_TIMEOUT = 20000; +const RUN_TIMEOUT = 30000; + +// User-friendly error messages +const ERROR_MESSAGES = { + serviceUnavailable: 'The code execution service is currently unavailable. Please try again later.', + serviceNotConfigured: 'Interactive code execution is not available at this time.', + sessionTimeout: 'The code editor took too long to load. The service may be temporarily unavailable.', + runTimeout: 'Code execution timed out. The service may be temporarily unavailable.', + editorNotFound: 'Could not initialize the code editor. Please try again.', + sessionNotInitialized: 'The code editor session is not ready. Please try reopening the code runner.', + fetchFailed: 'Could not load the listing source code. Please try again.', +}; + +/** + * Races a promise against a timeout. Rejects with the given message if the + * timeout fires first. + * @param {Promise} promise - The promise to race + * @param {number} ms - Timeout in milliseconds + * @param {string} timeoutMsg - Message for the timeout error + * @returns {Promise} + */ +function withTimeout(promise, ms, timeoutMsg) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMsg)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * Checks whether the TryDotNet origin is configured and non-empty. + * @returns {boolean} + */ +function isTryDotNetConfigured() { + const origin = window.TRYDOTNET_ORIGIN; + return typeof origin === 'string' && origin.trim().length > 0; +} + +/** + * Creates scaffolding for user code to run in the TryDotNet environment. + * @param {string} userCode - The user's C# code to wrap + * @returns {string} Scaffolded code with proper using statements and Main method + */ +function createScaffolding(userCode) { + return `using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Program +{ + class Program + { + static void Main(string[] args) + { + #region controller +${userCode} + #endregion + } + } +}`; +} + +/** + * Dynamically loads a script and returns a promise that resolves when loaded. + * @param {string} url - URL of the script to load + * @param {string} globalName - Name of the global variable the script creates + * @param {number} timeLimit - Maximum time to wait for script load + * @returns {Promise} Promise resolving to the global object + */ +function loadLibrary(url, globalName, timeLimit = 15000) { + return new Promise((resolve, reject) => { + // Check if already loaded + if (globalName && window[globalName]) { + resolve(window[globalName]); + return; + } + + const timeout = setTimeout(() => { + reject(new Error(`${url} load timeout`)); + }, timeLimit); + + const script = document.createElement('script'); + script.src = url; + script.async = true; + script.defer = true; + script.crossOrigin = 'anonymous'; + + script.onload = () => { + clearTimeout(timeout); + if (globalName && !window[globalName]) { + reject(new Error(`${url} loaded but ${globalName} is undefined`)); + } else { + resolve(window[globalName]); + } + }; + + script.onerror = () => { + clearTimeout(timeout); + reject(new Error(`Failed to load ${url}`)); + }; + + document.head.appendChild(script); + }); +} + +/** + * Vue composable for TryDotNet code execution functionality. + * @returns {Object} Composable state and methods + */ +export function useTryDotNet() { + // State + const isCodeRunnerOpen = ref(false); + const codeRunnerLoading = ref(false); + const codeRunnerError = ref(null); + const codeRunnerOutput = ref(''); + const codeRunnerOutputError = ref(false); + const currentListingInfo = ref(null); + const isRunning = ref(false); + const isLibraryLoaded = ref(false); + + // Internal state (not exposed) + let trydotnet = null; + let session = null; + let editorElement = null; + let currentLoadedListing = null; // Track which listing is currently loaded + + /** + * Gets the TryDotNet origin URL from config. + * @returns {string} The TryDotNet service origin URL + */ + function getTryDotNetOrigin() { + return window.TRYDOTNET_ORIGIN; + } + + /** + * Performs a lightweight reachability check against the TryDotNet origin. + * Rejects with a user-friendly message when the service is unreachable. + * @returns {Promise} + */ + async function checkServiceHealth() { + const origin = getTryDotNetOrigin(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT); + + try { + // Check the actual script endpoint rather than the bare origin, + // which may not have a handler and would return 404. + const res = await fetch(`${origin}/api/trydotnet.min.js`, { + method: 'HEAD', + mode: 'no-cors', + signal: controller.signal, + }); + // mode: 'no-cors' gives an opaque response (status 0), which is fine + // — we only care that the network request didn't fail. + } catch { + throw new Error(ERROR_MESSAGES.serviceUnavailable); + } finally { + clearTimeout(timer); + } + } + + /** + * Loads the TryDotNet library from the service. + * Performs a health check first to fail fast. + * @returns {Promise} + */ + async function loadTryDotNetLibrary() { + if (isLibraryLoaded.value && trydotnet) { + return; + } + + if (!isTryDotNetConfigured()) { + throw new Error(ERROR_MESSAGES.serviceNotConfigured); + } + + // Fail fast if the service is unreachable + await checkServiceHealth(); + + const origin = getTryDotNetOrigin(); + const trydotnetUrl = `${origin}/api/trydotnet.min.js`; + + try { + trydotnet = await loadLibrary(trydotnetUrl, 'trydotnet', 15000); + if (!trydotnet) { + throw new Error(ERROR_MESSAGES.serviceUnavailable); + } + isLibraryLoaded.value = true; + } catch (error) { + console.error('Failed to load TryDotNet library:', error); + throw new Error(ERROR_MESSAGES.serviceUnavailable); + } + } + + /** + * Creates a TryDotNet session with the editor iframe and initial code. + * @param {HTMLElement} editorEl - The iframe element for the Monaco editor + * @param {string} userCode - The C# code to display in the editor + * @returns {Promise} + */ + async function createSession(editorEl, userCode) { + if (!trydotnet) { + throw new Error('TryDotNet library not loaded'); + } + + editorElement = editorEl; + + const hostOrigin = window.location.origin; + window.postMessage({ type: 'HostEditorReady', editorId: '0' }, hostOrigin); + + const fileName = 'Program.cs'; + const isComplete = isCompleteProgram(userCode); + const fileContent = isComplete ? userCode : createScaffolding(userCode); + const files = [{ name: fileName, content: fileContent }]; + const project = { package: 'console', files: files }; + const document = isComplete + ? { fileName: fileName } + : { fileName: fileName, region: 'controller' }; + + const configuration = { + hostOrigin: hostOrigin, + trydotnetOrigin: getTryDotNetOrigin(), + enableLogging: false + }; + + session = await withTimeout( + trydotnet.createSessionWithProjectAndOpenDocument( + configuration, + [editorElement], + window, + project, + document + ), + SESSION_CREATION_TIMEOUT, + ERROR_MESSAGES.sessionTimeout + ); + + // Subscribe to output events + session.subscribeToOutputEvents((event) => { + handleOutput(event); + }); + } + + /** + * Sets code in the Monaco editor. + * @param {string} userCode - The C# code to display in the editor + * @returns {Promise} + */ + async function setCode(userCode) { + if (!session || !trydotnet) { + throw new Error('Session not initialized'); + } + + const isComplete = isCompleteProgram(userCode); + const fileContent = isComplete ? userCode : createScaffolding(userCode); + const fileName = 'Program.cs'; + const files = [{ name: fileName, content: fileContent }]; + const project = await trydotnet.createProject({ + packageName: 'console', + files: files + }); + + await session.openProject(project); + + const defaultEditor = session.getTextEditor(); + const documentOptions = { + fileName: fileName, + editorId: defaultEditor.id() + }; + + // Only add region for scaffolded code + if (!isComplete) { + documentOptions.region = 'controller'; + } + + await session.openDocument(documentOptions); + } + + /** + * Runs the code currently in the editor. + * @returns {Promise} + */ + async function runCode() { + if (!session) { + codeRunnerOutput.value = ERROR_MESSAGES.sessionNotInitialized; + codeRunnerOutputError.value = true; + return; + } + + codeRunnerOutput.value = 'Running...'; + codeRunnerOutputError.value = false; + isRunning.value = true; + + try { + await withTimeout(session.run(), RUN_TIMEOUT, ERROR_MESSAGES.runTimeout); + } catch (error) { + codeRunnerOutput.value = error.message; + codeRunnerOutputError.value = true; + } finally { + isRunning.value = false; + } + } + + /** + * Clears the editor content. + */ + function clearEditor() { + if (!session) return; + + const textEditor = session.getTextEditor(); + if (textEditor) { + textEditor.setContent(''); + codeRunnerOutput.value = 'Editor cleared.'; + codeRunnerOutputError.value = false; + } + } + + /** + * Handles output events from the TryDotNet session. + * @param {Object} event - Output event from TryDotNet + */ + function handleOutput(event) { + if (event.exception) { + codeRunnerOutput.value = event.exception.join('\n'); + codeRunnerOutputError.value = true; + } else if (event.diagnostics && event.diagnostics.length > 0) { + // Handle compilation errors/warnings + const diagnosticMessages = event.diagnostics.map(d => { + const severity = d.severity || 'Error'; + const location = d.location ? `(${d.location})` : ''; + const id = d.id ? `${d.id}: ` : ''; + return `${severity} ${location}: ${id}${d.message}`; + }); + codeRunnerOutput.value = diagnosticMessages.join('\n'); + codeRunnerOutputError.value = true; + } else if (event.stderr && event.stderr.length > 0) { + // Handle standard error output + codeRunnerOutput.value = event.stderr.join('\n'); + codeRunnerOutputError.value = true; + } else if (event.stdout) { + codeRunnerOutput.value = event.stdout.join('\n'); + codeRunnerOutputError.value = false; + } else { + codeRunnerOutput.value = 'No output'; + codeRunnerOutputError.value = false; + } + isRunning.value = false; + } + + /** + * Checks if code is a complete C# program that doesn't need scaffolding. + * Complete programs must have a namespace declaration with class and Main, + * or be a class named Program with Main. + * @param {string} code - Source code to check + * @returns {boolean} True if code is complete, false if it needs scaffolding + */ + function isCompleteProgram(code) { + // Check for explicit namespace declaration (most reliable indicator) + const hasNamespace = /namespace\s+\w+/i.test(code); + + // Check if it's a class specifically named "Program" with Main method + const isProgramClass = /class\s+Program\s*[\r\n{]/.test(code) && + /static\s+(void|async\s+Task)\s+Main\s*\(/.test(code); + + // Only consider it complete if it has namespace or is the Program class + return hasNamespace || isProgramClass; + } + + /** + * Extracts executable code snippet from source code. + * If code contains #region INCLUDE, extracts only that portion. + * Otherwise returns the full code. + * @param {string} code - Source code to process + * @returns {string} Extracted code snippet + */ + function extractCodeSnippet(code) { + // Extract code from #region INCLUDE if present + const regionMatch = code.match(/#region\s+INCLUDE\s*\n([\s\S]*?)\n\s*#endregion\s+INCLUDE/); + if (regionMatch) { + return regionMatch[1].trim(); + } + return code; + } + + /** + * Fetches listing source code from the API. + * @param {string|number} chapter - Chapter number + * @param {string|number} listing - Listing number + * @returns {Promise} The listing source code (extracted snippet) + */ + async function fetchListingCode(chapter, listing) { + const response = await fetch(`/api/ListingSourceCode/chapter/${chapter}/listing/${listing}`); + if (!response.ok) { + throw new Error(ERROR_MESSAGES.fetchFailed); + } + const data = await response.json(); + const code = data.content || ''; + // Extract the snippet portion if it has INCLUDE regions + return extractCodeSnippet(code); + } + + /** + * Opens the code runner panel with a specific listing. + * @param {string|number} chapter - Chapter number + * @param {string|number} listing - Listing number + * @param {string} title - Title to display + */ + async function openCodeRunner(chapter, listing, title) { + currentListingInfo.value = { chapter, listing, title }; + isCodeRunnerOpen.value = true; + codeRunnerLoading.value = true; + codeRunnerError.value = null; + codeRunnerOutput.value = 'Click "Run" to execute the code.'; + codeRunnerOutputError.value = false; + + const listingKey = `${chapter}.${listing}`; + + try { + // Load the library if not already loaded + if (!isLibraryLoaded.value) { + await loadTryDotNetLibrary(); + } + + // Wait for the panel to render and get the editor element + await nextTick(); + + const editorEl = document.querySelector('.code-runner-editor'); + if (!editorEl) { + throw new Error(ERROR_MESSAGES.editorNotFound); + } + + // Check if this listing is already loaded in the session + if (session && currentLoadedListing === listingKey) { + // Listing already loaded, just show the panel + codeRunnerLoading.value = false; + return; + } + + // Fetch the listing code + const code = await fetchListingCode(chapter, listing); + + // Create session if needed with the fetched code + if (!session) { + await createSession(editorEl, code); + currentLoadedListing = listingKey; + } else { + // Session exists, update the code + await setCode(code); + currentLoadedListing = listingKey; + } + + codeRunnerLoading.value = false; + } catch (error) { + console.error('Failed to open code runner:', error); + codeRunnerError.value = error.message || ERROR_MESSAGES.serviceUnavailable; + codeRunnerLoading.value = false; + } + } + + /** + * Retries opening the code runner after a failure. + * Resets the session so a fresh connection is attempted. + */ + function retryCodeRunner() { + // Reset session state so a fresh connection is attempted + session = null; + currentLoadedListing = null; + isLibraryLoaded.value = false; + trydotnet = null; + + if (currentListingInfo.value) { + const { chapter, listing, title } = currentListingInfo.value; + openCodeRunner(chapter, listing, title); + } + } + + /** + * Closes the code runner panel. + */ + function closeCodeRunner() { + isCodeRunnerOpen.value = false; + currentListingInfo.value = null; + // Note: We keep the session and currentLoadedListing to avoid recreating when reopened + } + + /** + * Clears the output console. + */ + function clearOutput() { + codeRunnerOutput.value = ''; + codeRunnerOutputError.value = false; + } + + /** + * Injects Run buttons into code block sections. + * Skipped entirely when TryDotNet origin is not configured. + */ + function injectRunButtons() { + if (!isTryDotNetConfigured()) { + return; // Don't show Run buttons when the service is not configured + } + + const codeBlocks = document.querySelectorAll('.code-block-section'); + + codeBlocks.forEach((block) => { + const heading = block.querySelector('.code-block-heading'); + if (!heading) return; + + // Skip if button already injected + if (heading.querySelector('.code-runner-btn')) return; + + // Parse chapter and listing numbers from the heading + // Format 1: Listing 1.22 + // Format 2: Listing 1.1: Title + let chapter = null; + let listing = null; + + // First, try to extract from the full heading text + // Pattern: "Listing 1.22" or "Listing 1.1:" + const headingText = heading.textContent; + const listingMatch = headingText.match(/Listing\s+(\d+)\.(\d+)/i); + + if (listingMatch) { + chapter = listingMatch[1]; + listing = listingMatch[2]; + } else { + // Fallback to old method for other formats + const spans = heading.querySelectorAll('span'); + + spans.forEach((span) => { + if (span.classList.contains('TBLNUM')) { + // Extract chapter number (format: "1." -> "1") + const match = span.textContent.match(/(\d+)\./); + if (match) { + chapter = match[1]; + } + } + if (span.classList.contains('CDTNUM') && chapter !== null && listing === null) { + // The CDTNUM after TBLNUM contains the listing number + const num = span.textContent.trim(); + if (/^\d+$/.test(num)) { + listing = num; + } + } + }); + } + + // Only add button for listing 1.1 + if (chapter === '1' && listing === '1') { + // Wrap existing content in a span to keep it together + const contentWrapper = document.createElement('span'); + while (heading.firstChild) { + contentWrapper.appendChild(heading.firstChild); + } + + // Make heading a flex container + heading.style.display = 'flex'; + heading.style.justifyContent = 'space-between'; + heading.style.alignItems = 'center'; + + // Add wrapped content back + heading.appendChild(contentWrapper); + + // Create run button + const runButton = document.createElement('button'); + runButton.className = 'code-runner-btn'; + runButton.type = 'button'; + runButton.title = `Run Listing ${chapter}.${listing}`; + runButton.innerHTML = ' Run'; + runButton.setAttribute('aria-label', `Run Listing ${chapter}.${listing}`); + + runButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openCodeRunner(chapter, listing, `Listing ${chapter}.${listing}`); + }); + + heading.appendChild(runButton); + } + }); + } + + // Lifecycle hooks + onMounted(() => { + // Inject run buttons after component mounts + nextTick(() => { + injectRunButtons(); + }); + }); + + // Return composable interface + return { + // State + isCodeRunnerOpen, + codeRunnerLoading, + codeRunnerError, + codeRunnerOutput, + codeRunnerOutputError, + currentListingInfo, + isRunning, + isLibraryLoaded, + + // Methods + openCodeRunner, + closeCodeRunner, + retryCodeRunner, + runCode, + clearEditor, + clearOutput, + injectRunButtons + }; +}