Files
thor/site.odin
T
Spencer Brower f78e3811e4 feat!: Added the ability to disable features / extensions.
BREAKING CHANGE: `sectionate` has been moved to `markdown_extensions.sections`
in the config.
2026-07-16 17:25:19 -04:00

253 lines
7.2 KiB
Odin

package main
import "core:encoding/json"
import "core:flags"
import "core:fmt"
import "core:log"
import "core:mem"
import "core:os"
import "core:strings"
// Site is the primary workhorse.
Site :: struct {
arena: mem.Dynamic_Arena,
title: string,
description: string,
author: string,
base_url: string,
config_path: string,
content_dir: string,
assets_dir: string,
output_dir: string,
layouts_dir: string,
params: json.Object,
features: bit_set[Feature],
markdown_extensions: bit_set[Markdown_Extension],
}
Feature :: enum {
Drafts,
Minify,
Watch,
}
Markdown_Extension :: enum {
Emoji,
Sidenotes,
Alerts,
Highlight,
Sections,
}
DEFAULT_MARKDOWN_EXTENSIONS :: bit_set[Markdown_Extension] {
.Emoji,
.Sidenotes,
.Alerts,
// .Highlight,
// .Sections,
}
// Configuration loaded from `thor.json`. Gets folded in to Site before
// Flags
Config_File :: struct {
title: string,
description: string,
base_url: string,
author: string,
content_dir: string,
assets_dir: string,
output_dir: string,
layouts_dir: string,
markdown_extensions: json.Value,
params: json.Value,
}
// Configuration loaded from command line arguments. Gets folded in to Site
// after Config_File
Flags :: struct {
config_path: string `args:"name=config" usage:"Path to thor.json config file"`,
base_url: string `args:"name=base-url" usage:"Site base URL (e.g. https://example.com)"`,
content_dir: string `args:"name=content" usage:"Path to content directory"`,
assets_dir: string `args:"name=assets" usage:"Path to assets directory (CSS, JS, fonts, images)"`,
output_dir: string `args:"name=output" usage:"Path to output directory (default: public/)"`,
drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`,
watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`,
minify: bool `args:"name=minify" usage:"Minify HTML output and CSS assets"`,
md_enable: string `args:"name=ext" usage:"Enable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
md_disable: string `args:"name=no-ext" usage:"Disable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
}
init_site :: proc(site: ^Site, args: []string) {
mem.dynamic_arena_init(&site.arena, alignment = 64)
alloc := site_allocator(site)
// Set defaults
site.base_url = "http://localhost:8080"
site.markdown_extensions = DEFAULT_MARKDOWN_EXTENSIONS
_flags: Flags
flags.parse_or_exit(&_flags, args, .Odin, alloc)
path := _flags.config_path
if path == "" {
found, ok := find_config("thor.json")
if ok {
path = found
log.debugf("thor: using config %s", path)
} else {
path = "./thor.json"
}
}
config: Config_File
config_loaded := load_config_file(&config, path, alloc)
config_dir := "./"
if idx := strings.last_index(path, "/"); idx >= 0 {
config_dir = path[:idx]
}
if config_loaded {
site_apply_config(site, config, config_dir)
} else {
site_apply_path_defaults(site, config_dir)
}
site_apply_cli_flags(site, _flags)
site.config_path = path
}
load_config_file :: proc(
config: ^Config_File,
path: string,
allocator := context.allocator,
) -> bool {
data, err := os.read_entire_file_from_path(path, allocator)
if err != nil {
return false
}
unmarshal_err := json.unmarshal_string(string(data), config, allocator = allocator)
if unmarshal_err != nil {
log.warnf("thor: failed to parse %s: %v", path, unmarshal_err)
return false
}
return true
}
site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string) {
if config.title != "" do site.title = config.title
if config.description != "" do site.description = config.description
if config.author != "" do site.author = config.author
if config.base_url != "" do site.base_url = config.base_url
site.content_dir =
config.content_dir if config.content_dir != "" else fmt.tprintf("%s/content", config_dir)
site.assets_dir =
config.assets_dir if config.assets_dir != "" else fmt.tprintf("%s/assets", config_dir)
site.output_dir =
config.output_dir if config.output_dir != "" else fmt.tprintf("%s/public", config_dir)
site.layouts_dir =
config.layouts_dir if config.layouts_dir != "" else fmt.tprintf("%s/layouts", config_dir)
if params, ok := config.markdown_extensions.(json.Object); ok {
site.params = params
}
// Apply markdown extensions from config
if ext_obj, ok := config.markdown_extensions.(json.Object); ok {
apply_extension_config(&site.markdown_extensions, ext_obj)
}
}
site_apply_path_defaults :: proc(site: ^Site, config_dir: string) {
site.content_dir = fmt.tprintf("%s/content", config_dir)
site.assets_dir = fmt.tprintf("%s/assets", config_dir)
site.output_dir = fmt.tprintf("%s/public", config_dir)
site.layouts_dir = fmt.tprintf("%s/layouts", config_dir)
}
site_apply_cli_flags :: proc(site: ^Site, flags: Flags) {
if flags.base_url != "" do site.base_url = flags.base_url
if flags.content_dir != "" do site.content_dir = flags.content_dir
if flags.assets_dir != "" do site.assets_dir = flags.assets_dir
if flags.output_dir != "" do site.output_dir = flags.output_dir
if flags.drafts {site.features += {.Drafts}}
if flags.watch {site.features += {.Watch}}
if flags.minify {site.features += {.Minify}}
site.markdown_extensions += parse_extension_list(flags.md_enable)
site.markdown_extensions -= parse_extension_list(flags.md_disable)
}
parse_extension_list :: proc(s: string) -> bit_set[Markdown_Extension] {
result: bit_set[Markdown_Extension]
if s == "" do return result
for part in strings.split(s, ",", allocator = context.temp_allocator) {
name := strings.to_lower(strings.trim_space(part), allocator = context.temp_allocator)
switch name {
case "emoji":
result += {.Emoji}
case "sidenotes":
result += {.Sidenotes}
case "alerts":
result += {.Alerts}
case "highlight":
result += {.Highlight}
case "sections":
result += {.Sections}
}
}
return result
}
apply_extension_config :: proc(ext: ^bit_set[Markdown_Extension], config: json.Object) {
for name, val in config {
enabled, is_bool := val.(json.Boolean)
if !is_bool do continue
switch name {
case "emoji":
if enabled {ext^ += {.Emoji}} else {ext^ -= {.Emoji}}
case "sidenotes":
if enabled {ext^ += {.Sidenotes}} else {ext^ -= {.Sidenotes}}
case "alerts":
if enabled {ext^ += {.Alerts}} else {ext^ -= {.Alerts}}
case "highlight":
if enabled {ext^ += {.Highlight}} else {ext^ -= {.Highlight}}
case "sections":
if enabled {ext^ += {.Sections}} else {ext^ -= {.Sections}}
}
}
}
site_allocator :: proc(site: ^Site) -> mem.Allocator {
return mem.dynamic_arena_allocator(&site.arena)
}
destroy_site :: proc(site: ^Site) {
mem.dynamic_arena_destroy(&site.arena)
}
find_config :: proc(filename: string) -> (path: string, ok: bool) {
dir, _ := os.get_working_directory(context.temp_allocator)
for {
candidate := fmt.tprintf("%s/%s", dir, filename)
if os.exists(candidate) {
path = candidate
ok = true
return
}
idx := strings.last_index(dir, "/")
if idx <= 0 {
return
}
dir = dir[:idx]
}
}