Files
thor/render.odin

447 lines
10 KiB
Odin

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"
Template_Context :: struct {
now: string,
date_format: string,
timezone: ^datetime.TZ_Region,
og: Open_Graph,
params: json.Value,
site: Site_Context,
menus: map[string][]Menu_Entry,
page: Page,
// Home data
pages: [dynamic]Page,
// Section Data
// TODO: Remove "posts" from the Odin code
posts: [dynamic]Page,
}
load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template {
entry, data, ok := vfs_get_entry(vfs, virtual_path)
if !ok {
log.fatalf("template %s not found", virtual_path)
os.exit(1)
}
source := string(data)
tpl, err := mustache.parse(source, entry.fs_path)
if err != nil {
b := mustache.body(err)
log.errorf(
"%s",
mustache.format_error(
entry.fs_path,
source,
b.pos,
b.msg,
colorize = mustache.should_colorize(),
),
)
os.exit(1)
}
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 ..< n {
candidate := chain[i]
if cached, ok := cache[candidate]; ok {
return cached
}
virtual := fmt.tprintf("layouts/%s.html", candidate)
if _, ok := vfs_get(vfs, virtual); ok {
tpl := load_template(vfs, virtual)
cache[candidate] = tpl
return tpl
}
if candidate != chain[n - 1] {
log.debugf("template %s not found, falling back", virtual)
}
}
log.errorf("base.html not found in VFS")
return mustache.Template{}
}
to_title_case :: proc(s: string, allocator := context.allocator) -> string {
if len(s) == 0 {
return s
}
out := transmute([]byte)strings.clone(s, allocator)
capitalize_next := true
for char, i in s {
switch char {
case '-', '_':
out[i] = ' '
fallthrough
case ' ':
capitalize_next = true
case 'a' ..= 'z':
if capitalize_next {
out[i] = u8(char) - 32
}
fallthrough
case:
capitalize_next = false
}
}
return string(out)
}
merge_params :: proc(site, page: json.Value) -> json.Value {
if page == nil do return site
if site == nil do return page
site_obj, ok1 := site.(json.Object)
page_obj, ok2 := page.(json.Object)
if !ok1 do return page
if !ok2 do return site
merged := make(json.Object, len(site_obj) + len(page_obj), context.temp_allocator)
for k, v in site_obj {merged[k] = v}
for k, v in page_obj {merged[k] = v}
return merged
}
render_template :: proc(
content_tpl: mustache.Template,
ctx: Template_Context,
partials: map[string]mustache.Template,
reported_errors: ^map[string]bool,
) -> string {
result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials)
if err != nil {
formatted := mustache.format_render_error(
err,
content_tpl,
colorize = mustache.should_colorize(),
)
if formatted in reported_errors^ {
return ""
}
reported_errors[formatted] = true
log.errorf("%s", formatted)
return ""
}
return result
}
render_site :: proc(site: ^Site) {
allocator := site_allocator(site)
pages := site.pages[:]
sort_pages(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)
errors := make(map[string]bool, context.temp_allocator)
offset, ok := mustache.compute_utc_offset(site.tz)
assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok2)
ctx := Template_Context {
site = site.site_context,
menus = site.menus,
now = now,
og = site.og,
date_format = site.date.format,
timezone = site.tz,
}
// Find home page
home: Page
has_home := false
for page in pages {
if page.section == "" && page._is_index {
home = page
has_home = true
break
}
}
ctx.page = home
// 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, ctx, &errors)
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,
ctx,
&errors,
)
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, ctx, &errors)
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
}
log.infof("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,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
ctx := ctx
ctx.page = page
ctx.og = og_for_page(site.og, page)
ctx.params = merge_params(site.params, page.params)
return render_template(content_tpl, ctx, partials, seen)
}
render_home_html :: proc(
home: Page,
site: ^Site,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
list_pages := make([dynamic]Page, 0, 8, context.temp_allocator)
for page in site.pages {
if page._is_index {
continue
}
append(&list_pages, page)
}
ctx := ctx
ctx.pages = list_pages
ctx.og = og_for_page(site.og, home)
ctx.params = merge_params(site.params, home.params)
return render_template(content_tpl, ctx, partials, seen)
}
render_section :: proc(
site: ^Site,
section: string,
section_index: Page,
has_index: bool,
content_tpl: mustache.Template,
partials: map[string]mustache.Template,
ctx: Template_Context,
seen: ^map[string]bool,
) -> string {
alloc := site_allocator(site)
posts := make([dynamic]Page, 0, len(site.pages) / 2, context.temp_allocator)
for page in site.pages {
if page.section != section || page._is_index {
continue
}
append(&posts, page)
}
ctx := ctx
if has_index {
ctx.page = section_index
ctx.og = og_for_page(site.og, section_index)
} else {
title := to_title_case(section, alloc)
ctx.page = Page {
title = title,
}
ctx.og.title = title
ctx.og.description = ""
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
ctx.og.type = "website"
ctx.og.is_article = false
}
ctx.posts = posts
ctx.params = merge_params(site.params, ctx.page.params)
return render_template(content_tpl, ctx, partials, seen)
}
load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template {
partials: map[string]mustache.Template
prefix := "layouts/partials/"
for virtual_path, entry 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 := vfs_entry_data(entry) or_continue
source := string(data)
tpl, err := mustache.parse(source, entry.fs_path)
if err != nil {
b := mustache.body(err)
log.errorf(
"%s",
mustache.format_error(
entry.fs_path,
source,
b.pos,
b.msg,
colorize = mustache.should_colorize(),
),
)
os.exit(1)
}
partials[key] = tpl
}
return partials
}
get_year :: proc(iso: string) -> string {
if len(iso) < 4 {
return ""
}
return iso[:4]
}
// Weight primary (ascending). Date secondary (descending) for equal weights.
sort_pages :: proc(pages: #soa[]Page) {
for i in 1 ..< len(pages) {
key := pages[i]
j := i - 1
for j >= 0 && compare_pages(pages, j, key) > 0 {
pages[j + 1] = pages[j]
j -= 1
}
pages[j + 1] = key
}
}
compare_pages :: proc(pages: #soa[]Page, j: int, key: Page) -> int {
wj := pages.weight[j].? or_else DEFAULT_WEIGHT
wk := key.weight.? or_else DEFAULT_WEIGHT
if wj != wk do return wj - wk
// Equal weight → date descending
if pages.date[j] < key.date do return 1
if pages.date[j] > key.date do return -1
return 0
}
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 {
log.errorf("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 {
log.errorf("cannot write %s: %v", path, err)
}
}