Mercurial Hosting > qwb
annotate qwb.go @ 6:bd0d3a189f5b
my deadass removed "built with" from defautl template (i need to go to sleep)
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Tue, 17 Mar 2026 22:36:17 +0500 |
| parents | 125e599b1217 |
| children | d139d86fb4e1 |
| rev | line source |
|---|---|
| 0 | 1 package main |
| 2 | |
| 3 import ( | |
| 4 "bufio" | |
| 5 "fmt" | |
| 6 "io" | |
| 7 "os" | |
| 8 "os/exec" | |
| 9 "path/filepath" | |
| 10 "strings" | |
| 11 ) | |
| 12 | |
| 2 | 13 const tpldefault = `<!DOCTYPE html> |
| 0 | 14 <html lang="en"> |
| 15 <head> | |
| 16 <meta charset="UTF-8"> | |
| 17 <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 18 <title>{{TITLE}}</title> | |
| 5 | 19 <link rel="stylesheet" href="/x.css"> |
| 0 | 20 </head> |
| 21 <body> | |
| 22 <nav>{{NAV}}</nav> | |
| 5 | 23 <header><h1>{{PAGE_TITLE}}</h1></header> |
| 0 | 24 <main>{{CONTENT}}</main> |
|
6
bd0d3a189f5b
my deadass removed "built with" from defautl template (i need to go to sleep)
Atarwn Gard <a@qwa.su>
parents:
5
diff
changeset
|
25 <footer><p>{{FOOTER_TEXT}}</p><p>Built with <a href="https://hg.reactionary.software/repo/qwb/">qwb</a></p></footer> |
| 0 | 26 </body> |
| 27 </html>` | |
| 28 | |
| 2 | 29 type config struct { |
| 5 | 30 SiteTitle, FooterText string |
| 2 | 31 } |
| 32 | |
| 5 | 33 type sect struct { |
| 0 | 34 title string |
| 5 | 35 |
| 36 href string | |
| 37 | |
| 2 | 38 pages []page |
| 5 | 39 |
| 40 hasIndex bool | |
| 41 | |
| 42 children []sect | |
| 0 | 43 } |
| 44 | |
| 45 type page struct { | |
| 46 title string | |
| 5 | 47 href string |
| 0 | 48 } |
| 49 | |
| 5 | 50 func parseini(r io.Reader) map[string]string { |
| 51 res := make(map[string]string) | |
| 52 sc := bufio.NewScanner(r) | |
| 53 for sc.Scan() { | |
| 54 line := strings.TrimSpace(sc.Text()) | |
| 55 if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { | |
| 2 | 56 continue |
| 57 } | |
| 58 if k, v, ok := strings.Cut(line, "="); ok { | |
| 5 | 59 res[strings.TrimSpace(k)] = strings.TrimSpace(v) |
| 2 | 60 } |
| 61 } | |
| 5 | 62 return res |
| 2 | 63 } |
| 0 | 64 |
| 5 | 65 func loadcfg(src string) config { |
| 66 cfg := config{SiteTitle: "My Site", FooterText: "© 2026"} | |
| 2 | 67 f, err := os.Open(filepath.Join(src, "qwb.ini")) |
| 5 | 68 if err == nil { |
| 69 defer f.Close() | |
| 70 ini := parseini(f) | |
| 71 if v, ok := ini["SiteTitle"]; ok { | |
| 72 cfg.SiteTitle = v | |
| 73 } | |
| 74 if v, ok := ini["FooterText"]; ok { | |
| 75 cfg.FooterText = v | |
| 2 | 76 } |
| 77 } | |
| 78 return cfg | |
| 79 } | |
| 80 | |
| 5 | 81 func gentitle(name string) string { |
| 0 | 82 name = strings.TrimSuffix(name, ".md") |
| 83 name = strings.ReplaceAll(name, "-", " ") | |
| 84 if len(name) > 0 { | |
| 85 name = strings.ToUpper(name[:1]) + name[1:] | |
| 86 } | |
| 87 return name | |
| 88 } | |
| 89 | |
| 5 | 90 func md2html(path string) (string, error) { |
| 91 cmd := exec.Command("lowdown", "-Thtml", "--html-no-skiphtml") | |
| 92 f, err := os.Open(path) | |
| 93 if err != nil { | |
| 94 return "", err | |
| 95 } | |
| 96 defer f.Close() | |
| 97 var buf strings.Builder | |
| 98 cmd.Stdin, cmd.Stdout = f, &buf | |
| 99 if err := cmd.Run(); err != nil { | |
| 100 return "", err | |
| 101 } | |
| 102 html := buf.String() | |
| 103 | |
| 104 if trimmed := strings.TrimSpace(html); strings.HasPrefix(trimmed, "<h1") { | |
| 105 if end := strings.Index(trimmed, "</h1>"); end >= 0 { | |
| 106 html = strings.TrimSpace(trimmed[end+5:]) | |
| 107 } | |
| 108 } | |
| 109 return html, nil | |
| 110 } | |
| 111 | |
| 112 func mdtitle(path string) string { | |
| 113 f, err := os.Open(path) | |
| 114 if err != nil { | |
| 115 return "" | |
| 116 } | |
| 117 defer f.Close() | |
| 118 sc := bufio.NewScanner(f) | |
| 119 for sc.Scan() { | |
| 120 line := strings.TrimSpace(sc.Text()) | |
| 121 if strings.HasPrefix(line, "# ") { | |
| 122 return strings.TrimSpace(line[2:]) | |
| 123 } | |
| 124 } | |
| 125 return "" | |
| 126 } | |
| 127 | |
| 128 //, | |
| 129 | |
| 130 func mdnavhref(path string) (string, bool) { | |
| 131 f, err := os.Open(path) | |
| 132 if err != nil { | |
| 133 return "", false | |
| 134 } | |
| 135 defer f.Close() | |
| 136 sc := bufio.NewScanner(f) | |
| 137 if !sc.Scan() { | |
| 138 return "", false | |
| 139 } | |
| 140 line := strings.TrimSpace(sc.Text()) | |
| 141 if strings.Contains(line, "://") { | |
| 142 return line, true | |
| 143 } | |
| 144 return "", false | |
| 145 } | |
| 146 | |
| 147 func scansrc(src string) ([]sect, error) { | |
| 148 root := sect{title: "Home", href: "/index.html"} | |
| 149 | |
| 150 var walk func(dir, rel string, s *sect) error | |
| 151 walk = func(dir, rel string, s *sect) error { | |
| 152 entries, err := os.ReadDir(dir) | |
| 153 if err != nil { | |
| 154 return err | |
| 155 } | |
| 156 for _, e := range entries { | |
| 157 name := e.Name() | |
| 158 childRel := filepath.Join(rel, name) | |
| 159 childAbs := filepath.Join(dir, name) | |
| 160 if e.IsDir() { | |
| 161 child := sect{ | |
| 162 title: gentitle(name), | |
| 163 href: "/" + filepath.ToSlash(childRel) + "/index.html", | |
| 164 } | |
| 165 if err := walk(childAbs, childRel, &child); err != nil { | |
| 166 return err | |
| 167 } | |
| 168 s.children = append(s.children, child) | |
| 169 continue | |
| 170 } | |
| 171 if !strings.HasSuffix(name, ".md") { | |
| 172 continue | |
| 173 } | |
| 174 if name == "index.md" { | |
| 175 s.hasIndex = true | |
| 176 if ext, ok := mdnavhref(childAbs); ok { | |
| 177 s.href = ext | |
| 178 } | |
| 179 } else { | |
| 180 href := "/" + filepath.ToSlash(strings.TrimSuffix(childRel, ".md")+".html") | |
| 181 if ext, ok := mdnavhref(childAbs); ok { | |
| 182 href = ext | |
| 183 } | |
| 184 s.pages = append(s.pages, page{ | |
| 185 title: gentitle(name), | |
| 186 href: href, | |
| 187 }) | |
| 188 } | |
| 189 } | |
| 190 return nil | |
| 191 } | |
| 192 | |
| 193 if err := walk(src, "", &root); err != nil { | |
| 194 return nil, err | |
| 195 } | |
| 196 return []sect{root}, nil | |
| 197 } | |
| 198 | |
| 199 func findCurSect(sects []sect, cur string) *sect { | |
| 200 for i := range sects { | |
| 201 s := §s[i] | |
| 202 if s.href == cur { | |
| 203 return s | |
| 204 } | |
| 205 for _, p := range s.pages { | |
| 206 if p.href == cur { | |
| 207 return s | |
| 208 } | |
| 209 } | |
| 210 if found := findCurSect(s.children, cur); found != nil { | |
| 211 return found | |
| 212 } | |
| 213 } | |
| 214 return nil | |
| 0 | 215 } |
| 216 | |
| 5 | 217 func navX(roots []sect, cur string) string { |
| 218 if len(roots) == 0 { | |
| 219 return "" | |
| 220 } | |
| 221 root := &roots[0] | |
| 222 | |
| 223 var path []*sect | |
| 224 var findPath func(s *sect) bool | |
| 225 findPath = func(s *sect) bool { | |
| 226 path = append(path, s) | |
| 227 if s.href == cur { | |
| 228 return true | |
| 229 } | |
| 230 for _, p := range s.pages { | |
| 231 if p.href == cur { | |
| 232 return true | |
| 233 } | |
| 234 } | |
| 235 for i := range s.children { | |
| 236 if findPath(&s.children[i]) { | |
| 237 return true | |
| 238 } | |
| 239 } | |
| 240 path = path[:len(path)-1] | |
| 241 return false | |
| 242 } | |
| 243 if !findPath(root) { | |
| 244 return "" | |
| 245 } | |
| 246 | |
| 247 sectLI := func(b *strings.Builder, s *sect, active bool) { | |
| 248 b.WriteString(" <li>") | |
| 249 if s.hasIndex { | |
| 250 cls := "" | |
| 251 if active { | |
| 252 cls = " current" | |
| 253 } | |
| 254 b.WriteString(`<a` + cls + ` href="` + s.href + `">` + s.title + `</a>`) | |
| 255 } else { | |
| 256 b.WriteString(s.title) | |
| 257 } | |
| 258 b.WriteString("</li>\n") | |
| 259 } | |
| 260 | |
| 261 var b strings.Builder | |
| 262 | |
| 263 { | |
| 264 var active1 *sect | |
| 265 | |
| 266 if len(path) > 1 { | |
| 267 active1 = path[1] | |
| 268 } | |
| 269 b.WriteString("<ul>\n") | |
| 270 sectLI(&b, root, len(path) == 1) | |
| 271 | |
| 272 for i := range root.children { | |
| 273 c := &root.children[i] | |
| 274 sectLI(&b, c, c == active1) | |
| 275 } | |
| 276 b.WriteString("</ul>\n") | |
| 277 } | |
| 278 | |
| 279 for depth := 1; depth < len(path); depth++ { | |
| 280 s := path[depth] | |
| 281 if len(s.children) == 0 && len(s.pages) == 0 { | |
| 282 continue | |
| 283 } | |
| 284 var activeChild *sect | |
| 285 if depth+1 < len(path) { | |
| 286 activeChild = path[depth+1] | |
| 287 } | |
| 288 b.WriteString("<ul>\n") | |
| 289 for i := range s.children { | |
| 290 c := &s.children[i] | |
| 291 sectLI(&b, c, c == activeChild) | |
| 292 } | |
| 293 for _, p := range s.pages { | |
| 294 cls := "" | |
| 295 if p.href == cur { | |
| 296 cls = " current" | |
| 297 } | |
| 298 b.WriteString(` <li><a` + cls + ` href="` + p.href + `">` + p.title + `</a></li>` + "\n") | |
| 299 } | |
| 300 b.WriteString("</ul>\n") | |
| 301 } | |
| 302 | |
| 303 return b.String() | |
| 304 } | |
| 305 | |
| 306 func navY(roots []sect, cur string) string { | |
| 307 curSect := findCurSect(roots, cur) | |
| 308 | |
| 309 var isAncestor func(s *sect) bool | |
| 310 isAncestor = func(s *sect) bool { | |
| 311 if s == curSect { | |
| 312 return true | |
| 313 } | |
| 314 for i := range s.children { | |
| 315 if isAncestor(&s.children[i]) { | |
| 316 return true | |
| 317 } | |
| 318 } | |
| 319 return false | |
| 320 } | |
| 321 | |
| 322 var b strings.Builder | |
| 323 var renderSects func(sects []sect, depth int) | |
| 324 renderSects = func(sects []sect, depth int) { | |
| 325 indent := strings.Repeat(" ", depth) | |
| 326 b.WriteString(indent + "<ul>\n") | |
| 327 for i := range sects { | |
| 328 s := §s[i] | |
| 329 b.WriteString(indent + " <li>") | |
| 330 if s.hasIndex { | |
| 331 cls := "" | |
| 332 if s == curSect { | |
| 333 cls = " current" | |
| 334 } | |
| 335 b.WriteString(`<a` + cls + ` href="` + s.href + `">` + s.title + `</a>`) | |
| 336 } else { | |
| 337 b.WriteString(s.title) | |
| 338 } | |
| 339 | |
| 340 if isAncestor(s) { | |
| 341 if len(s.pages) > 0 || len(s.children) > 0 { | |
| 342 b.WriteString("\n") | |
| 343 if len(s.children) > 0 { | |
| 344 renderSects(s.children, depth+2) | |
| 345 } | |
| 346 if len(s.pages) > 0 { | |
| 347 b.WriteString(indent + " <ul>\n") | |
| 348 for _, p := range s.pages { | |
| 349 cls := "" | |
| 350 if p.href == cur { | |
| 351 cls = " current" | |
| 352 } | |
| 353 b.WriteString(indent + ` <li><a` + cls + ` href="` + p.href + `">` + p.title + `</a></li>` + "\n") | |
| 354 } | |
| 355 b.WriteString(indent + " </ul>\n") | |
| 356 } | |
| 357 b.WriteString(indent + " ") | |
| 358 } | |
| 359 } | |
| 360 b.WriteString("</li>\n") | |
| 361 } | |
| 362 b.WriteString(indent + "</ul>\n") | |
| 363 } | |
| 364 renderSects(roots, 0) | |
| 365 return b.String() | |
| 366 } | |
| 367 | |
| 368 func render(tpl, pageTitle, nav, content string, cfg config) string { | |
| 369 compositeTitle := cfg.SiteTitle + " | " + pageTitle | |
| 370 r := strings.NewReplacer( | |
| 371 "{{TITLE}}", compositeTitle, | |
| 372 "{{NAV}}", nav, | |
| 373 "{{PAGE_TITLE}}", pageTitle, | |
| 374 "{{CONTENT}}", content, | |
| 375 "{{FOOTER_TEXT}}", cfg.FooterText, | |
| 376 ) | |
| 377 return r.Replace(tpl) | |
| 378 } | |
| 379 | |
| 380 func writepage(outpath, html string) error { | |
| 381 if err := os.MkdirAll(filepath.Dir(outpath), 0755); err != nil { | |
| 0 | 382 return err |
| 383 } | |
| 5 | 384 return os.WriteFile(outpath, []byte(html), 0644) |
| 0 | 385 } |
| 386 | |
| 387 func main() { | |
| 5 | 388 if len(os.Args) < 3 { |
| 389 fmt.Println("usage: qwb <src> <out> [-x|-y]") | |
| 390 return | |
| 0 | 391 } |
| 392 src, out := os.Args[1], os.Args[2] | |
| 5 | 393 mode := "x" |
| 394 if len(os.Args) == 4 { | |
| 395 switch os.Args[3] { | |
| 396 case "-x": | |
| 397 mode = "x" | |
| 398 case "-y": | |
| 399 mode = "y" | |
| 400 default: | |
| 401 fmt.Println("usage: qwb <src> <out> [-x|-y]") | |
| 402 return | |
| 0 | 403 } |
| 404 } | |
| 405 | |
| 5 | 406 cfg := loadcfg(src) |
| 0 | 407 |
| 5 | 408 sects, err := scansrc(src) |
| 0 | 409 if err != nil { |
| 5 | 410 fmt.Fprintln(os.Stderr, "scan:", err) |
| 0 | 411 os.Exit(1) |
| 412 } | |
| 5 | 413 |
| 414 var process func(dir, rel string) | |
| 415 process = func(dir, rel string) { | |
| 416 entries, err := os.ReadDir(dir) | |
| 417 if err != nil { | |
| 418 return | |
| 419 } | |
| 420 for _, e := range entries { | |
| 421 name := e.Name() | |
| 422 childRel := filepath.Join(rel, name) | |
| 423 childAbs := filepath.Join(dir, name) | |
| 424 if e.IsDir() { | |
| 425 process(childAbs, childRel) | |
| 426 continue | |
| 427 } | |
| 428 if !strings.HasSuffix(name, ".md") { | |
| 429 | |
| 430 dst := filepath.Join(out, childRel) | |
| 431 _ = os.MkdirAll(filepath.Dir(dst), 0755) | |
| 432 if data, err := os.ReadFile(childAbs); err == nil { | |
| 433 _ = os.WriteFile(dst, data, 0644) | |
| 434 } | |
| 435 continue | |
| 436 } | |
| 437 htmlRel := strings.TrimSuffix(childRel, ".md") + ".html" | |
| 438 href := "/" + filepath.ToSlash(htmlRel) | |
| 439 | |
| 440 var nav string | |
| 441 if mode == "y" { | |
| 442 nav = navY(sects, href) | |
| 443 } else { | |
| 444 nav = navX(sects, href) | |
| 445 } | |
| 446 | |
| 447 pageTitle := mdtitle(childAbs) | |
| 448 if pageTitle == "" { | |
| 449 pageTitle = gentitle(name) | |
| 450 } | |
| 451 | |
| 452 content, err := md2html(childAbs) | |
| 453 if err != nil { | |
| 454 fmt.Fprintf(os.Stderr, "md2html %s: %v\n", childAbs, err) | |
| 455 content = "<p>render error</p>" | |
| 456 } | |
| 457 | |
| 458 html := render(tpldefault, pageTitle, nav, content, cfg) | |
| 459 outpath := filepath.Join(out, htmlRel) | |
| 460 if err := writepage(outpath, html); err != nil { | |
| 461 fmt.Fprintf(os.Stderr, "write %s: %v\n", outpath, err) | |
| 462 } else { | |
| 463 fmt.Println("→", outpath) | |
| 464 } | |
| 465 } | |
| 466 } | |
| 467 | |
| 468 process(src, "") | |
| 469 } |
