// ============================================ // 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(`
%s
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: "c", Text: " 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, " tag with some text, and close it with ") && 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, "