Mercurial Hosting > qwb
view qwb.go @ 4:ce2b6dde4c10
remove nesting restriction + per-page header
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Sat, 14 Mar 2026 14:01:51 +0500 |
| parents | 3222f88c0afe |
| children | 125e599b1217 |
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="{{CSS}}"> </head> <body> <nav>{{NAV}}</nav> <header><h1>{{SITE_TITLE}}</h1></header> <main>{{CONTENT}}</main> <footer> <p>{{FOOTER_TEXT}}</p> <p>Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p> </footer> </body> </html>` type config struct { headertext string footertext string cssfile string tplfile string } type section struct { title string index string pages []page } type page struct { title string path string } func ParseINI(r io.Reader) (map[string]map[string]string, error) { res := make(map[string]map[string]string) sec := "default" scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { sec = line[1 : len(line)-1] if res[sec] == nil { res[sec] = make(map[string]string) } continue } if k, v, ok := strings.Cut(line, "="); ok { if res[sec] == nil { res[sec] = make(map[string]string) } res[sec][strings.TrimSpace(k)] = strings.TrimSpace(v) } } return res, scanner.Err() } func loadconfig(src string) config { cfg := config{ headertext: "My Site", footertext: "© Me", cssfile: "/style.css", } f, err := os.Open(filepath.Join(src, "qwb.ini")) if err != nil { return cfg } defer f.Close() ini, err := ParseINI(f) if err != nil { return cfg } s := ini["site"] set := func(key string, target *string) { if v, ok := s[key]; ok { *target = v } } set("header", &cfg.headertext) set("footer", &cfg.footertext) set("style", &cfg.cssfile) set("template", &cfg.tplfile) return cfg } func loadtemplate(cfg config) string { if cfg.tplfile == "" { return tpldefault } b, err := os.ReadFile(cfg.tplfile) if err != nil { fmt.Fprintf(os.Stderr, "cannot read template %s: %v", cfg.tplfile, err) return tpldefault } return string(b) } func collectsections(root, siteTitle string) (section, []section) { var subs []section root_ := section{title: siteTitle} entries, _ := os.ReadDir(root) for _, e := range entries { full := filepath.Join(root, e.Name()) if e.IsDir() { s := scansection(full, root) if s.index != "" || len(s.pages) > 0 { subs = append(subs, s) } continue } if !strings.HasSuffix(e.Name(), ".md") { continue } if e.Name() == "index.md" { root_.index = "/index.html" } else { rel, _ := filepath.Rel(root, full) root_.pages = append(root_.pages, page{ title: titlefromname(e.Name()), path: "/" + strings.TrimSuffix(rel, ".md") + ".html", }) } } return root_, subs } func scansection(dir, root string) section { s := section{title: titlefromname(filepath.Base(dir))} entries, _ := os.ReadDir(dir) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name())) htmlpath := "/" + strings.TrimSuffix(rel, ".md") + ".html" if e.Name() == "index.md" { s.index = htmlpath } else { s.pages = append(s.pages, page{titlefromname(e.Name()), htmlpath}) } } return s } func navlink(b *strings.Builder, p page, cur string) { if p.path == cur { fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title) } else { fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title) } } func buildnav(root section, subs []section, cur string) string { var b strings.Builder b.WriteString("<ul>\n") if root.index != "" { navlink(&b, page{"Home", root.index}, cur) } for _, p := range root.pages { navlink(&b, p, cur) } for _, s := range subs { link := s.index if link == "" && len(s.pages) > 0 { link = s.pages[0].path } if link == "" { continue } navlink(&b, page{s.title, link}, cur) } b.WriteString("</ul>\n") for _, s := range subs { if !sectioncontains(s, cur) { continue } total := len(s.pages) if s.index != "" { total++ } b.WriteString("<ul>\n") if s.index != "" { navlink(&b, page{"Index", s.index}, cur) } for _, p := range s.pages { navlink(&b, p, cur) } b.WriteString("</ul>\n") break } return b.String() } func sectioncontains(s section, cur string) bool { if s.index == cur { return true } for _, p := range s.pages { if p.path == cur { return true } } return false } func extracth1(html string) (title, rest string) { start := strings.Index(html, "<h1") if start == -1 { return "", html } close := strings.Index(html[start:], ">") if close == -1 { return "", html } content := start + close + 1 end := strings.Index(html[content:], "</h1>") if end == -1 { return "", html } title = html[content : content+end] rest = html[:start] + html[content+end+len("</h1>"):] return } func mdtohtml(path string) (string, error) { cmd := exec.Command("lowdown", "-T", "html", "--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 = f cmd.Stdout = &buf cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", err } return buf.String(), nil } func titlefromname(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 fixlinks(s string) string { return strings.NewReplacer( ".md)", ".html)", ".md\"", ".html\"", ".md'", ".html'", ".md#", ".html#", ".md>", ".html>", ".md ", ".html ", ".md,", ".html,", ).Replace(s) } func copyfile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } func main() { if len(os.Args) != 3 { fmt.Fprintln(os.Stderr, "usage: qwb <in> <out>") os.Exit(1) } src, out := os.Args[1], os.Args[2] if entries, err := os.ReadDir(out); err == nil && len(entries) > 0 { fmt.Fprintf(os.Stderr, "qwb: %s is not empty, overwrite? [y/N] ", out) s, _ := bufio.NewReader(os.Stdin).ReadString('\n') if strings.TrimSpace(strings.ToLower(s)) != "y" { fmt.Fprintln(os.Stderr, "aborted") os.Exit(1) } if err := os.RemoveAll(out); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } cfg := loadconfig(src) tmpl := loadtemplate(cfg) rootsec, subs := collectsections(src, cfg.headertext) err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { if err != nil { return err } rel, _ := filepath.Rel(src, path) outpath := filepath.Join(out, rel) if d.IsDir() { return os.MkdirAll(outpath, 0755) } if !strings.HasSuffix(path, ".md") { return copyfile(path, outpath) } body, err := mdtohtml(path) if err != nil { return err } body = fixlinks(body) pageTitle, body := extracth1(body) if pageTitle == "" { pageTitle = cfg.headertext } cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" title := cfg.headertext if filepath.Base(path) != "index.md" { title = cfg.headertext + " | " + pageTitle } pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title) pg = strings.ReplaceAll(pg, "{{SITE_TITLE}}", pageTitle) pg = strings.ReplaceAll(pg, "{{FOOTER_TEXT}}", cfg.footertext) pg = strings.ReplaceAll(pg, "{{CSS}}", cfg.cssfile) pg = strings.ReplaceAll(pg, "{{NAV}}", buildnav(rootsec, subs, cur)) pg = strings.ReplaceAll(pg, "{{CONTENT}}", body) outpath = strings.TrimSuffix(outpath, ".md") + ".html" return os.WriteFile(outpath, []byte(pg), 0644) }) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }
