more slides
This commit is contained in:
parent
ae0d8498ec
commit
176345e65a
5
experiments/tutorial/go.mod
Normal file
5
experiments/tutorial/go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module tutorial
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/google/uuid v1.6.0
|
||||
2
experiments/tutorial/go.sum
Normal file
2
experiments/tutorial/go.sum
Normal 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=
|
||||
720
experiments/tutorial/main.go
Normal file
720
experiments/tutorial/main.go
Normal 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
|
||||
}
|
||||
1
experiments/tutorial/notes.txt
Normal file
1
experiments/tutorial/notes.txt
Normal file
@ -0,0 +1 @@
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
|
||||
9
experiments/tutorial/static/datastar.js
Normal file
9
experiments/tutorial/static/datastar.js
Normal file
File diff suppressed because one or more lines are too long
7
experiments/tutorial/static/datastar.js.map
Normal file
7
experiments/tutorial/static/datastar.js.map
Normal file
File diff suppressed because one or more lines are too long
182
experiments/tutorial/templates/tutorial.html
Normal file
182
experiments/tutorial/templates/tutorial.html
Normal 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>% • 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 <h1> 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 (<h1>)</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 <h1>, <p>, <a> and others.</p>
|
||||
|
||||
<h3>Key ideas</h3>
|
||||
<ul>
|
||||
<li>HTML is markup, not a programming language.</li>
|
||||
<li>Elements use tags: <tag>content</tag>.</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 <h1></h2>
|
||||
<p>Finish the HTML snippet so the page shows a heading that reads <strong>Hello World</strong>.</p>
|
||||
|
||||
<pre class="code"><!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<!-- add a heading below -->
|
||||
{{printf "%s" ""}}
|
||||
</body>
|
||||
</html></pre>
|
||||
|
||||
<textarea id="code" placeholder="Type your HTML here"><h1>Hello World</h1></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}}
|
||||
|
||||
517
experiments/tutorial/tutorial old.txt
Normal file
517
experiments/tutorial/tutorial old.txt
Normal 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><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Page Title</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>My First Heading</h1>
|
||||
<p>My first paragraph.</p>
|
||||
</body>
|
||||
</html></pre>
|
||||
</div>
|
||||
|
||||
<h3>Let's break this down:</h3>
|
||||
<ul style="margin: 15px 0 15px 30px; line-height: 2;">
|
||||
<li><code><!DOCTYPE html></code> - Tells the browser this is HTML5</li>
|
||||
<li><code><html></code> - The root element, contains everything</li>
|
||||
<li><code><head></code> - Contains metadata (title, links to CSS, etc.)</li>
|
||||
<li><code><body></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><h1></code> to <code><h6></code> - Headings (h1 is largest)</p>
|
||||
<p><code><p></code> - Paragraph</p>
|
||||
<p><code><a></code> - Link</p>
|
||||
<p><code><img></code> - Image</p>
|
||||
<p><code><div></code> - Division/container</p>
|
||||
<p><code><span></code> - Inline container</p>
|
||||
</div>
|
||||
|
||||
<h3>Tag Anatomy:</h3>
|
||||
<div class="code-output">
|
||||
<pre><p>This is a paragraph</p>
|
||||
↑ ↑ ↑
|
||||
Opening Content Closing
|
||||
Tag Tag</pre>
|
||||
</div>
|
||||
|
||||
<p>Most tags come in pairs, but some are <strong>self-closing</strong> like <code><img></code> and <code><br></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><h1></code> and a closing tag <code></h1></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><a href="https://example.com">Click me</a>
|
||||
↑
|
||||
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><a></code> tags)</p>
|
||||
<p><code>src</code> - Image source (for <code><img></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><img src="logo.png" alt="Company Logo" width="200"></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><p>Your text here</p></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><!-- Unordered List -->
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ordered List -->
|
||||
<ol>
|
||||
<li>First</li>
|
||||
<li>Second</li>
|
||||
</ol></pre>
|
||||
</div>
|
||||
|
||||
<h3>Semantic HTML5 Tags:</h3>
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin: 20px 0;">
|
||||
<p><code><header></code> - Page or section header</p>
|
||||
<p><code><nav></code> - Navigation links</p>
|
||||
<p><code><main></code> - Main content</p>
|
||||
<p><code><article></code> - Self-contained content</p>
|
||||
<p><code><section></code> - Thematic grouping</p>
|
||||
<p><code><footer></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}}
|
||||
@ -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>
|
||||
<div id="foo" data-<span v-mark.circle.orange="4">what-ever</span>= <span v-mark.circle.red="5">"Any String you want"</span> > Lorem ... </div>
|
||||
</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 key–value 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 key–value 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user