Mercurial Hosting > qwb
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>© 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 } |
