Mercurial Hosting > qwb
diff qwb.go @ 0:ac64aa92dea1
initial
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Fri, 13 Mar 2026 13:13:07 +0500 |
| parents | |
| children | 3222f88c0afe |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/qwb.go Fri Mar 13 13:13:07 2026 +0500 @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const siteTitle = "My Site" + +const tmpl = `<!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> +<header><h1>` + siteTitle + `</h1></header> +<nav>{{NAV}}</nav> +<main>{{CONTENT}}</main> +<footer> +<p>` + siteTitle + `</p> +<p>© Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p> +</footer> +</body> +</html>` + +// section — top-level directory, each with its own index and pages +type section struct { + title string + index string // /section/index.html, or "" if none + pages []page // non-index .md files within this section +} + +type page struct { + title string + path string +} + +// collectSections scans one level deep: root files go into a synthetic root +// section, each subdirectory becomes its own section. +func collectSections(root string) (rootSection section, subs []section) { + rootSection.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") { + if e.Name() == "index.md" { + rootSection.index = "/index.html" + } else { + rel, _ := filepath.Rel(root, full) + rootSection.pages = append(rootSection.pages, page{ + title: titleFromName(e.Name()), + path: "/" + strings.TrimSuffix(rel, ".md") + ".html", + }) + } + } + } + return +} + +func scanSection(dir, root string) section { + s := section{title: titleFromName(filepath.Base(dir))} + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.IsDir() { + continue // only one level deep + } + if !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) + } +} + +// buildNav produces one or two <ul> blocks. +// Top <ul>: root index + root loose pages + one entry per sub-section (via its index). +// Second <ul>: pages of the active sub-section, only when section has 2+ pages. +func buildNav(rootSec section, subs []section, cur string) string { + var b strings.Builder + + b.WriteString("<ul>\n") + if rootSec.index != "" { + navLink(&b, page{"Home", rootSec.index}, cur) + } + for _, p := range rootSec.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") + + // Find which sub-section cur belongs to + for _, s := range subs { + if !sectionContains(s, cur) { + continue + } + // Only render sub-nav if there are 2+ addressable pages in the section + total := len(s.pages) + if s.index != "" { + total++ + } + if total < 2 { + break + } + 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 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) + } + } + + rootSec, subs := collectSections(src) + + 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) + + cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" + + title := siteTitle + if filepath.Base(path) != "index.md" { + title = siteTitle + " | " + titleFromName(filepath.Base(path)) + } + + pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title) + 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) + } +} \ No newline at end of file
