|
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>
|
|
5
|
25 <footer><p>{{FOOTER_TEXT}}</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 }
|