Mercurial Hosting > qwb
view qwb.go @ 7:d139d86fb4e1 default tip
html in md
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Tue, 17 Mar 2026 23:46:56 +0500 |
| parents | bd0d3a189f5b |
| children |
line wrap: on
line source
package main import ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "strings" ) const tpldefault = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{TITLE}}</title> <link rel="stylesheet" href="/x.css"> </head> <body> <nav>{{NAV}}</nav> <header><h1>{{PAGE_TITLE}}</h1></header> <main>{{CONTENT}}</main> <footer><p>{{FOOTER_TEXT}}</p><p>Built with <a href="https://hg.reactionary.software/repo/qwb/">qwb</a></p></footer> </body> </html>` type config struct { SiteTitle, FooterText string } type sect struct { title string href string pages []page hasIndex bool children []sect } type page struct { title string href string } func parseini(r io.Reader) map[string]string { res := make(map[string]string) sc := bufio.NewScanner(r) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { continue } if k, v, ok := strings.Cut(line, "="); ok { res[strings.TrimSpace(k)] = strings.TrimSpace(v) } } return res } func loadcfg(src string) config { cfg := config{SiteTitle: "My Site", FooterText: "© 2026"} f, err := os.Open(filepath.Join(src, "qwb.ini")) if err == nil { defer f.Close() ini := parseini(f) if v, ok := ini["SiteTitle"]; ok { cfg.SiteTitle = v } if v, ok := ini["FooterText"]; ok { cfg.FooterText = v } } return cfg } func gentitle(name string) string { name = strings.TrimSuffix(name, ".md") name = strings.ReplaceAll(name, "-", " ") if len(name) > 0 { name = strings.ToUpper(name[:1]) + name[1:] } return name } func md2html(path string) (string, error) { cmd := exec.Command("lowdown", "-Thtml", "--html-no-skiphtml", "--html-no-escapehtml") f, err := os.Open(path) if err != nil { return "", err } defer f.Close() var buf strings.Builder cmd.Stdin, cmd.Stdout = f, &buf if err := cmd.Run(); err != nil { return "", err } html := buf.String() if trimmed := strings.TrimSpace(html); strings.HasPrefix(trimmed, "<h1") { if end := strings.Index(trimmed, "</h1>"); end >= 0 { html = strings.TrimSpace(trimmed[end+5:]) } } return html, nil } func mdtitle(path string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if strings.HasPrefix(line, "# ") { return strings.TrimSpace(line[2:]) } } return "" } //, func mdnavhref(path string) (string, bool) { f, err := os.Open(path) if err != nil { return "", false } defer f.Close() sc := bufio.NewScanner(f) if !sc.Scan() { return "", false } line := strings.TrimSpace(sc.Text()) if strings.Contains(line, "://") { return line, true } return "", false } func scansrc(src string) ([]sect, error) { root := sect{title: "Home", href: "/index.html"} var walk func(dir, rel string, s *sect) error walk = func(dir, rel string, s *sect) error { entries, err := os.ReadDir(dir) if err != nil { return err } for _, e := range entries { name := e.Name() childRel := filepath.Join(rel, name) childAbs := filepath.Join(dir, name) if e.IsDir() { child := sect{ title: gentitle(name), href: "/" + filepath.ToSlash(childRel) + "/index.html", } if err := walk(childAbs, childRel, &child); err != nil { return err } s.children = append(s.children, child) continue } if !strings.HasSuffix(name, ".md") { continue } if name == "index.md" { s.hasIndex = true if ext, ok := mdnavhref(childAbs); ok { s.href = ext } } else { href := "/" + filepath.ToSlash(strings.TrimSuffix(childRel, ".md")+".html") if ext, ok := mdnavhref(childAbs); ok { href = ext } s.pages = append(s.pages, page{ title: gentitle(name), href: href, }) } } return nil } if err := walk(src, "", &root); err != nil { return nil, err } return []sect{root}, nil } func findCurSect(sects []sect, cur string) *sect { for i := range sects { s := §s[i] if s.href == cur { return s } for _, p := range s.pages { if p.href == cur { return s } } if found := findCurSect(s.children, cur); found != nil { return found } } return nil } func navX(roots []sect, cur string) string { if len(roots) == 0 { return "" } root := &roots[0] var path []*sect var findPath func(s *sect) bool findPath = func(s *sect) bool { path = append(path, s) if s.href == cur { return true } for _, p := range s.pages { if p.href == cur { return true } } for i := range s.children { if findPath(&s.children[i]) { return true } } path = path[:len(path)-1] return false } if !findPath(root) { return "" } sectLI := func(b *strings.Builder, s *sect, active bool) { b.WriteString(" <li>") if s.hasIndex { cls := "" if active { cls = " current" } b.WriteString(`<a` + cls + ` href="` + s.href + `">` + s.title + `</a>`) } else { b.WriteString(s.title) } b.WriteString("</li>\n") } var b strings.Builder { var active1 *sect if len(path) > 1 { active1 = path[1] } b.WriteString("<ul>\n") sectLI(&b, root, len(path) == 1) for i := range root.children { c := &root.children[i] sectLI(&b, c, c == active1) } b.WriteString("</ul>\n") } for depth := 1; depth < len(path); depth++ { s := path[depth] if len(s.children) == 0 && len(s.pages) == 0 { continue } var activeChild *sect if depth+1 < len(path) { activeChild = path[depth+1] } b.WriteString("<ul>\n") for i := range s.children { c := &s.children[i] sectLI(&b, c, c == activeChild) } for _, p := range s.pages { cls := "" if p.href == cur { cls = " current" } b.WriteString(` <li><a` + cls + ` href="` + p.href + `">` + p.title + `</a></li>` + "\n") } b.WriteString("</ul>\n") } return b.String() } func navY(roots []sect, cur string) string { curSect := findCurSect(roots, cur) var isAncestor func(s *sect) bool isAncestor = func(s *sect) bool { if s == curSect { return true } for i := range s.children { if isAncestor(&s.children[i]) { return true } } return false } var b strings.Builder var renderSects func(sects []sect, depth int) renderSects = func(sects []sect, depth int) { indent := strings.Repeat(" ", depth) b.WriteString(indent + "<ul>\n") for i := range sects { s := §s[i] b.WriteString(indent + " <li>") if s.hasIndex { cls := "" if s == curSect { cls = " current" } b.WriteString(`<a` + cls + ` href="` + s.href + `">` + s.title + `</a>`) } else { b.WriteString(s.title) } if isAncestor(s) { if len(s.pages) > 0 || len(s.children) > 0 { b.WriteString("\n") if len(s.children) > 0 { renderSects(s.children, depth+2) } if len(s.pages) > 0 { b.WriteString(indent + " <ul>\n") for _, p := range s.pages { cls := "" if p.href == cur { cls = " current" } b.WriteString(indent + ` <li><a` + cls + ` href="` + p.href + `">` + p.title + `</a></li>` + "\n") } b.WriteString(indent + " </ul>\n") } b.WriteString(indent + " ") } } b.WriteString("</li>\n") } b.WriteString(indent + "</ul>\n") } renderSects(roots, 0) return b.String() } func render(tpl, pageTitle, nav, content string, cfg config) string { compositeTitle := cfg.SiteTitle + " | " + pageTitle r := strings.NewReplacer( "{{TITLE}}", compositeTitle, "{{NAV}}", nav, "{{PAGE_TITLE}}", pageTitle, "{{CONTENT}}", content, "{{FOOTER_TEXT}}", cfg.FooterText, ) return r.Replace(tpl) } func writepage(outpath, html string) error { if err := os.MkdirAll(filepath.Dir(outpath), 0755); err != nil { return err } return os.WriteFile(outpath, []byte(html), 0644) } func main() { if len(os.Args) < 3 { fmt.Println("usage: qwb <src> <out> [-x|-y]") return } src, out := os.Args[1], os.Args[2] mode := "x" if len(os.Args) == 4 { switch os.Args[3] { case "-x": mode = "x" case "-y": mode = "y" default: fmt.Println("usage: qwb <src> <out> [-x|-y]") return } } cfg := loadcfg(src) sects, err := scansrc(src) if err != nil { fmt.Fprintln(os.Stderr, "scan:", err) os.Exit(1) } var process func(dir, rel string) process = func(dir, rel string) { entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { name := e.Name() childRel := filepath.Join(rel, name) childAbs := filepath.Join(dir, name) if e.IsDir() { process(childAbs, childRel) continue } if !strings.HasSuffix(name, ".md") { dst := filepath.Join(out, childRel) _ = os.MkdirAll(filepath.Dir(dst), 0755) if data, err := os.ReadFile(childAbs); err == nil { _ = os.WriteFile(dst, data, 0644) } continue } htmlRel := strings.TrimSuffix(childRel, ".md") + ".html" href := "/" + filepath.ToSlash(htmlRel) var nav string if mode == "y" { nav = navY(sects, href) } else { nav = navX(sects, href) } pageTitle := mdtitle(childAbs) if pageTitle == "" { pageTitle = gentitle(name) } content, err := md2html(childAbs) if err != nil { fmt.Fprintf(os.Stderr, "md2html %s: %v\n", childAbs, err) content = "<p>render error</p>" } html := render(tpldefault, pageTitle, nav, content, cfg) outpath := filepath.Join(out, htmlRel) if err := writepage(outpath, html); err != nil { fmt.Fprintf(os.Stderr, "write %s: %v\n", outpath, err) } else { fmt.Println("→", outpath) } } } process(src, "") }
