package main import "mustache" import "core:encoding/json" import "core:fmt" import "core:log" import "core:os" import "core:strings" import "core:time" import "core:time/datetime" Page_Context :: struct { permalink: string, title: string, starred: bool, date_iso: string, date_display: string, } Year_Section :: struct { year: string, posts: [dynamic]Page_Context, } Base_Data :: struct { now: datetime.DateTime, author: string, params: json.Value, body: string, title: string, og: Open_Graph, } Page_Data :: struct { using base: Base_Data, page_title: string, date_iso: string, date_display: string, } Home_Data :: struct { using base: Base_Data, pages: [dynamic]Page_Context, } Section_Data :: struct { using base: Base_Data, page_title: string, by_year: [dynamic]Year_Section, } build_page_context :: proc(page: Page) -> Page_Context { return Page_Context { permalink = page.permalink, title = page.title, starred = page.is_starred, date_iso = page.date, date_display = format_date(page.date), } } strip_html_tags :: proc(s: string) -> string { sb := strings.builder_make() defer strings.builder_destroy(&sb) in_tag := false start := 0 for i in 0 ..< len(s) { if s[i] == '<' && !in_tag { if i > start { strings.write_string(&sb, s[start:i]) } in_tag = true } else if s[i] == '>' && in_tag { in_tag = false start = i + 1 } } if start == 0 { return s } if !in_tag && start < len(s) { strings.write_string(&sb, s[start:]) } return strings.to_string(sb) } load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template { data, ok := vfs_get(vfs, virtual_path) if !ok { log.warnf("thor: template %s not found", virtual_path) return mustache.Template{} } tpl, err := mustache.parse(string(data)) if err != nil { log.warnf("thor: failed to parse template %s: %v", virtual_path, err) } return tpl } get_template :: proc( vfs: ^VFS, layout: string, cache: ^map[string]mustache.Template, ) -> mustache.Template { chain: [4]string n := 0 chain[n] = layout; n += 1 if strings.has_suffix(layout, "_index") && layout != "section_index" { chain[n] = "section_index"; n += 1 } if layout != "page" && layout != "base" { chain[n] = "page"; n += 1 } if layout != "base" { chain[n] = "base"; n += 1 } for i in 0.. string { if len(s) == 0 { return s } if s[0] >= 'a' && s[0] <= 'z' { return fmt.aprintf("%c%s", s[0] - 32, s[1:]) } return s } render_template :: proc( content_tpl: mustache.Template, data: any, partials: map[string]mustache.Template, ) -> string { result, err := mustache.render(content_tpl, data, partials) if err != nil { fmt.eprintfln("thor: mustache error: %v", err) return "" } return result } render_site :: proc(site: ^Site) { pages := site.pages[:] sort_pages_by_date(pages) // Load shared resources partials := load_partials(&site.vfs) partials["base"] = load_template(&site.vfs, "layouts/base.html") template_cache: map[string]mustache.Template defer delete(template_cache) now, ok := time.time_to_datetime(time.now()) assert(ok) // Build base data once base := Base_Data { now = now, author = site.author, params = site.params, og = { site_name = site.title, description = site.description, image = fmt.tprintf("%s/avatar.jpg", site.base_url), locale = "en_US", }, } // Find home page home: Page has_home := false for page in pages { if page.section == "" && page._is_index { home = page has_home = true break } } // Collect sections sections := make(map[string]bool) defer delete(sections) for page in pages { if page.section != "" { sections[page.section] = true } } // Render individual content pages (skip all index pages) for page in pages { if page._is_index { continue } tpl := get_template(&site.vfs, page.layout, &template_cache) html := render_page_html(page, site, tpl, partials, base) if .Minify in site.features { html = minify_html(html) } write_page(site.output_dir, page.permalink, html) } // Render section index pages for section in sections { section_index: Page has_section_index := false for page in pages { if page.section == section && page._is_index { section_index = page has_section_index = true break } } layout := fmt.tprintf("%s_index", section) section_tpl := get_template(&site.vfs, layout, &template_cache) html := render_section( site, section, section_index, has_section_index, section_tpl, partials, base, ) if .Minify in site.features { html = minify_html(html) } write_page(site.output_dir, fmt.aprintf("/%s/", section), html) } // Render home page if has_home { home_tpl := get_template(&site.vfs, "home", &template_cache) home_html := render_home_html(home, site, home_tpl, partials, base) if .Minify in site.features { home_html = minify_html(home_html) } write_file(fmt.tprintf("%s/index.html", site.output_dir), home_html) } // Generate RSS feed rss := generate_rss(site) write_file(fmt.tprintf("%s/index.xml", site.output_dir), rss) // Generate sitemap sitemap := generate_sitemap(site) write_file(fmt.tprintf("%s/sitemap.xml", site.output_dir), sitemap) // Copy and optionally minify assets directory copy_assets_dir(&site.vfs, site.output_dir, site.features) // Generate robots.txt robots := fmt.aprintf("User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", site.base_url) write_file(fmt.tprintf("%s/robots.txt", site.output_dir), robots) total := len(pages) + len(sections) if !has_home { total += 1 } fmt.printfln("Rendered %d pages to %s", total, site.output_dir) } render_page_html :: proc( page: Page, site: ^Site, content_tpl: mustache.Template, partials: map[string]mustache.Template, base: Base_Data, ) -> string { is_article := page.section != "" data := Page_Data { base = base, } data.title = fmt.tprintf("%s | %s", page.title, site.title) data.page_title = page.title data.body = page.body_html data.date_iso = page.date data.date_display = format_date(page.date) data.og.url = fmt.tprintf("%s%s", site.base_url, page.permalink) data.og.title = strip_html_tags(page.title) data.og.type = "article" if is_article else "website" data.og.is_article = is_article data.og.section = page.section data.og.published_time = page.date return render_template(content_tpl, data, partials) } render_home_html :: proc( home: Page, site: ^Site, content_tpl: mustache.Template, partials: map[string]mustache.Template, base: Base_Data, ) -> string { list_pages := make([dynamic]Page_Context) defer delete(list_pages) for page in site.pages { if page._is_index { continue } append(&list_pages, build_page_context(page)) } data := Home_Data { base = base, } data.title = site.title data.body = home.body_html data.pages = list_pages data.og.url = fmt.tprintf("%s/", site.base_url) data.og.title = site.title data.og.type = "website" data.og.is_article = false return render_template(content_tpl, data, partials) } render_section :: proc( site: ^Site, section: string, section_index: Page, has_index: bool, content_tpl: mustache.Template, partials: map[string]mustache.Template, base: Base_Data, ) -> string { by_year := make([dynamic]Year_Section) defer delete(by_year) current_year := "" for page in site.pages { if page.section != section || page._is_index { continue } year := get_year(page.date) if year != current_year { append(&by_year, Year_Section{year = year}) current_year = year } append(&by_year[len(by_year) - 1].posts, build_page_context(page)) } data := Section_Data { base = base, } if has_index { data.body = section_index.body_html data.page_title = section_index.title data.title = fmt.tprintf("%s | %s", section_index.title, site.title) data.og.title = section_index.title } else { data.page_title = capitalize(section) data.title = fmt.tprintf("%s | %s", capitalize(section), site.title) data.og.title = capitalize(section) } data.by_year = by_year data.og.url = fmt.tprintf("%s/%s/", site.base_url, section) data.og.type = "website" data.og.is_article = false return render_template(content_tpl, data, partials) } load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template { partials: map[string]mustache.Template prefix := "layouts/partials/" for virtual_path in vfs.files { if !strings.has_prefix(virtual_path, prefix) { continue } if !strings.has_suffix(virtual_path, ".html") { continue } stripped := virtual_path[len(prefix):] key := stripped[:len(stripped) - len(".html")] data, ok := vfs_get(vfs, virtual_path) if !ok { continue } tpl, err := mustache.parse(string(data)) if err != nil { log.warnf("thor: failed to parse partial %s: %v", key, err) continue } partials[key] = tpl } return partials } format_date :: proc(iso: string) -> string { if len(iso) < 10 { return "" } date, _, _, _ := time.iso8601_to_components(iso) return fmt.aprintf("%s %d, %d", time.Month(date.month), date.day, date.year) } get_year :: proc(iso: string) -> string { if len(iso) < 4 { return "" } return iso[:4] } sort_pages_by_date :: proc(pages: []Page) { for i in 1 ..< len(pages) { key := pages[i] j := i - 1 for j >= 0 && pages[j].date < key.date { pages[j + 1] = pages[j] j -= 1 } pages[j + 1] = key } } write_page :: proc(output_dir: string, permalink: string, html: string) { rel := permalink if len(rel) > 0 && rel[0] == '/' { rel = rel[1:] } dir := fmt.tprintf("%s/%s", output_dir, rel) if err := os.make_directory_all(dir); err != nil && err != .Exist { fmt.eprintfln("thor: cannot create %s: %v", dir, err) return } file_path := fmt.tprintf("%s/index.html", dir) write_file(file_path, html) } write_file :: proc(path: string, html: string) { if err := os.write_entire_file_from_string(path, html); err != nil { fmt.eprintfln("thor: cannot write %s: %v", path, err) } }