mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
471 lines
11 KiB
Odin
471 lines
11 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"
|
|
|
|
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,
|
|
og_section: string,
|
|
og_published: 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)
|
|
}
|
|
|
|
og_type :: proc(is_article: bool) -> string {
|
|
if is_article {
|
|
return "article"
|
|
}
|
|
return "website"
|
|
}
|
|
|
|
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..<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("thor: template %s not found, falling back", virtual)
|
|
}
|
|
}
|
|
|
|
log.errorf("thor: base.html not found in VFS")
|
|
return mustache.Template{}
|
|
}
|
|
|
|
capitalize :: proc(s: string) -> 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,
|
|
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.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 = 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.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 {
|
|
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.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)
|
|
}
|
|
}
|
|
|