#+feature dynamic-literals 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_site_name: string, og_description: string, og_image: string, og_url: string, og_title: string, og_type: string, is_article: bool, } Page_Data :: struct { using base: Base_Data, page_title: string, date_iso: string, date_display: string, is_post: bool, og_section: string, og_published: string, } Home_Data :: struct { using base: Base_Data, list_pages: [dynamic]Page_Context, } Section_Data :: struct { using base: Base_Data, page_title: string, year_sections: [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) } og_type :: proc(is_article: bool) -> string { if is_article { return "article" } return "website" } load_template :: proc(layouts_dir: string, name: string) -> mustache.Template { data, _ := os.read_entire_file_from_path( fmt.tprintf("%s/%s", layouts_dir, name), context.allocator, ) tpl, err := mustache.parse(string(data)) if err != nil { log.warnf("thor: failed to parse template %s: %v", name, err) } return tpl } get_template :: proc( layouts_dir: string, layout: string, cache: ^map[string]mustache.Template, ) -> mustache.Template { // Build fallback chain: → [section_index] → page → base 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.layouts_dir) partials["base"] = load_template(site.layouts_dir, "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, og_description = site.description, og_image = fmt.tprintf("%s/avatar.jpg", site.base_url), } // 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.layouts_dir, 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.layouts_dir, 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.layouts_dir, "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.assets_dir, 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.is_post = is_article data.og_url = fmt.tprintf("%s%s", site.base_url, page.permalink) data.og_title = strip_html_tags(page.title) data.og_type = og_type(is_article) data.is_article = is_article data.og_section = page.section data.og_published = 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.list_pages = list_pages data.og_url = fmt.tprintf("%s/", site.base_url) data.og_title = site.title data.og_type = "website" data.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 { year_sections := make([dynamic]Year_Section) defer delete(year_sections) 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(&year_sections, Year_Section{year = year}) current_year = year } append(&year_sections[len(year_sections) - 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.year_sections = year_sections data.og_url = fmt.tprintf("%s/%s/", site.base_url, section) data.og_type = "website" data.is_article = false return render_template(content_tpl, data, partials) } load_partials :: proc(layouts_dir: string) -> map[string]mustache.Template { partials: map[string]mustache.Template partials_dir := fmt.tprintf("%s/partials", layouts_dir) load_partials_recursive(&partials, partials_dir, "") return partials } load_partials_recursive :: proc( partials: ^map[string]mustache.Template, base_dir: string, rel_prefix: string, ) { entries, err := os.read_all_directory_by_path(base_dir, context.allocator) if err != nil { return } defer os.file_info_slice_delete(entries, context.allocator) for entry in entries { #partial switch entry.type { case .Regular: name := entry.name if !strings.has_suffix(name, ".html") { continue } stripped := name[:len(name) - len(".html")] key := stripped if rel_prefix != "" { key = fmt.tprintf("%s/%s", rel_prefix, stripped) } data, ok := os.read_entire_file_from_path(entry.fullpath, context.allocator) if ok == nil { tpl, perr := mustache.parse(string(data)) if perr != nil { log.warnf("thor: failed to parse partial %s: %v", key, perr) } partials[key] = tpl } case .Directory: sub_prefix := entry.name if rel_prefix != "" { sub_prefix = fmt.tprintf("%s/%s", rel_prefix, entry.name) } load_partials_recursive(partials, entry.fullpath, sub_prefix) } } } 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) } }