Skip to content
Open
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
edd4ef9
Implement ListingSourceCode feature with controller, service, and tests
Joshua-Lester3 Feb 5, 2026
30402fa
feat: add TryDotNet integration for interactive code execution
Joshua-Lester3 Feb 11, 2026
41d9c03
Merge branch 'main' into jlester/try-net
Joshua-Lester3 Feb 11, 2026
bd8d5d4
fix: ensure ListingSourceCode files are copied to output directory fo…
Joshua-Lester3 Feb 11, 2026
17e3b38
fix: enhance WebApplicationFactory to use TestData for IListingSource…
Joshua-Lester3 Feb 11, 2026
122cc0a
fix: update API routes in ListingSourceCodeController and tests for c…
Joshua-Lester3 Feb 12, 2026
c746e71
Update EssentialCSharp.Web.Tests/WebApplicationFactory.cs
Joshua-Lester3 Feb 12, 2026
6e2d455
refactor: convert ListingSourceCodeResponse to a record class for imp…
Joshua-Lester3 Feb 12, 2026
ee8a892
Merge branch 'jlester/try-net' of https://github.com/Joshua-Lester3/E…
Joshua-Lester3 Feb 12, 2026
30a3fd5
bug fix: Source code response syntax and instantiation
Joshua-Lester3 Feb 12, 2026
e7ebdff
fix: correct service removal in WebApplicationFactory for test data i…
Joshua-Lester3 Feb 12, 2026
d1e638e
refactor: update CreateService and GetTestDataPath methods to use Dir…
Joshua-Lester3 Feb 12, 2026
4ddcc59
refactor: temporarily (possibly use AutoMoq after I research it) repl…
Joshua-Lester3 Feb 12, 2026
76c08f4
refactor: update test project to use Moq.AutoMock for improved mockin…
Joshua-Lester3 Feb 12, 2026
2b1afe1
refactor: replace Mock with AutoMocker for IWebHostEnvironment in Web…
Joshua-Lester3 Feb 12, 2026
ecf4699
Merge branch 'main' into jlester/try-net
Joshua-Lester3 Feb 12, 2026
168c8d5
fix: accidentally removed system.data.common, added back here
Joshua-Lester3 Feb 12, 2026
36e9efd
Merge branch 'jlester/try-net' of https://github.com/Joshua-Lester3/E…
Joshua-Lester3 Feb 12, 2026
f4c065b
Enhance TryDotNet integration with runnable listings and code scaffol…
Joshua-Lester3 Feb 23, 2026
989eebf
Merge remote-tracking branch 'upstream/main' into jlester/try-net-2
Joshua-Lester3 Mar 5, 2026
274e78c
Undo bad merge conflict resolution
Joshua-Lester3 Mar 5, 2026
8bbdf5a
Update loadRunnableListings to require both can_compile and can_run f…
Joshua-Lester3 Mar 11, 2026
d318339
Merge branch 'main' into jlester/try-net-2
Joshua-Lester3 Mar 11, 2026
cbc08d5
Merge branch 'main' into jlester/try-net-2
Joshua-Lester3 Mar 13, 2026
2c3e7a8
Improve error handling in loadRunnableListings for chapter-listings.j…
Joshua-Lester3 Mar 13, 2026
4d1f096
Merge branch 'jlester/try-net-2' of https://github.com/Joshua-Lester3…
Joshua-Lester3 Mar 13, 2026
57923c5
Remove COMMON_USINGS and prependUsings function from TryDotNet module
Joshua-Lester3 Mar 13, 2026
c28d04d
Remove isRunnableListing function from loadRunnableListings
Joshua-Lester3 Mar 13, 2026
4b9c0b4
Merge branch 'main' into jlester/try-net-2
Joshua-Lester3 Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 90 additions & 35 deletions EssentialCSharp.Web/wwwroot/js/trydotnet-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,74 @@ function isTryDotNetConfigured() {
return typeof origin === 'string' && origin.trim().length > 0;
}

// ── Runnable-listings data (loaded once from chapter-listings.json) ──────────

/** @type {Promise<Set<string>>|null} */
let _runnableListingsPromise = null;

