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: "&copy; 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)
	}
}