more slides

This commit is contained in:
thomashamburg 2025-11-13 08:53:11 +01:00
parent ae0d8498ec
commit 176345e65a
9 changed files with 1572 additions and 1 deletions

View File

@ -0,0 +1,5 @@
module tutorial
go 1.25.0
require github.com/google/uuid v1.6.0

View File

@ -0,0 +1,2 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View File

@ -0,0 +1,720 @@
// ============================================
// 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
}

View File

@ -0,0 +1 @@
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,182 @@
{{define "main-page"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Interactive HTML Tutorial</title>
<script type="module" src="/datastar.js"></script>
<style>
*{box-sizing:border-box} body{font-family:Inter,system-ui,Segoe UI,Roboto,Arial; background:#f3f6fb; padding:24px}
.container{max-width:900px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.08);overflow:hidden}
.header{padding:28px;background:linear-gradient(90deg,#5562f7,#8b5cf6);color:#fff;text-align:center}
.header h1{margin:0;font-size:1.8rem}
#content{padding:28px;min-height:360px}
button{background:#5562f7;color:#fff;border:none;padding:10px 18px;border-radius:8px;cursor:pointer}
.btn-ghost{background:#eef2ff;color:#111}
.quiz-option{display:block;width:100%;text-align:left;padding:12px;border-radius:8px;margin:8px 0;border:1px solid #eef2ff;background:#fbfdff}
pre.code{background:#0f1724;color:#a7f3d0;padding:12px;border-radius:8px;overflow:auto}
.hint{background:#fff7ed;border-left:4px solid #f59e0b;padding:12px;border-radius:6px;margin:12px 0}
.feedback{padding:14px;border-radius:8px;margin:12px 0}
.feedback.success{background:#ecfdf5;border-left:4px solid #10b981;}
.feedback.error{background:#fff1f2;border-left:4px solid #ef4444;}
footer{padding:16px;text-align:center;font-size:.9rem;color:#6b7280;}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Learn HTML — Interactive Mini Tutorial</h1>
<p style="opacity:.9;margin-top:6px">Hands-on lessons, short quizzes and tiny exercises.</p>
</div>
<div id="content" data-signals='{"progress":0,"mastery":0}'>
{{template "intro" .}}
</div>
<footer>
Progress: <span data-signal="progress">0</span>% &nbsp;&nbsp; Mastery: <span data-signal="mastery">0</span>%
</footer>
</div>
</body>
</html>
{{end}}
{{/* --- Intro screen --- */}}
{{define "intro"}}
<div style="padding:8px 0 0 0">
<div style="margin-bottom:18px">
<h2>Welcome 👋</h2>
<p>In this short interactive tutorial you'll learn what HTML is, try a quick quiz, and write your first &lt;h1&gt; tag.</p>
</div>
<div style="display:flex;gap:12px;align-items:center">
<button data-on:click="@get('/node/what-is-html')">Start Learning</button>
<button class="btn-ghost" data-on:click="@get('/node/summary')">See Outline</button>
</div>
</div>
{{end}}
{{/* --- Outline / summary --- */}}
{{define "summary"}}
<h2>Lesson Outline</h2>
<ul>
<li>What is HTML? (short)</li>
<li>Quiz: basic concept</li>
<li>Exercise: create a heading (&lt;h1&gt;)</li>
</ul>
<p>
<button data-on:click="@get('/node/what-is-html')">Begin Lesson</button>
</p>
{{end}}
{{/* --- Short lesson node --- */}}
{{define "what-is-html"}}
<h2>What is HTML?</h2>
<p><strong>HTML</strong> stands for <em>HyperText Markup Language</em>. It's the language used to describe the structure of web pages using tags such as &lt;h1&gt;, &lt;p&gt;, &lt;a&gt; and others.</p>
<h3>Key ideas</h3>
<ul>
<li>HTML is markup, not a programming language.</li>
<li>Elements use tags: &lt;tag&gt;content&lt;/tag&gt;.</li>
<li>Browsers render HTML to show pages.</li>
</ul>
<div style="margin-top:18px">
<button data-on:click="@get('/node/quiz-what-is-html')">Take a quick quiz</button>
<button class="btn-ghost" data-on:click="@get('/node/exercise-first-tag')">Try an exercise instead</button>
</div>
{{end}}
{{/* --- Quiz node --- */}}
{{define "quiz-what-is-html"}}
<h2>Quiz — What is HTML?</h2>
<p>Choose the best answer:</p>
<div style="margin-top:12px">
{{range .QuizOptions}}
<button class="quiz-option" data-on:click="@post('/answer', {quiz: 'quiz-what-is-html', answer: '{{.ID}}'})">
<strong>{{.ID | printf "%s."}}</strong> {{.Text}}
</button>
{{end}}
</div>
<div id="quiz-feedback" style="margin-top:12px"></div>
<div style="margin-top:16px">
<button class="btn-ghost" data-on:click="@get('/node/what-is-html')">Back</button>
</div>
{{end}}
{{/* Keep an alias if older links use the other name */}}
{{define "quiz-html-tags"}}{{template "quiz-what-is-html" .}}{{end}}
{{/* --- Exercise: first tag --- */}}
{{define "exercise-first-tag"}}
<h2>Exercise — Your first &lt;h1&gt;</h2>
<p>Finish the HTML snippet so the page shows a heading that reads <strong>Hello World</strong>.</p>
<pre class="code">&lt;!doctype html&gt;
&lt;html&gt;
&lt;body&gt;
<!-- add a heading below -->
{{printf "%s" ""}}
&lt;/body&gt;
&lt;/html&gt;</pre>
<textarea id="code" placeholder="Type your HTML here">&lt;h1&gt;Hello World&lt;/h1&gt;</textarea>
<div style="margin-top:12px">
<button data-on:click="$$post('/code/check', {exerciseId: 'first-tag', code: document.querySelector('#code').value})">Run & Check</button>
<button class="btn-ghost" data-on:click="@get('/node/what-is-html')">Skip</button>
</div>
<div id="exercise-feedback" style="margin-top:12px"></div>
{{end}}
{{/* --- Feedback fragments --- */}}
{{define "correct-answer"}}
<div class="feedback success">
<h3>✅ Correct</h3>
<p>{{.Explanation}}</p>
<button data-on:click="@get('/node/{{.NextNode}}')">Continue</button>
</div>
{{end}}
{{define "wrong-answer"}}
<div class="feedback error">
<h3>❌ Not quite</h3>
{{if .ShowHint}}<div class="hint">{{.Hint}}</div>{{end}}
<p>Try again or review the lesson.</p>
<div style="margin-top:10px">
<button data-on:click="@get('/node/quiz-what-is-html')">Try Again</button>
<button class="btn-ghost" data-on:click="@get('/node/what-is-html')">Review</button>
</div>
</div>
{{end}}
{{/* --- Code-check results --- */}}
{{define "code-pass"}}
<div class="feedback success">
<h3>🎉 Nice work — it passed!</h3>
<p>{{.Output}}</p>
<button data-on:click="@get('/node/quiz-what-is-html')">Go to Quiz</button>
</div>
{{end}}
{{define "code-fail"}}
<div class="feedback error">
<h3>Looks like there is an issue</h3>
<p>{{.Error}}</p>
<button class="btn-ghost" data-on:click="@get('/node/exercise-first-tag')">Open Editor</button>
</div>
{{end}}

View File

@ -0,0 +1,517 @@
{{define "main-page"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive HTML Tutorial</title>
<script type="module" src="/datastar.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.progress-bar {
background: rgba(255,255,255,0.2);
height: 8px;
border-radius: 4px;
overflow: hidden;
margin-top: 20px;
}
.progress-fill {
background: #4ade80;
height: 100%;
transition: width 0.5s ease;
}
#content {
padding: 40px;
min-height: 400px;
}
h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 2em;
}
h3 {
color: #764ba2;
margin: 20px 0 10px 0;
}
p {
margin-bottom: 15px;
font-size: 1.1em;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
font-size: 1em;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin: 10px 10px 10px 0;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.quiz-option {
display: block;
width: 100%;
text-align: left;
margin: 10px 0;
background: #f8f9fa;
color: #333;
}
.quiz-option:hover {
background: #e9ecef;
}
.feedback {
padding: 20px;
border-radius: 10px;
margin: 20px 0;
}
.feedback.success {
background: #d1fae5;
border-left: 4px solid #10b981;
}
.feedback.error {
background: #fee2e2;
border-left: 4px solid #ef4444;
}
.hint {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 15px;
margin: 15px 0;
border-radius: 5px;
}
textarea {
width: 100%;
min-height: 200px;
padding: 15px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 14px;
margin: 15px 0;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
.code-output {
background: #1f2937;
color: #10b981;
padding: 15px;
border-radius: 8px;
font-family: 'Courier New', monospace;
margin: 15px 0;
}
pre {
white-space: pre-wrap;
}
.intro-box {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
padding: 30px;
border-radius: 15px;
margin: 20px 0;
}
.stats {
display: flex;
justify-content: space-around;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
margin: 20px 0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #667eea;
}
.stat-label {
color: #6b7280;
font-size: 0.9em;
}
</style>
</head>
<body>
<div id="content" class="container" data-signals='{"progress": 0, "mastery": 0}'>
{{template "intro" .}}
</div>
<script>
// Lightweight click handler to support data-on-click / data-on:click attributes if datastar.js doesn't wire them.
(function(){
function parseSpec(spec) {
spec = (spec||"").trim();
var m = spec.match(/^(?:@|\$+)?([A-Za-z]+)\((['"])(.+?)\2\)$/);
if (!m) return null;
var verb = m[1].toUpperCase();
var path = m[3];
var v2 = path.match(/^([A-Z]+)\s+(\/.*)$/);
if (v2) { verb = v2[1]; path = v2[2]; }
return { method: verb, path: path };
}
document.addEventListener('click', function(e){
// support either attribute name (old and rc6): data-on-click and data-on:click
var el = e.target.closest && e.target.closest('[data-on-click], [data-on\\:click]');
if (!el) return;
var spec = el.getAttribute('data-on-click') || el.getAttribute('data-on:click') || '';
var parsed = parseSpec(spec);
if (!parsed) return;
e.preventDefault();
if (parsed.method === 'GET') {
fetch(parsed.path, { method: 'GET', headers: { Accept: 'text/html' }, credentials: 'same-origin' })
.then(function(res){ return res.text(); })
.then(function(html){
var container = document.getElementById('content');
if (container) {
container.innerHTML = html;
container.querySelectorAll('script').forEach(function(s){
var ns = document.createElement('script');
if (s.src) ns.src = s.src;
ns.text = s.textContent;
document.head.appendChild(ns);
document.head.removeChild(ns);
});
}
})
.catch(function(err){ console.error('nav fetch failed', err); });
return;
}
console.warn('Unhandled method in data-on-click/ data-on:click:', parsed.method);
}, false);
})();
</script>
</body>
</html>
{{end}}
{{define "intro"}}
<div class="intro-box">
<h2>Welcome to Your HTML Journey! 👋</h2>
<p>This interactive tutorial will teach you HTML from the ground up. You'll learn by doing, with:</p>
<ul style="margin: 20px 0 20px 30px; font-size: 1.1em;">
</ul>
<p>The tutorial adapts to your pace - if you're doing great, we'll move faster. If you need more practice, we'll provide it!</p>
<button data-on:click="@get('/node/what-is-html')">
Start Learning
</button>
</div>
{{end}}
{{define "what-is-html"}}
<div>
<h2>What is HTML? 🤔</h2>
<p><strong>HTML</strong> stands for <strong>HyperText Markup Language</strong>. It's the standard language used to create web pages.</p>
<button data-on:click="@get('/node/quiz-what-is-html')">
Next
</button>
</div>
{{end}}
{{define "correct-answer"}}
<div class="feedback success">
<h3>✅ Correct! Great job!</h3>
<p>{{.Explanation}}</p>
<button data-on:click="@get('/node/{{.NextNode}}')">
Continue
</button>
</div>
{{end}}
{{define "wrong-answer"}}
<div class="feedback error">
<h3>❌ Not quite right</h3>
{{if .ShowHint}}
<div class="hint">
</div>
{{end}}
<button data-on:click="@get('/node/quiz-what-is-html')">
Try Again
</button>
</div>
{{end}}
{{define "html-structure"}}
<div>
<h2>HTML Structure 🏗️</h2>
<p>Every HTML document follows a basic structure. Think of it like a house - it needs a foundation, walls, and a roof!</p>
<div class="code-output">
<pre>&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Page Title&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;My First Heading&lt;/h1&gt;
&lt;p&gt;My first paragraph.&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;</pre>
</div>
<h3>Let's break this down:</h3>
<ul style="margin: 15px 0 15px 30px; line-height: 2;">
<li><code>&lt;!DOCTYPE html&gt;</code> - Tells the browser this is HTML5</li>
<li><code>&lt;html&gt;</code> - The root element, contains everything</li>
<li><code>&lt;head&gt;</code> - Contains metadata (title, links to CSS, etc.)</li>
<li><code>&lt;body&gt;</code> - Contains the visible content</li>s
</ul>
<p>Most HTML elements have an <strong>opening tag</strong> and a <strong>closing tag</strong> (with a /). The content goes between them!</p>
<button data-on-click="@get('/node/html-tags')">
Learn About HTML Tags →
</button>
</div>
{{end}}
{{define "html-tags"}}
<div>
<h2>HTML Tags 🏷️</h2>
<p>HTML uses <strong>tags</strong> to mark up content. Tags tell the browser how to display the content.</p>
<h3>Common HTML Tags:</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin: 20px 0;">
<p><code>&lt;h1&gt;</code> to <code>&lt;h6&gt;</code> - Headings (h1 is largest)</p>
<p><code>&lt;p&gt;</code> - Paragraph</p>
<p><code>&lt;a&gt;</code> - Link</p>
<p><code>&lt;img&gt;</code> - Image</p>
<p><code>&lt;div&gt;</code> - Division/container</p>
<p><code>&lt;span&gt;</code> - Inline container</p>
</div>
<h3>Tag Anatomy:</h3>
<div class="code-output">
<pre>&lt;p&gt;This is a paragraph&lt;/p&gt;
↑ ↑ ↑
Opening Content Closing
Tag Tag</pre>
</div>
<p>Most tags come in pairs, but some are <strong>self-closing</strong> like <code>&lt;img&gt;</code> and <code>&lt;br&gt;</code>.</p>
<button data-on-click="@get('/node/quiz-html-tags')">
Take the Quiz →
</button>
</div>
{{end}}
{{define "quiz-html-tags"}}
<div>
<h2>Quiz: HTML Tags 📝</h2>
<p>Let's test your knowledge of HTML tags:</p>
<div style="margin: 30px 0;">
<h3>Which tag is used to create a paragraph?</h3>
{{range .QuizOptions}}
<button class="quiz-option" data-on-click="@post('/answer', {quiz: 'quiz-html-tags', answer: '{{.ID}}'})">
{{.Text}}
</button>
{{end}}
</div>
<div id="quiz-feedback"></div>
</div>
{{end}}
{{/* Alias for older/alternate node name used by buttons/routes */}}
{{define "quiz-what-is-html"}}{{template "quiz-html-tags" .}}{{end}}
{{define "exercise-first-tag"}}
<div data-signals='{"code": "{{.StartingCode}}"}'>
<h2>{{.Title}} 💻</h2>
<p>{{.Instructions}}</p>
<p>Remember: HTML tags have an opening tag <code>&lt;h1&gt;</code> and a closing tag <code>&lt;/h1&gt;</code> with the content in between!</p>
<textarea
id="code-editor"
data-model="code"
placeholder="Type your HTML here..."
>{{.StartingCode}}</textarea>
<button data-on-click="@post('/code/check', {exerciseId: '{{.ExerciseID}}', code: $code})">
Check My Code ✓
</button>
<div id="exercise-feedback"></div>
</div>
{{end}}
{{define "html-attributes"}}
<div>
<h2>HTML Attributes 🎯</h2>
<p>Attributes provide additional information about HTML elements. They're always specified in the opening tag.</p>
<div class="code-output">
<pre>&lt;a href="https://example.com"&gt;Click me&lt;/a&gt;
Attribute
(name="value")</pre>
</div>
<h3>Common Attributes:</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin: 20px 0;">
<p><code>href</code> - Link destination (for <code>&lt;a&gt;</code> tags)</p>
<p><code>src</code> - Image source (for <code>&lt;img&gt;</code> tags)</p>
<p><code>alt</code> - Alternative text for images</p>
<p><code>id</code> - Unique identifier for an element</p>
<p><code>class</code> - Class name for styling</p>
</div>
<h3>Example with Multiple Attributes:</h3>
<div class="code-output">
<pre>&lt;img src="logo.png" alt="Company Logo" width="200"&gt;</pre>
</div>
<button data-on-click="@get('/node/exercise-paragraph')">
Practice with Paragraphs →
</button>
</div>
{{end}}
{{define "exercise-paragraph"}}
<div data-signals='{"code": "{{.StartingCode}}"}'>
<h2>{{.Title}} 💻</h2>
<p>{{.Instructions}}</p>
<p><strong>Tip:</strong> A paragraph tag looks like this: <code>&lt;p&gt;Your text here&lt;/p&gt;</code></p>
<textarea
id="code-editor"
data-model="code"
placeholder="Type your HTML here..."
>{{.StartingCode}}</textarea>
<button data-on-click="@post('/code/check', {exerciseId: '{{.ExerciseID}}', code: $code})">
Check My Code ✓
</button>
<div id="exercise-feedback"></div>
</div>
{{end}}
{{define "advanced-tags"}}
<div>
<h2>Advanced HTML Tags 🚀</h2>
<p>Great job so far! You're ready for more advanced tags. Let's look at lists and semantic HTML.</p>
<h3>Lists:</h3>
<div class="code-output">
<pre>&lt;!-- Unordered List --&gt;
&lt;ul&gt;
&lt;li&gt;Item 1&lt;/li&gt;
&lt;li&gt;Item 2&lt;/li&gt;
&lt;/ul&gt;
&lt;!-- Ordered List --&gt;
&lt;ol&gt;
&lt;li&gt;First&lt;/li&gt;
&lt;li&gt;Second&lt;/li&gt;
&lt;/ol&gt;</pre>
</div>
<h3>Semantic HTML5 Tags:</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin: 20px 0;">
<p><code>&lt;header&gt;</code> - Page or section header</p>
<p><code>&lt;nav&gt;</code> - Navigation links</p>
<p><code>&lt;main&gt;</code> - Main content</p>
<p><code>&lt;article&gt;</code> - Self-contained content</p>
<p><code>&lt;section&gt;</code> - Thematic grouping</p>
<p><code>&lt;footer&gt;</code> - Page or section footer</p>
</div>
<p>These semantic tags make your HTML more meaningful and accessible!</p>
<button data-on-click="@get('/node/congratulations')">
Complete Tutorial →
</button>
</div>
{{end}}
{{define "congratulations"}}
<div>
<h2>🎉 Congratulations!</h2>
<p>You've completed the HTML Basics tutorial! You now know:</p>
<ul style="margin: 20px 0 20px 30px; font-size: 1.1em; line-height: 2;">
<li>✅ What HTML is and why it's important</li>
<li>✅ Basic HTML structure</li>
<li>✅ Common HTML tags</li>
<li>✅ How to write HTML code</li>
<li>✅ HTML attributes</li>
{{if ge .MasteryScore 80}}
<li>✅ Advanced HTML concepts</li>
{{end}}
</ul>
<div class="stats">
<div class="stat-item">
<div class="stat-value">{{.NodesCompleted}}</div>
<div class="stat-label">Lessons Completed</div>
</div>
<div class="stat-item">
<div class="stat-value">{{.MasteryScore}}%</div>
<div class="stat-label">Mastery Score</div>
</div>
<div class="stat-item">
<div class="stat-value">{{len .CompletedExercises}}</div>
<div class="stat-label">Exercises Done</div>
</div>
</div>
<div class="intro-box">
<h3>What's Next? 🚀</h3>
<p>Continue your web development journey with:</p>
<ul style="margin: 15px 0 15px 30px;">
<li>CSS Tutorial - Style your HTML pages</li>
<li>JavaScript Tutorial - Add interactivity</li>
<li>Build Your First Website - Put it all together</li>
</ul>
</div>
<button data-on-click="@get('/node/intro')">
Start Over
</button>
<button class="btn-secondary" onclick="alert('More tutorials coming soon!')">
Next Tutorial →
</button>
</div>
{{end}}

View File

@ -444,6 +444,134 @@ class: default
| Mechanism | Placeholder substitution | Link/form embedding |
| Example | `{{.Title}}` → “Article” | `<a href="{{.Links.Edit}}">Edit</a>` |
---
layout: center
class: text-center
---
# Putting Everything Together
---
clicks: 6
---
# Smooth paragraph → circle flow
<div>
<p v-click="1">
This is the first paragraph with an
<span v-mark="{ at: 2, type: 'circle', color: '#ff7b72' }">
important phrase
</span>.
</p>
<p v-click="3">
Here comes the second paragraph with
<span v-mark="{ at: 4, type: 'circle', color: '#4cb0ff' }">
key words
</span>
to highlight.
</p>
<p v-click="5">
Finally, the third paragraph shows and then
<span v-mark="{ at: 6, type: 'circle', color: '#6ce5b6' }">
the spotlight
</span>
appears.
</p>
</div>
---
class: default
clicks: 7
---
# The Name "Datastar"
<v-click>
## Comes from the custom data-* Attribute
</v-click>
<v-click>
A custom data- attribute is an HTML attribute that allows you to store extra information about an HTML element in a standard, private way.
It must be named starting with the prefix data-, for example: data-product-id="123".
</v-click>
<p v-click="3">
<code>
&lt;div id="foo" data-<span v-mark.circle.orange="4">what-ever</span>= <span v-mark.circle.red="5">"Any String you want"</span> &gt;&nbsp; Lorem ... &nbsp;&lt;/div&gt;
</code>
</p>
<v-click at="6">
there even is a javascript api for this:
```js
const el = document.getElementById("foo");
el.dataset.whatEver = "you've got it!";
```
</v-click>
<v-click at="7" >
- Every HTML element provides a keyvalue store (via its .dataset property) for arbitrary data you want to associate with that element.
- data-* attributes are real, queryable, and stylable DOM attributes. You can select them in CSS, query them in JS, and even animate style changes as their values change.
</v-click>
---
class: default
---
# Datastar and `data-*` Attributes
##
<v-click>
<p>
The <strong>Datastar</strong> library builds on a simple, native web standard: custom <code>data-*</code> attributes.
Each element can carry its own keyvalue store of information, making HTML itself the natural host for behavior and state.
</p>
</v-click>
<v-click>
<p>
Datastar defines <strong>21 <code>data-*</code> attributes</strong> that express intent directly in markup.
These attributes describe data flow, user interactions, and state transitions without writing imperative JavaScript.
</p>
</v-click>
<v-click>
<p>
The <em>values</em> of those attributes form a small, declarative <strong>DSL</strong> — called <em>Datastar Expressions</em>.
Each expression describes what should happen when data or events change, turning attributes into a layer of reactive logic.
</p>
</v-click>
<v-click>
<p>
By keeping logic inside the elements they affect, Datastar achieves <strong>locality of behavior</strong>
where markup, data, and interactivity remain close together.
This makes components easier to read, reason about, and maintain.
</p>
</v-click>
<v-click>
<p>
Datastar provides a set of <strong>built-in actions</strong> (helper functions) that can be used in Datastar expressions to interact with the backend.
The main actions are: <code>@get()</code>, <code>@post()</code>, <code>@put()</code>, <code>@patch()</code>, and <code>@delete()</code>.
</p>
```html
<button data-on:click="@get('/api/items')">Load Items</button>
```
</v-click>
---
layout: image
@ -451,4 +579,4 @@ image: /mariner.png
transition: slide-up
level: 2
---
# to be continued ...
# to be continued ...