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>&copy; 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