Mercurial Hosting > qwb
annotate qwb.go @ 4:ce2b6dde4c10
remove nesting restriction + per-page header
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Sat, 14 Mar 2026 14:01:51 +0500 |
| parents | 3222f88c0afe |
| children | 125e599b1217 |
| 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> | |
| 2 | 19 <link rel="stylesheet" href="{{CSS}}"> |
| 0 | 20 </head> |
| 21 <body> | |
| 22 <nav>{{NAV}}</nav> | |
| 2 | 23 <header><h1>{{SITE_TITLE}}</h1></header> |
| 0 | 24 <main>{{CONTENT}}</main> |
| 25 <footer> | |
| 2 | 26 <p>{{FOOTER_TEXT}}</p> |
| 27 <p>Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p> | |
| 0 | 28 </footer> |
| 29 </body> | |
| 30 </html>` | |
| 31 | |
| 2 | 32 type config struct { |
| 33 headertext string | |
| 34 footertext string | |
| 35 cssfile string | |
| 36 tplfile string | |
| 37 } | |
| 38 | |
| 0 | 39 type section struct { |
| 40 title string | |
| 2 | 41 index string |
| 42 pages []page | |
| 0 | 43 } |
| 44 | |
| 45 type page struct { | |
| 46 title string | |
| 47 path string | |
| 48 } | |
| 49 | |
| 2 | 50 func ParseINI(r io.Reader) (map[string]map[string]string, error) { |
| 51 res := make(map[string]map[string]string) | |
| 52 sec := "default" | |
| 53 scanner := bufio.NewScanner(r) | |
| 54 for scanner.Scan() { | |
| 55 line := strings.TrimSpace(scanner.Text()) | |
| 56 if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { | |
| 57 continue | |
| 58 } | |
| 59 if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { | |
| 60 sec = line[1 : len(line)-1] | |
| 61 if res[sec] == nil { | |
| 62 res[sec] = make(map[string]string) | |
| 63 } | |
| 64 continue | |
| 65 } | |
| 66 if k, v, ok := strings.Cut(line, "="); ok { | |
| 67 if res[sec] == nil { | |
| 68 res[sec] = make(map[string]string) | |
| 69 } | |
| 70 res[sec][strings.TrimSpace(k)] = strings.TrimSpace(v) | |
| 71 } | |
| 72 } | |
| 73 return res, scanner.Err() | |
| 74 } | |
| 0 | 75 |
| 2 | 76 func loadconfig(src string) config { |
| 77 cfg := config{ | |
| 78 headertext: "My Site", | |
| 79 footertext: "© Me", | |
| 80 cssfile: "/style.css", | |
| 81 } | |
| 82 f, err := os.Open(filepath.Join(src, "qwb.ini")) | |
| 83 if err != nil { | |
| 84 return cfg | |
| 85 } | |
| 86 defer f.Close() | |
| 87 ini, err := ParseINI(f) | |
| 88 if err != nil { | |
| 89 return cfg | |
| 90 } | |
| 91 s := ini["site"] | |
| 92 set := func(key string, target *string) { | |
| 93 if v, ok := s[key]; ok { | |
| 94 *target = v | |
| 95 } | |
| 96 } | |
| 97 set("header", &cfg.headertext) | |
| 98 set("footer", &cfg.footertext) | |
| 99 set("style", &cfg.cssfile) | |
| 100 set("template", &cfg.tplfile) | |
| 101 return cfg | |
| 102 } | |
| 103 | |
| 104 func loadtemplate(cfg config) string { | |
| 105 if cfg.tplfile == "" { | |
| 106 return tpldefault | |
| 107 } | |
| 108 b, err := os.ReadFile(cfg.tplfile) | |
| 109 if err != nil { | |
| 110 fmt.Fprintf(os.Stderr, "cannot read template %s: %v", cfg.tplfile, err) | |
| 111 return tpldefault | |
| 112 } | |
| 113 return string(b) | |
| 114 } | |
| 115 | |
| 116 func collectsections(root, siteTitle string) (section, []section) { | |
| 117 var subs []section | |
| 118 root_ := section{title: siteTitle} | |
| 0 | 119 entries, _ := os.ReadDir(root) |
| 120 for _, e := range entries { | |
| 121 full := filepath.Join(root, e.Name()) | |
| 122 if e.IsDir() { | |
| 2 | 123 s := scansection(full, root) |
| 0 | 124 if s.index != "" || len(s.pages) > 0 { |
| 125 subs = append(subs, s) | |
| 126 } | |
| 127 continue | |
| 128 } | |
| 129 if !strings.HasSuffix(e.Name(), ".md") { | |
| 130 continue | |
| 131 } | |
| 132 if e.Name() == "index.md" { | |
| 2 | 133 root_.index = "/index.html" |
| 0 | 134 } else { |
| 2 | 135 rel, _ := filepath.Rel(root, full) |
| 136 root_.pages = append(root_.pages, page{ | |
| 137 title: titlefromname(e.Name()), | |
| 138 path: "/" + strings.TrimSuffix(rel, ".md") + ".html", | |
| 139 }) | |
| 140 } | |
| 141 } | |
| 142 return root_, subs | |
| 143 } | |
| 144 | |
| 145 func scansection(dir, root string) section { | |
| 146 s := section{title: titlefromname(filepath.Base(dir))} | |
| 147 entries, _ := os.ReadDir(dir) | |
| 148 for _, e := range entries { | |
| 149 if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { | |
| 150 continue | |
| 151 } | |
| 152 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name())) | |
| 153 htmlpath := "/" + strings.TrimSuffix(rel, ".md") + ".html" | |
| 154 if e.Name() == "index.md" { | |
| 155 s.index = htmlpath | |
| 156 } else { | |
| 157 s.pages = append(s.pages, page{titlefromname(e.Name()), htmlpath}) | |
| 0 | 158 } |
| 159 } | |
| 160 return s | |
| 161 } | |
| 162 | |
| 2 | 163 func navlink(b *strings.Builder, p page, cur string) { |
| 0 | 164 if p.path == cur { |
| 165 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title) | |
| 166 } else { | |
| 167 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title) | |
| 168 } | |
| 169 } | |
| 170 | |
| 2 | 171 func buildnav(root section, subs []section, cur string) string { |
| 0 | 172 var b strings.Builder |
| 173 b.WriteString("<ul>\n") | |
| 2 | 174 if root.index != "" { |
| 175 navlink(&b, page{"Home", root.index}, cur) | |
| 0 | 176 } |
| 2 | 177 for _, p := range root.pages { |
| 178 navlink(&b, p, cur) | |
| 0 | 179 } |
| 180 for _, s := range subs { | |
| 181 link := s.index | |
| 182 if link == "" && len(s.pages) > 0 { | |
| 183 link = s.pages[0].path | |
| 184 } | |
| 185 if link == "" { | |
| 186 continue | |
| 187 } | |
| 2 | 188 navlink(&b, page{s.title, link}, cur) |
| 0 | 189 } |
| 190 b.WriteString("</ul>\n") | |
| 191 for _, s := range subs { | |
| 2 | 192 if !sectioncontains(s, cur) { |
| 0 | 193 continue |
| 194 } | |
| 195 total := len(s.pages) | |
| 196 if s.index != "" { | |
| 197 total++ | |
| 198 } | |
| 199 b.WriteString("<ul>\n") | |
| 200 if s.index != "" { | |
| 2 | 201 navlink(&b, page{"Index", s.index}, cur) |
| 0 | 202 } |
| 203 for _, p := range s.pages { | |
| 2 | 204 navlink(&b, p, cur) |
| 0 | 205 } |
| 206 b.WriteString("</ul>\n") | |
| 207 break | |
| 208 } | |
| 209 return b.String() | |
| 210 } | |
| 211 | |
| 2 | 212 func sectioncontains(s section, cur string) bool { |
| 0 | 213 if s.index == cur { |
| 214 return true | |
| 215 } | |
| 216 for _, p := range s.pages { | |
| 217 if p.path == cur { | |
| 218 return true | |
| 219 } | |
| 220 } | |
| 221 return false | |
| 222 } | |
| 223 | |
|
4
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
224 func extracth1(html string) (title, rest string) { |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
225 start := strings.Index(html, "<h1") |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
226 if start == -1 { |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
227 return "", html |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
228 } |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
229 close := strings.Index(html[start:], ">") |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
230 if close == -1 { |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
231 return "", html |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
232 } |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
233 content := start + close + 1 |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
234 end := strings.Index(html[content:], "</h1>") |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
235 if end == -1 { |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
236 return "", html |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
237 } |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
238 title = html[content : content+end] |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
239 rest = html[:start] + html[content+end+len("</h1>"):] |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
240 return |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
241 } |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
242 |
| 2 | 243 func mdtohtml(path string) (string, error) { |
| 0 | 244 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml") |
| 245 f, err := os.Open(path) | |
| 246 if err != nil { | |
| 247 return "", err | |
| 248 } | |
| 249 defer f.Close() | |
| 250 var buf strings.Builder | |
| 251 cmd.Stdin = f | |
| 252 cmd.Stdout = &buf | |
| 253 cmd.Stderr = os.Stderr | |
| 254 if err := cmd.Run(); err != nil { | |
| 255 return "", err | |
| 256 } | |
| 257 return buf.String(), nil | |
| 258 } | |
| 259 | |
| 2 | 260 func titlefromname(name string) string { |
| 0 | 261 name = strings.TrimSuffix(name, ".md") |
| 262 name = strings.ReplaceAll(name, "-", " ") | |
| 263 if len(name) > 0 { | |
| 264 name = strings.ToUpper(name[:1]) + name[1:] | |
| 265 } | |
| 266 return name | |
| 267 } | |
| 268 | |
| 2 | 269 func fixlinks(s string) string { |
| 0 | 270 return strings.NewReplacer( |
| 2 | 271 ".md)", ".html)", |
| 272 ".md\"", ".html\"", | |
| 273 ".md'", ".html'", | |
| 274 ".md#", ".html#", | |
| 275 ".md>", ".html>", | |
| 276 ".md ", ".html ", | |
| 0 | 277 ".md,", ".html,", |
| 278 ).Replace(s) | |
| 279 } | |
| 280 | |
| 2 | 281 func copyfile(src, dst string) error { |
| 0 | 282 in, err := os.Open(src) |
| 283 if err != nil { | |
| 284 return err | |
| 285 } | |
| 286 defer in.Close() | |
| 287 out, err := os.Create(dst) | |
| 288 if err != nil { | |
| 289 return err | |
| 290 } | |
| 291 defer out.Close() | |
| 292 _, err = io.Copy(out, in) | |
| 293 return err | |
| 294 } | |
| 295 | |
| 296 func main() { | |
| 297 if len(os.Args) != 3 { | |
| 298 fmt.Fprintln(os.Stderr, "usage: qwb <in> <out>") | |
| 299 os.Exit(1) | |
| 300 } | |
| 301 src, out := os.Args[1], os.Args[2] | |
| 302 | |
| 303 if entries, err := os.ReadDir(out); err == nil && len(entries) > 0 { | |
| 304 fmt.Fprintf(os.Stderr, "qwb: %s is not empty, overwrite? [y/N] ", out) | |
| 305 s, _ := bufio.NewReader(os.Stdin).ReadString('\n') | |
| 306 if strings.TrimSpace(strings.ToLower(s)) != "y" { | |
| 307 fmt.Fprintln(os.Stderr, "aborted") | |
| 308 os.Exit(1) | |
| 309 } | |
| 310 if err := os.RemoveAll(out); err != nil { | |
| 311 fmt.Fprintln(os.Stderr, err) | |
| 312 os.Exit(1) | |
| 313 } | |
| 314 } | |
| 315 | |
| 2 | 316 cfg := loadconfig(src) |
| 317 tmpl := loadtemplate(cfg) | |
| 318 rootsec, subs := collectsections(src, cfg.headertext) | |
| 0 | 319 |
| 320 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { | |
| 321 if err != nil { | |
| 322 return err | |
| 323 } | |
| 324 rel, _ := filepath.Rel(src, path) | |
| 325 outpath := filepath.Join(out, rel) | |
| 326 if d.IsDir() { | |
| 327 return os.MkdirAll(outpath, 0755) | |
| 328 } | |
| 329 if !strings.HasSuffix(path, ".md") { | |
| 2 | 330 return copyfile(path, outpath) |
| 0 | 331 } |
| 2 | 332 body, err := mdtohtml(path) |
| 0 | 333 if err != nil { |
| 334 return err | |
| 335 } | |
| 2 | 336 body = fixlinks(body) |
|
4
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
337 pageTitle, body := extracth1(body) |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
338 if pageTitle == "" { |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
339 pageTitle = cfg.headertext |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
340 } |
| 0 | 341 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" |
| 2 | 342 title := cfg.headertext |
| 0 | 343 if filepath.Base(path) != "index.md" { |
|
4
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
344 title = cfg.headertext + " | " + pageTitle |
| 0 | 345 } |
|
4
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
346 pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title) |
|
ce2b6dde4c10
remove nesting restriction + per-page header
Atarwn Gard <a@qwa.su>
parents:
2
diff
changeset
|
347 pg = strings.ReplaceAll(pg, "{{SITE_TITLE}}", pageTitle) |
| 2 | 348 pg = strings.ReplaceAll(pg, "{{FOOTER_TEXT}}", cfg.footertext) |
| 349 pg = strings.ReplaceAll(pg, "{{CSS}}", cfg.cssfile) | |
| 350 pg = strings.ReplaceAll(pg, "{{NAV}}", buildnav(rootsec, subs, cur)) | |
| 0 | 351 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body) |
| 352 outpath = strings.TrimSuffix(outpath, ".md") + ".html" | |
| 353 return os.WriteFile(outpath, []byte(pg), 0644) | |
| 354 }) | |
| 355 | |
| 356 if err != nil { | |
| 357 fmt.Fprintln(os.Stderr, err) | |
| 358 os.Exit(1) | |
| 359 } | |
| 360 } |
