Mercurial Hosting > qwb
comparison qwb.go @ 2:3222f88c0afe
refactored + new css
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Sat, 14 Mar 2026 12:03:52 +0500 |
| parents | ac64aa92dea1 |
| children | ce2b6dde4c10 |
comparison
equal
deleted
inserted
replaced
| 1:72124c0555c8 | 2:3222f88c0afe |
|---|---|
| 8 "os/exec" | 8 "os/exec" |
| 9 "path/filepath" | 9 "path/filepath" |
| 10 "strings" | 10 "strings" |
| 11 ) | 11 ) |
| 12 | 12 |
| 13 const siteTitle = "My Site" | 13 const tpldefault = `<!DOCTYPE html> |
| 14 | |
| 15 const tmpl = `<!DOCTYPE html> | |
| 16 <html lang="en"> | 14 <html lang="en"> |
| 17 <head> | 15 <head> |
| 18 <meta charset="UTF-8"> | 16 <meta charset="UTF-8"> |
| 19 <meta name="viewport" content="width=device-width, initial-scale=1"> | 17 <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 20 <title>{{TITLE}}</title> | 18 <title>{{TITLE}}</title> |
| 21 <link rel="stylesheet" href="/x.css"> | 19 <link rel="stylesheet" href="{{CSS}}"> |
| 22 </head> | 20 </head> |
| 23 <body> | 21 <body> |
| 24 <header><h1>` + siteTitle + `</h1></header> | |
| 25 <nav>{{NAV}}</nav> | 22 <nav>{{NAV}}</nav> |
| 23 <header><h1>{{SITE_TITLE}}</h1></header> | |
| 26 <main>{{CONTENT}}</main> | 24 <main>{{CONTENT}}</main> |
| 27 <footer> | 25 <footer> |
| 28 <p>` + siteTitle + `</p> | 26 <p>{{FOOTER_TEXT}}</p> |
| 29 <p>© Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p> | 27 <p>Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p> |
| 30 </footer> | 28 </footer> |
| 31 </body> | 29 </body> |
| 32 </html>` | 30 </html>` |
| 33 | 31 |
| 34 // section — top-level directory, each with its own index and pages | 32 type config struct { |
| 33 headertext string | |
| 34 footertext string | |
| 35 cssfile string | |
| 36 tplfile string | |
| 37 } | |
| 38 | |
| 35 type section struct { | 39 type section struct { |
| 36 title string | 40 title string |
| 37 index string // /section/index.html, or "" if none | 41 index string |
| 38 pages []page // non-index .md files within this section | 42 pages []page |
| 39 } | 43 } |
| 40 | 44 |
| 41 type page struct { | 45 type page struct { |
| 42 title string | 46 title string |
| 43 path string | 47 path string |
| 44 } | 48 } |
| 45 | 49 |
| 46 // collectSections scans one level deep: root files go into a synthetic root | 50 func ParseINI(r io.Reader) (map[string]map[string]string, error) { |
| 47 // section, each subdirectory becomes its own section. | 51 res := make(map[string]map[string]string) |
| 48 func collectSections(root string) (rootSection section, subs []section) { | 52 sec := "default" |
| 49 rootSection.title = siteTitle | 53 scanner := bufio.NewScanner(r) |
| 50 | 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 } | |
| 75 | |
| 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} | |
| 51 entries, _ := os.ReadDir(root) | 119 entries, _ := os.ReadDir(root) |
| 52 for _, e := range entries { | 120 for _, e := range entries { |
| 53 full := filepath.Join(root, e.Name()) | 121 full := filepath.Join(root, e.Name()) |
| 54 if e.IsDir() { | 122 if e.IsDir() { |
| 55 s := scanSection(full, root) | 123 s := scansection(full, root) |
| 56 if s.index != "" || len(s.pages) > 0 { | 124 if s.index != "" || len(s.pages) > 0 { |
| 57 subs = append(subs, s) | 125 subs = append(subs, s) |
| 58 } | 126 } |
| 59 continue | 127 continue |
| 60 } | 128 } |
| 61 if strings.HasSuffix(e.Name(), ".md") { | 129 if !strings.HasSuffix(e.Name(), ".md") { |
| 62 if e.Name() == "index.md" { | 130 continue |
| 63 rootSection.index = "/index.html" | 131 } |
| 64 } else { | 132 if e.Name() == "index.md" { |
| 65 rel, _ := filepath.Rel(root, full) | 133 root_.index = "/index.html" |
| 66 rootSection.pages = append(rootSection.pages, page{ | 134 } else { |
| 67 title: titleFromName(e.Name()), | 135 rel, _ := filepath.Rel(root, full) |
| 68 path: "/" + strings.TrimSuffix(rel, ".md") + ".html", | 136 root_.pages = append(root_.pages, page{ |
| 69 }) | 137 title: titlefromname(e.Name()), |
| 70 } | 138 path: "/" + strings.TrimSuffix(rel, ".md") + ".html", |
| 71 } | 139 }) |
| 72 } | 140 } |
| 73 return | 141 } |
| 74 } | 142 return root_, subs |
| 75 | 143 } |
| 76 func scanSection(dir, root string) section { | 144 |
| 77 s := section{title: titleFromName(filepath.Base(dir))} | 145 func scansection(dir, root string) section { |
| 146 s := section{title: titlefromname(filepath.Base(dir))} | |
| 78 entries, _ := os.ReadDir(dir) | 147 entries, _ := os.ReadDir(dir) |
| 79 for _, e := range entries { | 148 for _, e := range entries { |
| 80 if e.IsDir() { | 149 if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { |
| 81 continue // only one level deep | |
| 82 } | |
| 83 if !strings.HasSuffix(e.Name(), ".md") { | |
| 84 continue | 150 continue |
| 85 } | 151 } |
| 86 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name())) | 152 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name())) |
| 87 htmlPath := "/" + strings.TrimSuffix(rel, ".md") + ".html" | 153 htmlpath := "/" + strings.TrimSuffix(rel, ".md") + ".html" |
| 88 if e.Name() == "index.md" { | 154 if e.Name() == "index.md" { |
| 89 s.index = htmlPath | 155 s.index = htmlpath |
| 90 } else { | 156 } else { |
| 91 s.pages = append(s.pages, page{titleFromName(e.Name()), htmlPath}) | 157 s.pages = append(s.pages, page{titlefromname(e.Name()), htmlpath}) |
| 92 } | 158 } |
| 93 } | 159 } |
| 94 return s | 160 return s |
| 95 } | 161 } |
| 96 | 162 |
| 97 func navLink(b *strings.Builder, p page, cur string) { | 163 func navlink(b *strings.Builder, p page, cur string) { |
| 98 if p.path == cur { | 164 if p.path == cur { |
| 99 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title) | 165 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title) |
| 100 } else { | 166 } else { |
| 101 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title) | 167 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title) |
| 102 } | 168 } |
| 103 } | 169 } |
| 104 | 170 |
| 105 // buildNav produces one or two <ul> blocks. | 171 func buildnav(root section, subs []section, cur string) string { |
| 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 | 172 var b strings.Builder |
| 110 | |
| 111 b.WriteString("<ul>\n") | 173 b.WriteString("<ul>\n") |
| 112 if rootSec.index != "" { | 174 if root.index != "" { |
| 113 navLink(&b, page{"Home", rootSec.index}, cur) | 175 navlink(&b, page{"Home", root.index}, cur) |
| 114 } | 176 } |
| 115 for _, p := range rootSec.pages { | 177 for _, p := range root.pages { |
| 116 navLink(&b, p, cur) | 178 navlink(&b, p, cur) |
| 117 } | 179 } |
| 118 for _, s := range subs { | 180 for _, s := range subs { |
| 119 link := s.index | 181 link := s.index |
| 120 if link == "" && len(s.pages) > 0 { | 182 if link == "" && len(s.pages) > 0 { |
| 121 link = s.pages[0].path | 183 link = s.pages[0].path |
| 122 } | 184 } |
| 123 if link == "" { | 185 if link == "" { |
| 124 continue | 186 continue |
| 125 } | 187 } |
| 126 navLink(&b, page{s.title, link}, cur) | 188 navlink(&b, page{s.title, link}, cur) |
| 127 } | 189 } |
| 128 b.WriteString("</ul>\n") | 190 b.WriteString("</ul>\n") |
| 129 | |
| 130 // Find which sub-section cur belongs to | |
| 131 for _, s := range subs { | 191 for _, s := range subs { |
| 132 if !sectionContains(s, cur) { | 192 if !sectioncontains(s, cur) { |
| 133 continue | 193 continue |
| 134 } | 194 } |
| 135 // Only render sub-nav if there are 2+ addressable pages in the section | |
| 136 total := len(s.pages) | 195 total := len(s.pages) |
| 137 if s.index != "" { | 196 if s.index != "" { |
| 138 total++ | 197 total++ |
| 139 } | 198 } |
| 140 if total < 2 { | 199 if total < 2 { |
| 141 break | 200 break |
| 142 } | 201 } |
| 143 b.WriteString("<ul>\n") | 202 b.WriteString("<ul>\n") |
| 144 if s.index != "" { | 203 if s.index != "" { |
| 145 navLink(&b, page{"Index", s.index}, cur) | 204 navlink(&b, page{"Index", s.index}, cur) |
| 146 } | 205 } |
| 147 for _, p := range s.pages { | 206 for _, p := range s.pages { |
| 148 navLink(&b, p, cur) | 207 navlink(&b, p, cur) |
| 149 } | 208 } |
| 150 b.WriteString("</ul>\n") | 209 b.WriteString("</ul>\n") |
| 151 break | 210 break |
| 152 } | 211 } |
| 153 | |
| 154 return b.String() | 212 return b.String() |
| 155 } | 213 } |
| 156 | 214 |
| 157 func sectionContains(s section, cur string) bool { | 215 func sectioncontains(s section, cur string) bool { |
| 158 if s.index == cur { | 216 if s.index == cur { |
| 159 return true | 217 return true |
| 160 } | 218 } |
| 161 for _, p := range s.pages { | 219 for _, p := range s.pages { |
| 162 if p.path == cur { | 220 if p.path == cur { |
| 164 } | 222 } |
| 165 } | 223 } |
| 166 return false | 224 return false |
| 167 } | 225 } |
| 168 | 226 |
| 169 func mdToHTML(path string) (string, error) { | 227 func mdtohtml(path string) (string, error) { |
| 170 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml") | 228 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml") |
| 171 f, err := os.Open(path) | 229 f, err := os.Open(path) |
| 172 if err != nil { | 230 if err != nil { |
| 173 return "", err | 231 return "", err |
| 174 } | 232 } |
| 181 return "", err | 239 return "", err |
| 182 } | 240 } |
| 183 return buf.String(), nil | 241 return buf.String(), nil |
| 184 } | 242 } |
| 185 | 243 |
| 186 func titleFromName(name string) string { | 244 func titlefromname(name string) string { |
| 187 name = strings.TrimSuffix(name, ".md") | 245 name = strings.TrimSuffix(name, ".md") |
| 188 name = strings.ReplaceAll(name, "-", " ") | 246 name = strings.ReplaceAll(name, "-", " ") |
| 189 if len(name) > 0 { | 247 if len(name) > 0 { |
| 190 name = strings.ToUpper(name[:1]) + name[1:] | 248 name = strings.ToUpper(name[:1]) + name[1:] |
| 191 } | 249 } |
| 192 return name | 250 return name |
| 193 } | 251 } |
| 194 | 252 |
| 195 func fixLinks(s string) string { | 253 func fixlinks(s string) string { |
| 196 return strings.NewReplacer( | 254 return strings.NewReplacer( |
| 197 ".md)", ".html)", ".md\"", ".html\"", | 255 ".md)", ".html)", |
| 198 ".md'", ".html'", ".md#", ".html#", | 256 ".md\"", ".html\"", |
| 199 ".md>", ".html>", ".md ", ".html ", | 257 ".md'", ".html'", |
| 258 ".md#", ".html#", | |
| 259 ".md>", ".html>", | |
| 260 ".md ", ".html ", | |
| 200 ".md,", ".html,", | 261 ".md,", ".html,", |
| 201 ).Replace(s) | 262 ).Replace(s) |
| 202 } | 263 } |
| 203 | 264 |
| 204 func copyFile(src, dst string) error { | 265 func copyfile(src, dst string) error { |
| 205 in, err := os.Open(src) | 266 in, err := os.Open(src) |
| 206 if err != nil { | 267 if err != nil { |
| 207 return err | 268 return err |
| 208 } | 269 } |
| 209 defer in.Close() | 270 defer in.Close() |
| 234 fmt.Fprintln(os.Stderr, err) | 295 fmt.Fprintln(os.Stderr, err) |
| 235 os.Exit(1) | 296 os.Exit(1) |
| 236 } | 297 } |
| 237 } | 298 } |
| 238 | 299 |
| 239 rootSec, subs := collectSections(src) | 300 cfg := loadconfig(src) |
| 301 tmpl := loadtemplate(cfg) | |
| 302 rootsec, subs := collectsections(src, cfg.headertext) | |
| 240 | 303 |
| 241 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { | 304 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { |
| 242 if err != nil { | 305 if err != nil { |
| 243 return err | 306 return err |
| 244 } | 307 } |
| 245 rel, _ := filepath.Rel(src, path) | 308 rel, _ := filepath.Rel(src, path) |
| 246 outpath := filepath.Join(out, rel) | 309 outpath := filepath.Join(out, rel) |
| 247 | |
| 248 if d.IsDir() { | 310 if d.IsDir() { |
| 249 return os.MkdirAll(outpath, 0755) | 311 return os.MkdirAll(outpath, 0755) |
| 250 } | 312 } |
| 251 | |
| 252 if !strings.HasSuffix(path, ".md") { | 313 if !strings.HasSuffix(path, ".md") { |
| 253 return copyFile(path, outpath) | 314 return copyfile(path, outpath) |
| 254 } | 315 } |
| 255 | 316 body, err := mdtohtml(path) |
| 256 body, err := mdToHTML(path) | |
| 257 if err != nil { | 317 if err != nil { |
| 258 return err | 318 return err |
| 259 } | 319 } |
| 260 body = fixLinks(body) | 320 body = fixlinks(body) |
| 261 | |
| 262 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" | 321 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" |
| 263 | 322 title := cfg.headertext |
| 264 title := siteTitle | |
| 265 if filepath.Base(path) != "index.md" { | 323 if filepath.Base(path) != "index.md" { |
| 266 title = siteTitle + " | " + titleFromName(filepath.Base(path)) | 324 title = cfg.headertext + " | " + titlefromname(filepath.Base(path)) |
| 267 } | 325 } |
| 268 | 326 pg:= strings.ReplaceAll(tmpl, "{{TITLE}}", title) |
| 269 pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title) | 327 pg = strings.ReplaceAll(pg, "{{SITE_TITLE}}", cfg.headertext) |
| 270 pg = strings.ReplaceAll(pg, "{{NAV}}", buildNav(rootSec, subs, cur)) | 328 pg = strings.ReplaceAll(pg, "{{FOOTER_TEXT}}", cfg.footertext) |
| 329 pg = strings.ReplaceAll(pg, "{{CSS}}", cfg.cssfile) | |
| 330 pg = strings.ReplaceAll(pg, "{{NAV}}", buildnav(rootsec, subs, cur)) | |
| 271 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body) | 331 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body) |
| 272 | |
| 273 outpath = strings.TrimSuffix(outpath, ".md") + ".html" | 332 outpath = strings.TrimSuffix(outpath, ".md") + ".html" |
| 274 return os.WriteFile(outpath, []byte(pg), 0644) | 333 return os.WriteFile(outpath, []byte(pg), 0644) |
| 275 }) | 334 }) |
| 276 | 335 |
| 277 if err != nil { | 336 if err != nil { |
