package main import ( "bytes" "encoding/json" "fmt" "html/template" "io" "io/fs" "log/slog" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/fsnotify/fsnotify" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" goldmarkhtml "github.com/yuin/goldmark/renderer/html" ) // ── types ───────────────────────────────────────────────────────────────────── type Page struct { Title string Description string Section string Order int Slug string URL string Content template.HTML Excerpt string } type Section struct { Slug string Title string Pages []*Page } type SidebarPage struct { Title string URL string Active bool } type SidebarSection struct { Title string URL string Pages []SidebarPage } type Breadcrumb struct { Label string URL string } type PageLink struct { Title string URL string } type PageData struct { Title string Description string Content template.HTML Root string Canonical string Nav []SidebarSection Breadcrumbs []Breadcrumb Prev *PageLink Next *PageLink Year int Customer any IsAdmin bool } type SearchDoc struct { Title string `json:"title"` URL string `json:"url"` Section string `json:"section"` Excerpt string `json:"excerpt"` } type RSSItem struct { Title string URL string Description string Section string } // ── config ──────────────────────────────────────────────────────────────────── const ( siteBaseURL = "https://docs.arcline.it" distDir = "dist" contentDir = "content" staticDir = "static" ) var sectionMeta = []struct { Slug string Title string }{ {"getting-started", "Getting Started"}, {"migrate", "Migration Guides"}, {"wordpress", "WordPress"}, {"vps", "VPS Guides"}, {"privacy", "Privacy & Self-Hosting"}, {"reference", "Reference"}, } // ── main ────────────────────────────────────────────────────────────────────── func main() { watch := false for _, arg := range os.Args[1:] { if arg == "--watch" { watch = true } } if watch { runWatch() } else { build() } } func build() { start := time.Now() if err := os.RemoveAll(distDir); err != nil { die("rm dist: %v", err) } pages, err := collectPages() if err != nil { die("collect pages: %v", err) } sections := buildSections(pages) tmpl := template.New("").Funcs(template.FuncMap{ "hasPrefix": strings.HasPrefix, }) tmpl, err = template.ParseFiles("templates/page.html", "templates/layout.html") if err != nil { die("parse template: %v", err) } for _, sec := range sections { for i, page := range sec.Pages { var prev, next *PageLink if i > 0 { prev = &PageLink{Title: sec.Pages[i-1].Title, URL: sec.Pages[i-1].URL} } if i < len(sec.Pages)-1 { next = &PageLink{Title: sec.Pages[i+1].Title, URL: sec.Pages[i+1].URL} } if err := renderPage(tmpl, page, sections, sec, prev, next); err != nil { die("render %s: %v", page.URL, err) } } if err := renderSectionIndex(tmpl, sec, sections); err != nil { die("render section index %s: %v", sec.Slug, err) } } if err := renderHome(tmpl, sections); err != nil { die("render home: %v", err) } if err := render404(tmpl); err != nil { die("render 404: %v", err) } if err := copyDir(staticDir, distDir); err != nil { die("copy static: %v", err) } fontSrc := filepath.Join("..", "website", "static", "public", "fonts") if info, err := os.Stat(fontSrc); err == nil && info.IsDir() { fontDst := filepath.Join(distDir, "public", "fonts") if err := copyDir(fontSrc, fontDst); err != nil { fmt.Fprintf(os.Stderr, "warn: copy fonts: %v\n", err) } } if err := generateSearch(pages, sections); err != nil { die("generate search: %v", err) } if err := generateSitemap(pages, sections); err != nil { die("generate sitemap: %v", err) } if err := generateRSS(pages, sections); err != nil { die("generate rss: %v", err) } total := 0 for _, sec := range sections { total += len(sec.Pages) } fmt.Printf("Built %d pages in %s → %s/\n", total, time.Since(start).Round(time.Millisecond), distDir) } func runWatch() { fmt.Println("Watching for changes (Ctrl+C to stop)...") build() watcher, err := fsnotify.NewWatcher() if err != nil { die("fsnotify: %v", err) } defer watcher.Close() for _, dir := range []string{contentDir, "templates", staticDir} { if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return watcher.Add(path) } return nil }); err != nil { die("watch add %s: %v", dir, err) } } debounce := time.NewTimer(0) if !debounce.Stop() { <-debounce.C } for { select { case event := <-watcher.Events: if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove) != 0 { debounce.Reset(300 * time.Millisecond) } case err := <-watcher.Errors: slog.Warn("watch error", "err", err) case <-debounce.C: fmt.Println("\nChange detected, rebuilding...") build() fmt.Println("Watching for changes (Ctrl+C to stop)...") } } } // ── collect ─────────────────────────────────────────────────────────────────── var md = goldmark.New( goldmark.WithExtensions( extension.Table, extension.Strikethrough, extension.TaskList, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( goldmarkhtml.WithUnsafe(), ), ) func collectPages() ([]*Page, error) { var pages []*Page err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { return err } raw, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %w", path, err) } fm, body := parseFrontmatter(raw) var buf bytes.Buffer if err := md.Convert(body, &buf); err != nil { return fmt.Errorf("convert %s: %w", path, err) } rel, _ := filepath.Rel(contentDir, path) parts := strings.Split(filepath.ToSlash(rel), "/") var section, slug string if len(parts) == 1 { return nil } section = parts[0] slug = strings.TrimSuffix(parts[len(parts)-1], ".md") url := "/" + section + "/" + slug + "/" order, _ := strconv.Atoi(fm["order"]) htmlContent := buf.String() pages = append(pages, &Page{ Title: fm["title"], Description: fm["description"], Section: section, Order: order, Slug: slug, URL: url, Content: template.HTML(htmlContent), Excerpt: htmlExcerpt(htmlContent, 220), }) return nil }) return pages, err } func parseFrontmatter(raw []byte) (map[string]string, []byte) { fm := make(map[string]string) s := string(raw) if !strings.HasPrefix(s, "---") { return fm, raw } rest := s[3:] if rest != "" && rest[0] == '\n' { rest = rest[1:] } end := strings.Index(rest, "---") if end < 0 { return fm, raw } block := rest[:end] body := rest[end+3:] if len(body) > 0 && body[0] == '\n' { body = body[1:] } for _, line := range strings.Split(block, "\n") { line = strings.TrimSpace(line) if line == "" { continue } idx := strings.Index(line, ":") if idx < 0 { continue } k := strings.TrimSpace(line[:idx]) v := strings.TrimSpace(line[idx+1:]) v = strings.Trim(v, `"'`) fm[k] = v } return fm, []byte(body) } var tagRe = regexp.MustCompile(`<[^>]+>`) func htmlExcerpt(h string, maxLen int) string { text := tagRe.ReplaceAllString(h, " ") text = strings.Join(strings.Fields(text), " ") if len([]rune(text)) > maxLen { r := []rune(text)[:maxLen] text = string(r) + "…" } return text } // ── sections ────────────────────────────────────────────────────────────────── func buildSections(pages []*Page) []*Section { bySlug := make(map[string]*Section) for _, sm := range sectionMeta { bySlug[sm.Slug] = &Section{Slug: sm.Slug, Title: sm.Title} } for _, p := range pages { sec := bySlug[p.Section] if sec == nil { sec = &Section{ Slug: p.Section, Title: strings.ReplaceAll(strings.ToTitle(p.Section[:1])+p.Section[1:], "-", " "), } bySlug[p.Section] = sec } sec.Pages = append(sec.Pages, p) } for _, sec := range bySlug { sort.Slice(sec.Pages, func(i, j int) bool { if sec.Pages[i].Order != sec.Pages[j].Order { return sec.Pages[i].Order < sec.Pages[j].Order } return sec.Pages[i].Title < sec.Pages[j].Title }) } var out []*Section for _, sm := range sectionMeta { if sec := bySlug[sm.Slug]; sec != nil && len(sec.Pages) > 0 { out = append(out, sec) } } return out } func buildNav(sections []*Section, currentURL string) []SidebarSection { nav := make([]SidebarSection, 0, len(sections)) for _, sec := range sections { pages := make([]SidebarPage, 0, len(sec.Pages)) for _, p := range sec.Pages { pages = append(pages, SidebarPage{ Title: p.Title, URL: p.URL, Active: p.URL == currentURL, }) } nav = append(nav, SidebarSection{ Title: sec.Title, URL: "/" + sec.Slug + "/", Pages: pages, }) } return nav } // ── render ──────────────────────────────────────────────────────────────────── func renderPage(tmpl *template.Template, page *Page, sections []*Section, sec *Section, prev, next *PageLink) error { outPath := filepath.Join(distDir, page.Section, page.Slug, "index.html") f := mustCreate(outPath) defer f.Close() return tmpl.Execute(f, PageData{ Title: page.Title + " — Arcline Docs", Description: page.Description, Content: page.Content, Root: "../../", Canonical: siteBaseURL + page.URL, Nav: buildNav(sections, page.URL), Breadcrumbs: []Breadcrumb{ {Label: sec.Title, URL: "/" + sec.Slug + "/"}, {Label: page.Title}, }, Prev: prev, Next: next, Year: time.Now().Year(), }) } func renderSectionIndex(tmpl *template.Template, sec *Section, sections []*Section) error { outPath := filepath.Join(distDir, sec.Slug, "index.html") f := mustCreate(outPath) defer f.Close() var buf bytes.Buffer buf.WriteString(`
Find guides on getting connected, migrating from other hosts, and managing your Arcline hosting account.
`) for _, sec := range sections { fmt.Fprintf(&buf, `