721 lines
18 KiB
Go
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
|
|
}
|