comparison qwb.go @ 5:125e599b1217

agbubu
author Atarwn Gard <a@qwa.su>
date Tue, 17 Mar 2026 22:18:02 +0500
parents ce2b6dde4c10
children bd0d3a189f5b
comparison
equal deleted inserted replaced
4:ce2b6dde4c10 5:125e599b1217
14 <html lang="en"> 14 <html lang="en">
15 <head> 15 <head>
16 <meta charset="UTF-8"> 16 <meta charset="UTF-8">
17 <meta name="viewport" content="width=device-width, initial-scale=1"> 17 <meta name="viewport" content="width=device-width, initial-scale=1">
18 <title>{{TITLE}}</title> 18 <title>{{TITLE}}</title>
19 <link rel="stylesheet" href="{{CSS}}"> 19 <link rel="stylesheet" href="/x.css">
20 </head> 20 </head>
21 <body> 21 <body>
22 <nav>{{NAV}}</nav> 22 <nav>{{NAV}}</nav>
23 <header><h1>{{SITE_TITLE}}</h1></header> 23 <header><h1>{{PAGE_TITLE}}</h1></header>
24 <main>{{CONTENT}}</main> 24 <main>{{CONTENT}}</main>
25 <footer> 25 <footer><p>{{FOOTER_TEXT}}</p></footer>
26 <p>{{FOOTER_TEXT}}</p>
27 <p>Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p>
28 </footer>
29 </body> 26 </body>
30 </html>` 27 </html>`
31 28
32 type config struct { 29 type config struct {
33 headertext string 30 SiteTitle, FooterText string
34 footertext string 31 }
35 cssfile string 32
36 tplfile string 33 type sect struct {
37 }
38
39 type section struct {
40 title string 34 title string
41 index string 35
36 href string
37
42 pages []page 38 pages []page
39
40 hasIndex bool
41
42 children []sect
43 } 43 }
44 44
45 type page struct { 45 type page struct {
46 title string 46 title string
47 path string 47 href string
48 } 48 }
49 49
50 func ParseINI(r io.Reader) (map[string]map[string]string, error) { 50 func parseini(r io.Reader) map[string]string {
51 res := make(map[string]map[string]string) 51 res := make(map[string]string)
52 sec := "default" 52 sc := bufio.NewScanner(r)
53 scanner := bufio.NewScanner(r) 53 for sc.Scan() {
54 for scanner.Scan() { 54 line := strings.TrimSpace(sc.Text())
55 line := strings.TrimSpace(scanner.Text()) 55 if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
56 if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
57 continue 56 continue
58 } 57 }
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 { 58 if k, v, ok := strings.Cut(line, "="); ok {
67 if res[sec] == nil { 59 res[strings.TrimSpace(k)] = strings.TrimSpace(v)
68 res[sec] = make(map[string]string) 60 }
69 } 61 }
70 res[sec][strings.TrimSpace(k)] = strings.TrimSpace(v) 62 return res
71 } 63 }
72 } 64
73 return res, scanner.Err() 65 func loadcfg(src string) config {
74 } 66 cfg := config{SiteTitle: "My Site", FooterText: "© 2026"}
75
76 func loadconfig(src string) config {
77 cfg := config{
78 headertext: "My Site",
79 footertext: "&copy; Me",
80 cssfile: "/style.css",
81 }
82 f, err := os.Open(filepath.Join(src, "qwb.ini")) 67 f, err := os.Open(filepath.Join(src, "qwb.ini"))
83 if err != nil { 68 if err == nil {
84 return cfg 69 defer f.Close()
85 } 70 ini := parseini(f)
86 defer f.Close() 71 if v, ok := ini["SiteTitle"]; ok {
87 ini, err := ParseINI(f) 72 cfg.SiteTitle = v
88 if err != nil { 73 }
89 return cfg 74 if v, ok := ini["FooterText"]; ok {
90 } 75 cfg.FooterText = v
91 s := ini["site"] 76 }
92 set := func(key string, target *string) { 77 }
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 78 return cfg
102 } 79 }
103 80
104 func loadtemplate(cfg config) string { 81 func gentitle(name string) 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}
119 entries, _ := os.ReadDir(root)
120 for _, e := range entries {
121 full := filepath.Join(root, e.Name())
122 if e.IsDir() {
123 s := scansection(full, root)
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" {
133 root_.index = "/index.html"
134 } else {
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})
158 }
159 }
160 return s
161 }
162
163 func navlink(b *strings.Builder, p page, cur string) {
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
171 func buildnav(root section, subs []section, cur string) string {
172 var b strings.Builder
173 b.WriteString("<ul>\n")
174 if root.index != "" {
175 navlink(&b, page{"Home", root.index}, cur)
176 }
177 for _, p := range root.pages {
178 navlink(&b, p, cur)
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 }
188 navlink(&b, page{s.title, link}, cur)
189 }
190 b.WriteString("</ul>\n")
191 for _, s := range subs {
192 if !sectioncontains(s, cur) {
193 continue
194 }
195 total := len(s.pages)
196 if s.index != "" {
197 total++
198 }
199 b.WriteString("<ul>\n")
200 if s.index != "" {
201 navlink(&b, page{"Index", s.index}, cur)
202 }
203 for _, p := range s.pages {
204 navlink(&b, p, cur)
205 }
206 b.WriteString("</ul>\n")
207 break
208 }
209 return b.String()
210 }
211
212 func sectioncontains(s section, cur string) bool {
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
224 func extracth1(html string) (title, rest string) {
225 start := strings.Index(html, "<h1")
226 if start == -1 {
227 return "", html
228 }
229 close := strings.Index(html[start:], ">")
230 if close == -1 {
231 return "", html
232 }
233 content := start + close + 1
234 end := strings.Index(html[content:], "</h1>")
235 if end == -1 {
236 return "", html
237 }
238 title = html[content : content+end]
239 rest = html[:start] + html[content+end+len("</h1>"):]
240 return
241 }
242
243 func mdtohtml(path string) (string, error) {
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
260 func titlefromname(name string) string {
261 name = strings.TrimSuffix(name, ".md") 82 name = strings.TrimSuffix(name, ".md")
262 name = strings.ReplaceAll(name, "-", " ") 83 name = strings.ReplaceAll(name, "-", " ")
263 if len(name) > 0 { 84 if len(name) > 0 {
264 name = strings.ToUpper(name[:1]) + name[1:] 85 name = strings.ToUpper(name[:1]) + name[1:]
265 } 86 }
266 return name 87 return name
267 } 88 }
268 89
269 func fixlinks(s string) string { 90 func md2html(path string) (string, error) {
270 return strings.NewReplacer( 91 cmd := exec.Command("lowdown", "-Thtml", "--html-no-skiphtml")
271 ".md)", ".html)", 92 f, err := os.Open(path)
272 ".md\"", ".html\"",
273 ".md'", ".html'",
274 ".md#", ".html#",
275 ".md>", ".html>",
276 ".md ", ".html ",
277 ".md,", ".html,",
278 ).Replace(s)
279 }
280
281 func copyfile(src, dst string) error {
282 in, err := os.Open(src)
283 if err != nil { 93 if err != nil {
284 return err 94 return "", err
285 } 95 }
286 defer in.Close() 96 defer f.Close()
287 out, err := os.Create(dst) 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)
288 if err != nil { 114 if err != nil {
289 return err 115 return ""
290 } 116 }
291 defer out.Close() 117 defer f.Close()
292 _, err = io.Copy(out, in) 118 sc := bufio.NewScanner(f)
293 return err 119 for sc.Scan() {
294 } 120 line := strings.TrimSpace(sc.Text())
295 121 if strings.HasPrefix(line, "# ") {
296 func main() { 122 return strings.TrimSpace(line[2:])
297 if len(os.Args) != 3 { 123 }
298 fmt.Fprintln(os.Stderr, "usage: qwb <in> <out>") 124 }
299 os.Exit(1) 125 return ""
300 } 126 }
301 src, out := os.Args[1], os.Args[2] 127
302 128 //,
303 if entries, err := os.ReadDir(out); err == nil && len(entries) > 0 { 129
304 fmt.Fprintf(os.Stderr, "qwb: %s is not empty, overwrite? [y/N] ", out) 130 func mdnavhref(path string) (string, bool) {
305 s, _ := bufio.NewReader(os.Stdin).ReadString('\n') 131 f, err := os.Open(path)
306 if strings.TrimSpace(strings.ToLower(s)) != "y" { 132 if err != nil {
307 fmt.Fprintln(os.Stderr, "aborted") 133 return "", false
308 os.Exit(1) 134 }
309 } 135 defer f.Close()
310 if err := os.RemoveAll(out); err != nil { 136 sc := bufio.NewScanner(f)
311 fmt.Fprintln(os.Stderr, err) 137 if !sc.Scan() {
312 os.Exit(1) 138 return "", false
313 } 139 }
314 } 140 line := strings.TrimSpace(sc.Text())
315 141 if strings.Contains(line, "://") {
316 cfg := loadconfig(src) 142 return line, true
317 tmpl := loadtemplate(cfg) 143 }
318 rootsec, subs := collectsections(src, cfg.headertext) 144 return "", false
319 145 }
320 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { 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)
321 if err != nil { 153 if err != nil {
322 return err 154 return err
323 } 155 }
324 rel, _ := filepath.Rel(src, path) 156 for _, e := range entries {
325 outpath := filepath.Join(out, rel) 157 name := e.Name()
326 if d.IsDir() { 158 childRel := filepath.Join(rel, name)
327 return os.MkdirAll(outpath, 0755) 159 childAbs := filepath.Join(dir, name)
328 } 160 if e.IsDir() {
329 if !strings.HasSuffix(path, ".md") { 161 child := sect{
330 return copyfile(path, outpath) 162 title: gentitle(name),
331 } 163 href: "/" + filepath.ToSlash(childRel) + "/index.html",
332 body, err := mdtohtml(path) 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 := &sects[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
215 }
216
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 := &sects[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 {
382 return err
383 }
384 return os.WriteFile(outpath, []byte(html), 0644)
385 }
386
387 func main() {
388 if len(os.Args) < 3 {
389 fmt.Println("usage: qwb <src> <out> [-x|-y]")
390 return
391 }
392 src, out := os.Args[1], os.Args[2]
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
403 }
404 }
405
406 cfg := loadcfg(src)
407
408 sects, err := scansrc(src)
409 if err != nil {
410 fmt.Fprintln(os.Stderr, "scan:", err)
411 os.Exit(1)
412 }
413
414 var process func(dir, rel string)
415 process = func(dir, rel string) {
416 entries, err := os.ReadDir(dir)
333 if err != nil { 417 if err != nil {
334 return err 418 return
335 } 419 }
336 body = fixlinks(body) 420 for _, e := range entries {
337 pageTitle, body := extracth1(body) 421 name := e.Name()
338 if pageTitle == "" { 422 childRel := filepath.Join(rel, name)
339 pageTitle = cfg.headertext 423 childAbs := filepath.Join(dir, name)
340 } 424 if e.IsDir() {
341 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" 425 process(childAbs, childRel)
342 title := cfg.headertext 426 continue
343 if filepath.Base(path) != "index.md" { 427 }
344 title = cfg.headertext + " | " + pageTitle 428 if !strings.HasSuffix(name, ".md") {
345 } 429
346 pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title) 430 dst := filepath.Join(out, childRel)
347 pg = strings.ReplaceAll(pg, "{{SITE_TITLE}}", pageTitle) 431 _ = os.MkdirAll(filepath.Dir(dst), 0755)
348 pg = strings.ReplaceAll(pg, "{{FOOTER_TEXT}}", cfg.footertext) 432 if data, err := os.ReadFile(childAbs); err == nil {
349 pg = strings.ReplaceAll(pg, "{{CSS}}", cfg.cssfile) 433 _ = os.WriteFile(dst, data, 0644)
350 pg = strings.ReplaceAll(pg, "{{NAV}}", buildnav(rootsec, subs, cur)) 434 }
351 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body) 435 continue
352 outpath = strings.TrimSuffix(outpath, ".md") + ".html" 436 }
353 return os.WriteFile(outpath, []byte(pg), 0644) 437 htmlRel := strings.TrimSuffix(childRel, ".md") + ".html"
354 }) 438 href := "/" + filepath.ToSlash(htmlRel)
355 439
356 if err != nil { 440 var nav string
357 fmt.Fprintln(os.Stderr, err) 441 if mode == "y" {
358 os.Exit(1) 442 nav = navY(sects, href)
359 } 443 } else {
360 } 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 }