view 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 source

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)
	}
}