// ============================================ // main.go - Tutorial Server // ============================================ package main import ( "bytes" "context" "encoding/json" "fmt" "html/template" "log" "net/http" "strings" "time" "github.com/google/uuid" ) // ============================================ // Data Structures // ============================================ type StudentState struct { ID string CurrentNode string NodesVisited []string QuizScores map[string]int Attempts map[string]int MasteryScore int StartTime time.Time LastActivity time.Time CompletedExercises map[string]bool } type QuizOption struct { ID string Text string } type NodeData struct { *StudentState QuizOptions []QuizOption ShowHint bool ProgressPercent int NodesCompleted int TotalNodes int WantsMoreDetail bool Explanation string NextNode string Hint string Topic string StartingCode string Instructions string ExerciseID string Title string } type TutorialServer struct { templates *template.Template sessions map[string]*StudentState } // ============================================ // Template Functions // ============================================ var funcMap = template.FuncMap{ "add": func(a, b int) int { return a + b }, "ge": func(a, b int) bool { return a >= b }, "gt": func(a, b int) bool { return a > b }, "lt": func(a, b int) bool { return a < b }, } // ============================================ // Server Setup // ============================================ func NewTutorialServer() *TutorialServer { // Load templates from file tmpl, err := template.New("").Funcs(funcMap).ParseFiles("templates/tutorial.html") if err != nil { log.Fatalf("Error loading templates: %v", err) } return &TutorialServer{ templates: tmpl, sessions: make(map[string]*StudentState), } } // ============================================ // Simple Router (supports "VERB /path/{param}" specs) // ============================================ type routeSpec struct { method string pattern string handler http.HandlerFunc } type SimpleRouter struct { routes []routeSpec } type ctxKey string var routeParamsKey ctxKey = "routeParams" func NewSimpleRouter() *SimpleRouter { return &SimpleRouter{ routes: []routeSpec{}, } } // Register a route using a string like "GET /node/{nodeID}" func (sr *SimpleRouter) HandleFunc(spec string, h http.HandlerFunc) { parts := strings.SplitN(spec, " ", 2) if len(parts) != 2 { log.Fatalf("invalid route spec: %s", spec) } method := strings.ToUpper(strings.TrimSpace(parts[0])) pattern := strings.TrimSpace(parts[1]) sr.routes = append(sr.routes, routeSpec{method: method, pattern: pattern, handler: h}) } func (sr *SimpleRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path method := r.Method for _, rt := range sr.routes { if rt.method != method { continue } ok, params := matchPattern(rt.pattern, path) if ok { ctx := context.WithValue(r.Context(), routeParamsKey, params) rt.handler.ServeHTTP(w, r.WithContext(ctx)) return } } // If no route matched, return 404 log.Printf("No route matched for %s %s", method, path) http.NotFound(w, r) } // Match a pattern like "/node/{nodeID}" against a path and return params func matchPattern(pattern, path string) (bool, map[string]string) { pattern = strings.TrimSuffix(pattern, "/") path = strings.TrimSuffix(path, "/") pSeg := strings.Split(pattern, "/") qSeg := strings.Split(path, "/") // Special-case root if len(pSeg) == 1 && pSeg[0] == "" { if path == "" || path == "/" { return true, map[string]string{} } } if len(pSeg) != len(qSeg) { return false, nil } params := map[string]string{} for i := 0; i < len(pSeg); i++ { p := pSeg[i] q := qSeg[i] if p == "" && q == "" { continue } if strings.HasPrefix(p, "{") && strings.HasSuffix(p, "}") { name := strings.TrimSuffix(strings.TrimPrefix(p, "{"), "}") params[name] = q continue } if p != q { return false, nil } } return true, params } // Helper to retrieve a route parameter from request context func routeVar(r *http.Request, name string) string { v := r.Context().Value(routeParamsKey) if v == nil { return "" } if m, ok := v.(map[string]string); ok { return m[name] } return "" } // Simple logging middleware to help debug requests func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("--> %s %s cookies=%v", r.Method, r.URL.Path, r.Cookies()) next.ServeHTTP(w, r) log.Printf("<-- %s %s", r.Method, r.URL.Path) }) } // ============================================ // Session Management // ============================================ func (s *TutorialServer) getOrCreateState(sessionID string) *StudentState { if state, exists := s.sessions[sessionID]; exists { state.LastActivity = time.Now() return state } state := &StudentState{ ID: sessionID, CurrentNode: "intro", NodesVisited: []string{}, QuizScores: make(map[string]int), Attempts: make(map[string]int), CompletedExercises: make(map[string]bool), MasteryScore: 0, StartTime: time.Now(), LastActivity: time.Now(), } s.sessions[sessionID] = state return state } func getSessionID(r *http.Request) string { cookie, err := r.Cookie("session_id") if err != nil { return "" } return cookie.Value } func setSessionID(w http.ResponseWriter) string { sessionID := uuid.New().String() http.SetCookie(w, &http.Cookie{ Name: "session_id", Value: sessionID, Path: "/", MaxAge: 86400 * 7, // 7 days HttpOnly: true, }) return sessionID } // ============================================ // HTTP Handlers // ============================================ func (s *TutorialServer) HandleIndex(w http.ResponseWriter, r *http.Request) { sessionID := getSessionID(r) if sessionID == "" { sessionID = setSessionID(w) } state := s.getOrCreateState(sessionID) data := NodeData{ StudentState: state, TotalNodes: 15, // Total nodes in our tutorial } var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, "main-page", data); err != nil { log.Printf("Template error: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } // normalize attribute names for datastar rc6 compatibility out := normalizeDataOnAttrs(buf.String()) w.Header().Set("Content-Type", "text/html") w.Write([]byte(out)) } func (s *TutorialServer) RenderNode(w http.ResponseWriter, r *http.Request) { nodeID := routeVar(r, "nodeID") if nodeID == "" { http.Error(w, "No nodeID", http.StatusBadRequest) return } // Ensure there is a session cookie; create one if missing sessionID := getSessionID(r) if sessionID == "" { sessionID = setSessionID(w) } state := s.getOrCreateState(sessionID) // Track visit if !contains(state.NodesVisited, nodeID) { state.NodesVisited = append(state.NodesVisited, nodeID) } state.CurrentNode = nodeID data := s.prepareNodeData(nodeID, state) var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, nodeID, data); err != nil { log.Printf("Template error for node %s: %v", nodeID, err) http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError) return } // If the client expects SSE (Datastar) send event-stream; otherwise return raw HTML accept := r.Header.Get("Accept") fragment := normalizeDataOnAttrs(buf.String()) if strings.Contains(accept, "text/event-stream") { // Send as SSE for Datastar w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") fmt.Fprintf(w, "event: datastar-fragment\n") fmt.Fprintf(w, "data: selector #content\n") fmt.Fprintf(w, "data: merge morph\n") fmt.Fprintf(w, "data: fragment %s\n\n", fragment) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } return } // Fallback: return the fragment HTML directly so simple fetch() clients can replace #content w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(fragment)) } func (s *TutorialServer) HandleAnswer(w http.ResponseWriter, r *http.Request) { var answer struct { Quiz string `json:"quiz"` Answer string `json:"answer"` } if err := json.NewDecoder(r.Body).Decode(&answer); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Ensure session exists (create cookie if needed) sessionID := getSessionID(r) if sessionID == "" { sessionID = setSessionID(w) } state := s.getOrCreateState(sessionID) state.Attempts[answer.Quiz]++ isCorrect := s.validateAnswer(answer.Quiz, answer.Answer) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") var buf bytes.Buffer if isCorrect { state.QuizScores[answer.Quiz] = 100 state.MasteryScore = s.calculateMastery(state) nextNode := s.getNextNode(answer.Quiz, state) data := NodeData{ StudentState: state, Explanation: s.getExplanation(answer.Quiz), NextNode: nextNode, } s.templates.ExecuteTemplate(&buf, "correct-answer", data) } else { data := NodeData{ StudentState: state, Hint: s.getHint(answer.Quiz, state.Attempts[answer.Quiz]), ShowHint: state.Attempts[answer.Quiz] > 1, Topic: s.getTopicFromQuiz(answer.Quiz), } s.templates.ExecuteTemplate(&buf, "wrong-answer", data) } fragment := normalizeDataOnAttrs(buf.String()) fmt.Fprintf(w, "event: datastar-fragment\n") fmt.Fprintf(w, "data: selector #quiz-feedback\n") fmt.Fprintf(w, "data: merge morph\n") fmt.Fprintf(w, "data: fragment %s\n\n", fragment) // Update progress progress := (len(state.NodesVisited) * 100) / 15 fmt.Fprintf(w, "event: datastar-signal\n") fmt.Fprintf(w, "data: {\"progress\": %d, \"mastery\": %d}\n\n", progress, state.MasteryScore) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } } func (s *TutorialServer) HandleCodeCheck(w http.ResponseWriter, r *http.Request) { var submission struct { ExerciseID string `json:"exerciseId"` Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&submission); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Ensure session exists (create cookie if needed) sessionID := getSessionID(r) if sessionID == "" { sessionID = setSessionID(w) } state := s.getOrCreateState(sessionID) result := s.validateCode(submission.ExerciseID, submission.Code) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") var buf bytes.Buffer if result.Passed { state.CompletedExercises[submission.ExerciseID] = true state.MasteryScore += 10 feedback := fmt.Sprintf(`
✅ Excellent! Your code works correctly.
%s
`, result.Output, result.NextNode) buf.WriteString(feedback) } else { feedback := fmt.Sprintf(`
❌ Not quite right. %s
`, result.Error, submission.ExerciseID) buf.WriteString(feedback) } fragment := normalizeDataOnAttrs(buf.String()) fmt.Fprintf(w, "event: datastar-fragment\n") fmt.Fprintf(w, "data: selector #exercise-feedback\n") fmt.Fprintf(w, "data: merge morph\n") fmt.Fprintf(w, "data: fragment %s\n\n", fragment) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } } // ============================================ // Business Logic // ============================================ func (s *TutorialServer) prepareNodeData(nodeID string, state *StudentState) NodeData { data := NodeData{ StudentState: state, QuizOptions: s.getQuizOptions(nodeID), ProgressPercent: (len(state.NodesVisited) * 100) / 15, NodesCompleted: len(state.NodesVisited), TotalNodes: 15, WantsMoreDetail: len(state.NodesVisited) > 5, ShowHint: state.Attempts[nodeID] > 1, } // Set exercise-specific data switch nodeID { case "exercise-first-tag": data.StartingCode = "\n \n \n \n" data.Instructions = "Add an

tag with the text 'Hello World' inside the body." data.ExerciseID = "first-tag" data.Title = "Your First HTML Tag" case "exercise-paragraph": data.StartingCode = "\n \n

My Page

\n \n \n" data.Instructions = "Add a

tag with any text you like below the h1." data.ExerciseID = "paragraph" data.Title = "Adding a Paragraph" } return data } func (s *TutorialServer) getQuizOptions(nodeID string) []QuizOption { quizzes := map[string][]QuizOption{ "quiz-what-is-html": { {ID: "a", Text: "HyperText Markup Language"}, {ID: "b", Text: "High Technology Modern Language"}, {ID: "c", Text: "Home Tool Markup Language"}, {ID: "d", Text: "HyperText Modern Language"}, }, "quiz-html-tags": { {ID: "a", Text: ""}, {ID: "b", Text: "

"}, {ID: "c", Text: ""}, {ID: "d", Text: ""}, }, } return quizzes[nodeID] } func (s *TutorialServer) validateAnswer(quiz, answer string) bool { correctAnswers := map[string]string{ "quiz-what-is-html": "a", "quiz-html-tags": "b", } return correctAnswers[quiz] == answer } func (s *TutorialServer) getExplanation(quiz string) string { explanations := map[string]string{ "quiz-what-is-html": "HTML stands for HyperText Markup Language. It's the standard language for creating web pages!", "quiz-html-tags": "The

tag creates a paragraph. The 'p' stands for paragraph!", } return explanations[quiz] } func (s *TutorialServer) getHint(quiz string, attempts int) string { hints := map[string][]string{ "quiz-what-is-html": { "Think about what HTML stands for...", "The 'HT' in HTML stands for HyperText", }, "quiz-html-tags": { "Think about the first letter of 'paragraph'", "Paragraph tags are very short - just one letter!", }, } hintList := hints[quiz] if attempts-1 < len(hintList) { return hintList[attempts-1] } return hintList[len(hintList)-1] } func (s *TutorialServer) getNextNode(quiz string, state *StudentState) string { nextNodes := map[string]string{ "quiz-what-is-html": "html-structure", "quiz-html-tags": "exercise-first-tag", } // Could add logic based on mastery score if state.MasteryScore > 80 { // Fast track for high performers if quiz == "quiz-html-tags" { return "advanced-tags" } } return nextNodes[quiz] } func (s *TutorialServer) getTopicFromQuiz(quiz string) string { topics := map[string]string{ "quiz-what-is-html": "HTML Basics", "quiz-html-tags": "HTML Tags", } return topics[quiz] } func (s *TutorialServer) calculateMastery(state *StudentState) int { if len(state.QuizScores) == 0 { return 0 } total := 0 for _, score := range state.QuizScores { total += score } mastery := total / len(state.QuizScores) // Bonus for completed exercises mastery += len(state.CompletedExercises) * 5 if mastery > 100 { mastery = 100 } return mastery } type CodeResult struct { Passed bool Output string Error string NextNode string } func (s *TutorialServer) validateCode(exerciseID, code string) CodeResult { code = strings.TrimSpace(code) switch exerciseID { case "first-tag": if strings.Contains(code, "

") && strings.Contains(code, "Hello World") && strings.Contains(code, "

") { return CodeResult{ Passed: true, Output: "Your page would display:\n\nHello World\n(as a large heading)", NextNode: "html-attributes", } } return CodeResult{ Passed: false, Error: "Make sure you have an

tag with 'Hello World' inside it, and don't forget the closing

tag!", } case "paragraph": if strings.Contains(code, "

") && strings.Contains(code, "

") { return CodeResult{ Passed: true, Output: "Perfect! Your page now has both a heading and a paragraph.", NextNode: "quiz-html-tags", } } return CodeResult{ Passed: false, Error: "You need to add a

tag with some text, and close it with

", } } return CodeResult{ Passed: false, Error: "Unknown exercise", } } // ============================================ // Utilities // ============================================ func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } // ============================================ // Main // ============================================ func main() { server := NewTutorialServer() r := NewSimpleRouter() // Use route specs like "GET /", "GET /node/{nodeID}", "POST /answer", etc. r.HandleFunc("GET /", server.HandleIndex) r.HandleFunc("GET /node/{nodeID}", server.RenderNode) r.HandleFunc("POST /answer", server.HandleAnswer) r.HandleFunc("POST /code/check", server.HandleCodeCheck) // serve local datastar.js from ./static/datastar.js r.HandleFunc("GET /datastar.js", func(w http.ResponseWriter, req *http.Request) { http.ServeFile(w, req, "static/datastar.js") }) // serve source map if requested r.HandleFunc("GET /datastar.js.map", func(w http.ResponseWriter, req *http.Request) { http.ServeFile(w, req, "static/datastar.js.map") }) // CORS for development - wrap the router var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if req.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } r.ServeHTTP(w, req) }) // wrap with logging middleware to print incoming requests handler = loggingMiddleware(handler) fmt.Println("🚀 Tutorial server starting on http://localhost:8080") fmt.Println("📚 Open your browser and start learning!") log.Fatal(http.ListenAndServe(":8080", handler)) } // normalizeDataOnAttrs replaces older data-on-click attrs with rc6's data-on:click func normalizeDataOnAttrs(s string) string { if s == "" { return s } // replace old attr name (and any accidental variants) with the new rc6 name s = strings.ReplaceAll(s, "data-on-click", "data-on:click") s = strings.ReplaceAll(s, "data-on\\:click", "data-on:click") // defensive return s }