comparison qwb.go @ 0:ac64aa92dea1

initial
author Atarwn Gard <a@qwa.su>
date Fri, 13 Mar 2026 13:13:07 +0500
parents
children 3222f88c0afe
comparison
equal deleted inserted replaced
-1:000000000000 0:ac64aa92dea1
1 package main
2
3 import (
4 "bufio"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 )
12
13 const siteTitle = "My Site"
14
15 const tmpl = `<!DOCTYPE html>
16 <html lang="en">
17 <head>
18 <meta charset="UTF-8">
19 <meta name="viewport" content="width=device-width, initial-scale=1">
20 <title>{{TITLE}}</title>
21 <link rel="stylesheet" href="/x.css">
22 </head>
23 <body>
24 <header><h1>` + siteTitle + `</h1></header>
25 <nav>{{NAV}}</nav>
26 <main>{{CONTENT}}</main>
27 <footer>
28 <p>` + siteTitle + `</p>
29 <p>&copy; Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p>
30 </footer>
31 </body>
32 </html>`
33
34 // section — top-level directory, each with its own index and pages
35 type section struct {
36 title string
37 index string // /section/index.html, or "" if none
38 pages []page // non-index .md files within this section
39 }
40
41 type page struct {
42 title string
43 path string
44 }
45
46 // collectSections scans one level deep: root files go into a synthetic root
47 // section, each subdirectory becomes its own section.
48 func collectSections(root string) (rootSection section, subs []section) {
49 rootSection.title = siteTitle
50
51 entries, _ := os.ReadDir(root)
52 for _, e := range entries {
53 full := filepath.Join(root, e.Name())
54 if e.IsDir() {
55 s := scanSection(full, root)
56 if s.index != "" || len(s.pages) > 0 {
57 subs = append(subs, s)
58 }
59 continue
60 }
61 if strings.HasSuffix(e.Name(), ".md") {
62 if e.Name() == "index.md" {
63 rootSection.index = "/index.html"
64 } else {
65 rel, _ := filepath.Rel(root, full)
66 rootSection.pages = append(rootSection.pages, page{
67 title: titleFromName(e.Name()),
68 path: "/" + strings.TrimSuffix(rel, ".md") + ".html",
69 })
70 }
71 }
72 }
73 return
74 }
75
76 func scanSection(dir, root string) section {
77 s := section{title: titleFromName(filepath.Base(dir))}
78 entries, _ := os.ReadDir(dir)
79 for _, e := range entries {
80 if e.IsDir() {
81 continue // only one level deep
82 }
83 if !strings.HasSuffix(e.Name(), ".md") {
84 continue
85 }
86 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name()))
87 htmlPath := "/" + strings.TrimSuffix(rel, ".md") + ".html"
88 if e.Name() == "index.md" {
89 s.index = htmlPath
90 } else {
91 s.pages = append(s.pages, page{titleFromName(e.Name()), htmlPath})
92 }
93 }
94 return s
95 }
96
97 func navLink(b *strings.Builder, p page, cur string) {
98 if p.path == cur {
99 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title)
100 } else {
101 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title)
102 }
103 }
104
105 // buildNav produces one or two <ul> blocks.
106 // Top <ul>: root index + root loose pages + one entry per sub-section (via its index).
107 // Second <ul>: pages of the active sub-section, only when section has 2+ pages.
108 func buildNav(rootSec section, subs []section, cur string) string {
109 var b strings.Builder
110
111 b.WriteString("<ul>\n")
112 if rootSec.index != "" {
113 navLink(&b, page{"Home", rootSec.index}, cur)
114 }
115 for _, p := range rootSec.pages {
116 navLink(&b, p, cur)
117 }
118 for _, s := range subs {
119 link := s.index
120 if link == "" && len(s.pages) > 0 {
121 link = s.pages[0].path
122 }
123 if link == "" {
124 continue
125 }
126 navLink(&b, page{s.title, link}, cur)
127 }
128 b.WriteString("</ul>\n")
129
130 // Find which sub-section cur belongs to
131 for _, s := range subs {
132 if !sectionContains(s, cur) {
133 continue
134 }
135 // Only render sub-nav if there are 2+ addressable pages in the section
136 total := len(s.pages)
137 if s.index != "" {
138 total++
139 }
140 if total < 2 {
141 break
142 }
143 b.WriteString("<ul>\n")
144 if s.index != "" {
145 navLink(&b, page{"Index", s.index}, cur)
146 }
147 for _, p := range s.pages {
148 navLink(&b, p, cur)
149 }
150 b.WriteString("</ul>\n")
151 break
152 }
153
154 return b.String()
155 }
156
157 func sectionContains(s section, cur string) bool {
158 if s.index == cur {
159 return true
160 }
161 for _, p := range s.pages {
162 if p.path == cur {
163 return true
164 }
165 }
166 return false
167 }
168
169 func mdToHTML(path string) (string, error) {
170 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml")
171 f, err := os.Open(path)
172 if err != nil {
173 return "", err
174 }
175 defer f.Close()
176 var buf strings.Builder
177 cmd.Stdin = f
178 cmd.Stdout = &buf
179 cmd.Stderr = os.Stderr
180 if err := cmd.Run(); err != nil {
181 return "", err
182 }
183 return buf.String(), nil
184 }
185
186 func titleFromName(name string) string {
187 name = strings.TrimSuffix(name, ".md")
188 name = strings.ReplaceAll(name, "-", " ")
189 if len(name) > 0 {
190 name = strings.ToUpper(name[:1]) + name[1:]
191 }
192 return name
193 }
194
195 func fixLinks(s string) string {
196 return strings.NewReplacer(
197 ".md)", ".html)", ".md\"", ".html\"",
198 ".md'", ".html'", ".md#", ".html#",
199 ".md>", ".html>", ".md ", ".html ",
200 ".md,", ".html,",
201 ).Replace(s)
202 }
203
204 func copyFile(src, dst string) error {
205 in, err := os.Open(src)
206 if err != nil {
207 return err
208 }
209 defer in.Close()
210 out, err := os.Create(dst)
211 if err != nil {
212 return err
213 }
214 defer out.Close()
215 _, err = io.Copy(out, in)
216 return err
217 }
218
219 func main() {
220 if len(os.Args) != 3 {
221 fmt.Fprintln(os.Stderr, "usage: qwb <in> <out>")
222 os.Exit(1)
223 }
224 src, out := os.Args[1], os.Args[2]
225
226 if entries, err := os.ReadDir(out); err == nil && len(entries) > 0 {
227 fmt.Fprintf(os.Stderr, "qwb: %s is not empty, overwrite? [y/N] ", out)
228 s, _ := bufio.NewReader(os.Stdin).ReadString('\n')
229 if strings.TrimSpace(strings.ToLower(s)) != "y" {
230 fmt.Fprintln(os.Stderr, "aborted")
231 os.Exit(1)
232 }
233 if err := os.RemoveAll(out); err != nil {
234 fmt.Fprintln(os.Stderr, err)
235 os.Exit(1)
236 }
237 }
238
239 rootSec, subs := collectSections(src)
240
241 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
242 if err != nil {
243 return err
244 }
245 rel, _ := filepath.Rel(src, path)
246 outpath := filepath.Join(out, rel)
247
248 if d.IsDir() {
249 return os.MkdirAll(outpath, 0755)
250 }
251
252 if !strings.HasSuffix(path, ".md") {
253 return copyFile(path, outpath)
254 }
255
256 body, err := mdToHTML(path)
257 if err != nil {
258 return err
259 }
260 body = fixLinks(body)
261
262 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html"
263
264 title := siteTitle
265 if filepath.Base(path) != "index.md" {
266 title = siteTitle + " | " + titleFromName(filepath.Base(path))
267 }
268
269 pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title)
270 pg = strings.ReplaceAll(pg, "{{NAV}}", buildNav(rootSec, subs, cur))
271 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body)
272
273 outpath = strings.TrimSuffix(outpath, ".md") + ".html"
274 return os.WriteFile(outpath, []byte(pg), 0644)
275 })
276
277 if err != nil {
278 fmt.Fprintln(os.Stderr, err)
279 os.Exit(1)
280 }
281 }