/**
* Loads chapter-listings.json (once) and builds a Set of normalised
* "chapter.listing" keys, e.g. "1.3", "12.50".
* Only includes listings where both can_compile and can_run are true.
* @returns {Promise<Set<string>>}
*/
function loadRunnableListings() {
if (_runnableListingsPromise) return _runnableListingsPromise;

_runnableListingsPromise = fetch('/js/chapter-listings.json')
.then(res => {
if (!res.ok) {
const msg = res.status === 404
? 'chapter-listings.json not found (404). The NuGet content package may not be restored — Run buttons will not be shown.'
: `Failed to load chapter-listings.json: ${res.status}`;
throw new Error(msg);
}
return res.json();
})
.then(data => {
const set = new Set();
const chapters = data.chapters || {};
for (const [, files] of Object.entries(chapters)) {
for (const fileObj of files) {
// fileObj is now { filename: "01.03.cs", can_compile: true, can_run: true }
if (!fileObj.can_compile || !fileObj.can_run) continue; // Skip listings that can't compile or can't run

const filename = fileObj.filename;
// filename looks like "01.03.cs" → chapter 1, listing 3
const m = filename.match(/^(\d+)\.(\d+)\./);
if (m) {
set.add(`${parseInt(m[1], 10)}.${parseInt(m[2], 10)}`);
}
}
}
return set;
})
.catch(err => {
console.warn('Could not load runnable listings:', err);
_runnableListingsPromise = null; // Allow retry on next call (e.g. transient network error)
return new Set(); // graceful degradation — no Run buttons
});

return _runnableListingsPromise;
}

/**
* Strips #region / #endregion directive lines (INCLUDE, EXCLUDE, etc.)
* from source code while keeping the code between them intact.
* @param {string} code - Raw source code
* @returns {string} Code with region directive lines removed
*/
function stripRegionDirectives(code) {
return code.replace(/^\s*#(?:region|endregion)\s+(?:INCLUDE|EXCLUDE).*$/gm, '').trim();
}

/**
* 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;

return `
namespace Program
{
class Program
Expand Down Expand Up @@ -222,7 +277,7 @@ export function useTryDotNet() {
const files = [{ name: fileName, content: fileContent }];
const project = { package: 'console', files: files };
const document = isComplete
? { fileName: fileName }
? fileName
: { fileName: fileName, region: 'controller' };

const configuration = {
Expand Down Expand Up @@ -357,37 +412,22 @@ export function useTryDotNet() {

/**
* 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.
* A program is "complete" when it contains a namespace declaration, OR
* when it defines any class with a static Main method.
* Top-level statement files (no class, no namespace) return false and
* will be wrapped by createScaffolding().
* @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) &&
// Check if any class has a static Main method
const hasClassWithMain = /class\s+\w+/.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;
return hasNamespace || hasClassWithMain;
}

/**
Expand All @@ -403,8 +443,16 @@ export function useTryDotNet() {
}
const data = await response.json();
const code = data.content || '';
// Extract the snippet portion if it has INCLUDE regions
return extractCodeSnippet(code);

// Complete programs (namespace or class+Main) are sent as-is, but
// with common usings prepended when the file has none — TryDotNet's
// 'console' package does not provide SDK implicit global usings.
// Top-level statement files get region directives stripped so the
// scaffolding wrapper doesn't contain raw #region lines.
if (isCompleteProgram(code)) {
return code;
}
return stripRegionDirectives(code);
}

/**
Expand Down Expand Up @@ -502,12 +550,19 @@ export function useTryDotNet() {
/**
* Injects Run buttons into code block sections.
* Skipped entirely when TryDotNet origin is not configured.
* Only adds buttons for listings present in chapter-listings.json.
*/
function injectRunButtons() {
async function injectRunButtons() {
if (!isTryDotNetConfigured()) {
return; // Don't show Run buttons when the service is not configured
}

// Pre-load the runnable listings set so we can check membership below
const runnableSet = await loadRunnableListings();
if (runnableSet.size === 0) {
return; // JSON failed to load or is empty — no buttons
}

const codeBlocks = document.querySelectorAll('.code-block-section');

codeBlocks.forEach((block) => {
Expand Down Expand Up @@ -553,8 +608,8 @@ export function useTryDotNet() {
});
}

// Only add button for listing 1.1
if (chapter === '1' && listing === '1') {
// Only add button for listings present in the curated JSON
if (chapter && listing && runnableSet.has(`${parseInt(chapter, 10)}.${parseInt(listing, 10)}`)) {
// Wrap existing content in a span to keep it together
const contentWrapper = document.createElement('span');
while (heading.firstChild) {
Expand Down
Loading