@* 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
+ };
+}