2025-11-13 08:53:11 +01:00

721 lines
18 KiB
Go

// ============================================
// 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(`
<div class="feedback success">
✅ Excellent! Your code works correctly.
<div class="output"><pre>%s</pre></div>
<button data-on:click="$$get('/node/%s')" class="btn-primary">
Continue to Next Lesson →
</button>
</div>
`, result.Output, result.NextNode)
buf.WriteString(feedback)
} else {
feedback := fmt.Sprintf(`
<div class="feedback error">
❌ Not quite right. %s
<button data-on:click="$$get('/node/hint-%s')" class="btn-secondary">
Get a Hint 💡
</button>
</div>
`, 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 = "<html>\n <body>\n \n </body>\n</html>"
data.Instructions = "Add an <h1> tag with the text 'Hello World' inside the body."
data.ExerciseID = "first-tag"
data.Title = "Your First HTML Tag"
case "exercise-paragraph":
data.StartingCode = "<html>\n <body>\n <h1>My Page</h1>\n \n </body>\n</html>"
data.Instructions = "Add a <p> 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: "<paragraph>"},
{ID: "b", Text: "<p>"},
{ID: "c", Text: "<text>"},
{ID: "d", Text: "<para>"},
},
}
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 <p> 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, "<h1>") && strings.Contains(code, "Hello World") && strings.Contains(code, "</h1>") {
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 <h1> tag with 'Hello World' inside it, and don't forget the closing </h1> tag!",
}
case "paragraph":
if strings.Contains(code, "<p>") && strings.Contains(code, "</p>") {
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 <p> tag with some text, and close it with </p>",
}
}
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
}