package main import ( "flag" "fmt" "html" "html/template" "net/http" "os" "path/filepath" "regexp" "strings" ) // Choice beschreibt eine Entscheidungsmöglichkeit zu einem Ziel-Knoten type Choice struct { TargetID string `json:"targetId"` Text string `json:"text"` } // StoryNode repräsentiert einen einzelnen Knotenpunkt in der Geschichte type StoryNode struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Choices []Choice `json:"choices"` IsReboot bool `json:"isReboot"` IsEnd bool `json:"isEnd"` FileSource string `json:"fileSource"` } // StoryGraph hält alle Knoten und Analyse-Daten type StoryGraph struct { Nodes map[string]*StoryNode `json:"nodes"` StartNodeID string `json:"startNodeId"` BrokenLinks []string `json:"brokenLinks"` OrphanNodes []string `json:"orphanNodes"` DeadEndNodes []string `json:"deadEndNodes"` } func main() { dirPtr := flag.String("dir", ".", "Verzeichnis mit AsciiDoc oder Markdown Story-Dateien") outPtr := flag.String("out", "export/story.html", "Zielpfad für die generierte HTML-Datei") servePtr := flag.Bool("serve", false, "Startet einen lokalen Preview-Webserver auf -port") portPtr := flag.Int("port", 8080, "Port für den Preview-Webserver") flag.Parse() fmt.Println("\033[1;36m=== Computer Historyz :: CYOA Engine & Generator ===\033[0m") graph := loadStoryGraph(*dirPtr) validateGraph(graph) // Export-Verzeichnis anlegen falls nicht vorhanden outDir := filepath.Dir(*outPtr) if err := os.MkdirAll(outDir, 0755); err != nil { fmt.Printf("\033[1;31m[FEHLER]\033[0m Kann Ordner %s nicht erstellen: %v\n", outDir, err) os.Exit(1) } // Single-File HTML generieren err := generateHTML(graph, *outPtr) if err != nil { fmt.Printf("\033[1;31m[FEHLER]\033[0m Fehler beim Schreiben der HTML-Datei: %v\n", err) os.Exit(1) } fmt.Printf("\033[1;32m[ERFOLG]\033[0m Interaktive Story generiert -> \033[1;33m%s\033[0m\n", *outPtr) if *servePtr { startServer(graph, *outPtr, *portPtr, *dirPtr) } } // loadStoryGraph scannt das Verzeichnis nach .adoc und .md Dateien func loadStoryGraph(rootDir string) *StoryGraph { graph := &StoryGraph{ Nodes: make(map[string]*StoryNode), } // Regex-Muster für Knoten und Links // AsciiDoc: [[node_id]] gefolgt von === Titel // Markdown: # node_id oder [[node_id]] adocNodeRegex := regexp.MustCompile(`\[\[([a-zA-Z0-9_\-]+)\]\]`) adocChoiceRegex := regexp.MustCompile(`<<([a-zA-Z0-9_\-]+)\s*,\s*([^>]+)>>`) mdChoiceRegex := regexp.MustCompile(`\[([^\]]+)\]\((?:#)?([a-zA-Z0-9_\-]+)\)`) headingRegex := regexp.MustCompile(`^(?:={1,4}|#{1,4})\s+(.+)`) err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(path)) if ext != ".adoc" && ext != ".md" { return nil } contentBytes, err := os.ReadFile(path) if err != nil { return nil } rawContent := string(contentBytes) relPath, _ := filepath.Rel(rootDir, path) // Dateien anhand von [[node_id]]-Blöcken aufteilen lines := strings.Split(rawContent, "\n") var currentNode *StoryNode = nil var currentBody strings.Builder for _, line := range lines { // Prüfung auf neuen Knoten-Anker [[id]] if matches := adocNodeRegex.FindStringSubmatch(line); len(matches) > 1 { if currentNode != nil { currentNode.Content = cleanBodyText(currentBody.String()) graph.Nodes[currentNode.ID] = currentNode currentBody.Reset() } nodeID := matches[1] currentNode = &StoryNode{ ID: nodeID, Title: nodeID, Choices: make([]Choice, 0), FileSource: relPath, } if graph.StartNodeID == "" || nodeID == "start" || nodeID == "node_start" { graph.StartNodeID = nodeID } continue } if currentNode == nil { // Fallback: Erste Zeile oder Datei-Header als Standard-Start-Knoten verwenden if strings.TrimSpace(line) != "" { baseID := strings.TrimSuffix(filepath.Base(path), ext) currentNode = &StoryNode{ ID: baseID, Title: baseID, Choices: make([]Choice, 0), FileSource: relPath, } if graph.StartNodeID == "" { graph.StartNodeID = baseID } } else { continue } } // Titel extrahieren if hMatches := headingRegex.FindStringSubmatch(line); len(hMatches) > 1 && currentNode.Title == currentNode.ID { currentNode.Title = strings.TrimSpace(hMatches[1]) } // AsciiDoc Verzweigung finden: <> if cMatches := adocChoiceRegex.FindAllStringSubmatch(line, -1); len(cMatches) > 0 { for _, match := range cMatches { currentNode.Choices = append(currentNode.Choices, Choice{ TargetID: match[1], Text: strings.TrimSpace(match[2]), }) } } // Markdown Verzweigung finden: [Optionstext](#ziel_id) if mMatches := mdChoiceRegex.FindAllStringSubmatch(line, -1); len(mMatches) > 0 { for _, match := range mMatches { currentNode.Choices = append(currentNode.Choices, Choice{ TargetID: match[2], Text: strings.TrimSpace(match[1]), }) } } // Status-Kennzeichnungen erkennen lowerLine := strings.ToLower(line) if strings.Contains(lowerLine, "[reboot]") || strings.Contains(lowerLine, "speicherabbild") { currentNode.IsReboot = true } if strings.Contains(lowerLine, "[ende]") || strings.Contains(lowerLine, "[end]") { currentNode.IsEnd = true } currentBody.WriteString(line + "\n") } if currentNode != nil { currentNode.Content = cleanBodyText(currentBody.String()) graph.Nodes[currentNode.ID] = currentNode } return nil }) if err != nil { fmt.Printf("\033[1;31m[WAR NUNG]\033[0m Fehler beim Durchsuchen der Dateien: %v\n", err) } return graph } // cleanBodyText entfernt Syntax-Rauschen für die HTML-Darstellung func cleanBodyText(raw string) string { // Links entfernen/bereinigen adocChoiceRegex := regexp.MustCompile(`<<[a-zA-Z0-9_\-]+\s*,\s*([^>]+)>>`) cleaned := adocChoiceRegex.ReplaceAllString(raw, "") mdChoiceRegex := regexp.MustCompile(`\[([^\]]+)\]\((?:#)?[a-zA-Z0-9_\-]+\)`) cleaned = mdChoiceRegex.ReplaceAllString(cleaned, "") // HTML-Maskierung return html.EscapeString(strings.TrimSpace(cleaned)) } // validateGraph prüft Verzweigungen auf Konsistenz func validateGraph(g *StoryGraph) { fmt.Printf("Gefundene Knotenpunkte: \033[1;32m%d\033[0m | Start-Knoten: \033[1;33m%s\033[0m\n", len(g.Nodes), g.StartNodeID) referenced := make(map[string]bool) if g.StartNodeID != "" { referenced[g.StartNodeID] = true } for id, node := range g.Nodes { // Tote Links prüfen for _, choice := range node.Choices { referenced[choice.TargetID] = true if _, exists := g.Nodes[choice.TargetID]; !exists { g.BrokenLinks = append(g.BrokenLinks, fmt.Sprintf("%s -> %s (in %s)", id, choice.TargetID, node.FileSource)) } } // Sackgassen ohne Choices & ohne End-Markierung if len(node.Choices) == 0 && !node.IsReboot && !node.IsEnd { g.DeadEndNodes = append(g.DeadEndNodes, id) } } // Waisen-Knoten ermitteln for id := range g.Nodes { if !referenced[id] { g.OrphanNodes = append(g.OrphanNodes, id) } } // Konsolen-Bericht if len(g.BrokenLinks) > 0 { fmt.Println("\n\033[1;31m[!] DEFECKTE ZIEL-LINKS (Fehlende Knoten):\033[0m") for _, link := range g.BrokenLinks { fmt.Printf(" - %s\n", link) } } if len(g.OrphanNodes) > 0 { fmt.Println("\n\033[1;33m[?] UNERREICHBARE KNOTEN (Waisen):\033[0m") for _, orphan := range g.OrphanNodes { fmt.Printf(" - %s\n", orphan) } } if len(g.DeadEndNodes) > 0 { fmt.Println("\n\033[1;35m[*] SACKGASSEN OHNE OPTIONEN:\033[0m") for _, dead := range g.DeadEndNodes { fmt.Printf(" - %s\n", dead) } } if len(g.BrokenLinks) == 0 && len(g.OrphanNodes) == 0 && len(g.DeadEndNodes) == 0 { fmt.Println("\033[1;32m[✓] Graph-Check bestanden: Alle Verzweigungen sind konsistent!\033[0m") } } // generateHTML erzeugt das eigenständige HTML5 CRT-Terminal Schreibdeck func generateHTML(g *StoryGraph, outPath string) error { tmpl, err := template.New("story").Parse(htmlTemplate) if err != nil { return err } f, err := os.Create(outPath) if err != nil { return err } defer f.Close() return tmpl.Execute(f, g) } // startServer führt den Live-Preview-Webserver aus func startServer(g *StoryGraph, htmlPath string, port int, watchDir string) { addr := fmt.Sprintf(":%d", port) fmt.Printf("\n\033[1;34m[SERVER]\033[0m Live Terminal Web-Engine läuft unter: \033[1;32mhttp://localhost:%d\033[0m\n", port) fmt.Println("Drücke Strg+C zum Beenden.") http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Bei jedem Aufruf Graph neu laden für Hot-Reload Feeling freshGraph := loadStoryGraph(watchDir) _ = generateHTML(freshGraph, htmlPath) http.ServeFile(w, r, htmlPath) }) http.HandleFunc("/api/graph", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") freshGraph := loadStoryGraph(watchDir) // Einfacher JSON Output fmt.Fprintf(w, `{"nodes":%d, "start":"%s"}`, len(freshGraph.Nodes), freshGraph.StartNodeID) }) err := http.ListenAndServe(addr, nil) if err != nil { fmt.Printf("Server-Fehler: %v\n", err) } } // Integrierte Retro-CRT Engine Templates (HTML, CSS, JS) const htmlTemplate = ` Computer Historyz :: Interactive Terminal Engine
[COMPUTER HISTORYZ :: INSTANZ_TERMINAL]
SPEICHERABBILD: #001
HYDRAULIK: 100%
KNOTEN: -
System Initialisierung...
Verbinde mit Datenstrom...
=== INTERAKTIVER VORHERSAGE-GRAPH ===
`