|
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
|
|
|
13 const siteTitle = "My Site"
|
|
|
14
|
|
|
15 const tmpl = `<!DOCTYPE html>
|
|
|
16 <html lang="en">
|
|
|
17 <head>
|
|
|
18 <meta charset="UTF-8">
|
|
|
19 <meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
20 <title>{{TITLE}}</title>
|
|
|
21 <link rel="stylesheet" href="/x.css">
|
|
|
22 </head>
|
|
|
23 <body>
|
|
|
24 <header><h1>` + siteTitle + `</h1></header>
|
|
|
25 <nav>{{NAV}}</nav>
|
|
|
26 <main>{{CONTENT}}</main>
|
|
|
27 <footer>
|
|
|
28 <p>` + siteTitle + `</p>
|
|
|
29 <p>© Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p>
|
|
|
30 </footer>
|
|
|
31 </body>
|
|
|
32 </html>`
|
|
|
33
|
|
|
34 // section — top-level directory, each with its own index and pages
|
|
|
35 type section struct {
|
|
|
36 title string
|
|
|
37 index string // /section/index.html, or "" if none
|
|
|
38 pages []page // non-index .md files within this section
|
|
|
39 }
|
|
|
40
|
|
|
41 type page struct {
|
|
|
42 title string
|
|
|
43 path string
|
|
|
44 }
|
|
|
45
|
|
|
46 // collectSections scans one level deep: root files go into a synthetic root
|
|
|
47 // section, each subdirectory becomes its own section.
|
|
|
48 func collectSections(root string) (rootSection section, subs []section) {
|
|
|
49 rootSection.title = siteTitle
|
|
|
50
|
|
|
51 entries, _ := os.ReadDir(root)
|
|
|
52 for _, e := range entries {
|
|
|
53 full := filepath.Join(root, e.Name())
|
|
|
54 if e.IsDir() {
|
|
|
55 s := scanSection(full, root)
|
|
|
56 if s.index != "" || len(s.pages) > 0 {
|
|
|
57 subs = append(subs, s)
|
|
|
58 }
|
|
|
59 continue
|
|
|
60 }
|
|
|
61 if strings.HasSuffix(e.Name(), ".md") {
|
|
|
62 if e.Name() == "index.md" {
|
|
|
63 rootSection.index = "/index.html"
|
|
|
64 } else {
|
|
|
65 rel, _ := filepath.Rel(root, full)
|
|
|
66 rootSection.pages = append(rootSection.pages, page{
|
|
|
67 title: titleFromName(e.Name()),
|
|
|
68 path: "/" + strings.TrimSuffix(rel, ".md") + ".html",
|
|
|
69 })
|
|
|
70 }
|
|
|
71 }
|
|
|
72 }
|
|
|
73 return
|
|
|
74 }
|
|
|
75
|
|
|
76 func scanSection(dir, root string) section {
|
|
|
77 s := section{title: titleFromName(filepath.Base(dir))}
|
|
|
78 entries, _ := os.ReadDir(dir)
|
|
|
79 for _, e := range entries {
|
|
|
80 if e.IsDir() {
|
|
|
81 continue // only one level deep
|
|
|
82 }
|
|
|
83 if !strings.HasSuffix(e.Name(), ".md") {
|
|
|
84 continue
|
|
|
85 }
|
|
|
86 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name()))
|
|
|
87 htmlPath := "/" + strings.TrimSuffix(rel, ".md") + ".html"
|
|
|
88 if e.Name() == "index.md" {
|
|
|
89 s.index = htmlPath
|
|
|
90 } else {
|
|
|
91 s.pages = append(s.pages, page{titleFromName(e.Name()), htmlPath})
|
|
|
92 }
|
|
|
93 }
|
|
|
94 return s
|
|
|
95 }
|
|
|
96
|
|
|
97 func navLink(b *strings.Builder, p page, cur string) {
|
|
|
98 if p.path == cur {
|
|
|
99 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title)
|
|
|
100 } else {
|
|
|
101 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title)
|
|
|
102 }
|
|
|
103 }
|
|
|
104
|
|
|
105 // buildNav produces one or two <ul> blocks.
|
|
|
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
|
|
|
110
|
|
|
111 b.WriteString("<ul>\n")
|
|
|
112 if rootSec.index != "" {
|
|
|
113 navLink(&b, page{"Home", rootSec.index}, cur)
|
|
|
114 }
|
|
|
115 for _, p := range rootSec.pages {
|
|
|
116 navLink(&b, p, cur)
|
|
|
117 }
|
|
|
118 for _, s := range subs {
|
|
|
119 link := s.index
|
|
|
120 if link == "" && len(s.pages) > 0 {
|
|
|
121 link = s.pages[0].path
|
|
|
122 }
|
|
|
123 if link == "" {
|
|
|
124 continue
|
|
|
125 }
|
|
|
126 navLink(&b, page{s.title, link}, cur)
|
|
|
127 }
|
|
|
128 b.WriteString("</ul>\n")
|
|
|
129
|
|
|
130 // Find which sub-section cur belongs to
|
|
|
131 for _, s := range subs {
|
|
|
132 if !sectionContains(s, cur) {
|
|
|
133 continue
|
|
|
134 }
|
|
|
135 // Only render sub-nav if there are 2+ addressable pages in the section
|
|
|
136 total := len(s.pages)
|
|
|
137 if s.index != "" {
|
|
|
138 total++
|
|
|
139 }
|
|
|
140 if total < 2 {
|
|
|
141 break
|
|
|
142 }
|
|
|
143 b.WriteString("<ul>\n")
|
|
|
144 if s.index != "" {
|
|
|
145 navLink(&b, page{"Index", s.index}, cur)
|
|
|
146 }
|
|
|
147 for _, p := range s.pages {
|
|
|
148 navLink(&b, p, cur)
|
|
|
149 }
|
|
|
150 b.WriteString("</ul>\n")
|
|
|
151 break
|
|
|
152 }
|
|
|
153
|
|
|
154 return b.String()
|
|
|
155 }
|
|
|
156
|
|
|
157 func sectionContains(s section, cur string) bool {
|
|
|
158 if s.index == cur {
|
|
|
159 return true
|
|
|
160 }
|
|
|
161 for _, p := range s.pages {
|
|
|
162 if p.path == cur {
|
|
|
163 return true
|
|
|
164 }
|
|
|
165 }
|
|
|
166 return false
|
|
|
167 }
|
|
|
168
|
|
|
169 func mdToHTML(path string) (string, error) {
|
|
|
170 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml")
|
|
|
171 f, err := os.Open(path)
|
|
|
172 if err != nil {
|
|
|
173 return "", err
|
|
|
174 }
|
|
|
175 defer f.Close()
|
|
|
176 var buf strings.Builder
|
|
|
177 cmd.Stdin = f
|
|
|
178 cmd.Stdout = &buf
|
|
|
179 cmd.Stderr = os.Stderr
|
|
|
180 if err := cmd.Run(); err != nil {
|
|
|
181 return "", err
|
|
|
182 }
|
|
|
183 return buf.String(), nil
|
|
|
184 }
|
|
|
185
|
|
|
186 func titleFromName(name string) string {
|
|
|
187 name = strings.TrimSuffix(name, ".md")
|
|
|
188 name = strings.ReplaceAll(name, "-", " ")
|
|
|
189 if len(name) > 0 {
|
|
|
190 name = strings.ToUpper(name[:1]) + name[1:]
|
|
|
191 }
|
|
|
192 return name
|
|
|
193 }
|
|
|
194
|
|
|
195 func fixLinks(s string) string {
|
|
|
196 return strings.NewReplacer(
|
|
|
197 ".md)", ".html)", ".md\"", ".html\"",
|
|
|
198 ".md'", ".html'", ".md#", ".html#",
|
|
|
199 ".md>", ".html>", ".md ", ".html ",
|
|
|
200 ".md,", ".html,",
|
|
|
201 ).Replace(s)
|
|
|
202 }
|
|
|
203
|
|
|
204 func copyFile(src, dst string) error {
|
|
|
205 in, err := os.Open(src)
|
|
|
206 if err != nil {
|
|
|
207 return err
|
|
|
208 }
|
|
|
209 defer in.Close()
|
|
|
210 out, err := os.Create(dst)
|
|
|
211 if err != nil {
|
|
|
212 return err
|
|
|
213 }
|
|
|
214 defer out.Close()
|
|
|
215 _, err = io.Copy(out, in)
|
|
|
216 return err
|
|
|
217 }
|
|
|
218
|
|
|
219 func main() {
|
|
|
220 if len(os.Args) != 3 {
|
|
|
221 fmt.Fprintln(os.Stderr, "usage: qwb <in> <out>")
|
|
|
222 os.Exit(1)
|
|
|
223 }
|
|
|
224 src, out := os.Args[1], os.Args[2]
|
|
|
225
|
|
|
226 if entries, err := os.ReadDir(out); err == nil && len(entries) > 0 {
|
|
|
227 fmt.Fprintf(os.Stderr, "qwb: %s is not empty, overwrite? [y/N] ", out)
|
|
|
228 s, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
|
229 if strings.TrimSpace(strings.ToLower(s)) != "y" {
|
|
|
230 fmt.Fprintln(os.Stderr, "aborted")
|
|
|
231 os.Exit(1)
|
|
|
232 }
|
|
|
233 if err := os.RemoveAll(out); err != nil {
|
|
|
234 fmt.Fprintln(os.Stderr, err)
|
|
|
235 os.Exit(1)
|
|
|
236 }
|
|
|
237 }
|
|
|
238
|
|
|
239 rootSec, subs := collectSections(src)
|
|
|
240
|
|
|
241 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
|
|
242 if err != nil {
|
|
|
243 return err
|
|
|
244 }
|
|
|
245 rel, _ := filepath.Rel(src, path)
|
|
|
246 outpath := filepath.Join(out, rel)
|
|
|
247
|
|
|
248 if d.IsDir() {
|
|
|
249 return os.MkdirAll(outpath, 0755)
|
|
|
250 }
|
|
|
251
|
|
|
252 if !strings.HasSuffix(path, ".md") {
|
|
|
253 return copyFile(path, outpath)
|
|
|
254 }
|
|
|
255
|
|
|
256 body, err := mdToHTML(path)
|
|
|
257 if err != nil {
|
|
|
258 return err
|
|
|
259 }
|
|
|
260 body = fixLinks(body)
|
|
|
261
|
|
|
262 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html"
|
|
|
263
|
|
|
264 title := siteTitle
|
|
|
265 if filepath.Base(path) != "index.md" {
|
|
|
266 title = siteTitle + " | " + titleFromName(filepath.Base(path))
|
|
|
267 }
|
|
|
268
|
|
|
269 pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title)
|
|
|
270 pg = strings.ReplaceAll(pg, "{{NAV}}", buildNav(rootSec, subs, cur))
|
|
|
271 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body)
|
|
|
272
|
|
|
273 outpath = strings.TrimSuffix(outpath, ".md") + ".html"
|
|
|
274 return os.WriteFile(outpath, []byte(pg), 0644)
|
|
|
275 })
|
|
|
276
|
|
|
277 if err != nil {
|
|
|
278 fmt.Fprintln(os.Stderr, err)
|
|
|
279 os.Exit(1)
|
|
|
280 }
|
|
|
281 } |