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 := &sects[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 := &sects[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, "")
}