diff --git a/TODOS.md b/TODOS.md index 0821121..c59e365 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,8 +1,10 @@ - README.md - [ ] "Zero is beautiful" - [ ] Content-hash fingerprinting for CSS and JS cache busting +- [ ] Performance + - [ ] See if we can disable bounds checks in `write_indented` and elsewhere. - [ ] remove "has_*" keys from context. -- [ ] Enable configuration to opt-out of features, particular pre/post processors. +- [x] Enable configuration to opt-out of features, particular pre/post processors. - [ ] proper date/time/now object. - [ ] Footer isn't centered properly. - [ ] mustache data keys for opengraph, etc. diff --git a/content.odin b/content.odin index ed67afa..91dbb6f 100644 --- a/content.odin +++ b/content.odin @@ -35,18 +35,18 @@ Page :: struct { walk_content :: proc(site: ^Site) -> []Page { content_path := site.content_dir include_drafts := .Drafts in site.features - sectionate := .Sections in site.features + ext := site.markdown_extensions allocator := site_allocator(site) pages := make([dynamic]Page, allocator) - collect_home(&pages, content_path, sectionate) - collect_standalone(&pages, content_path, sectionate) + collect_home(&pages, content_path, ext) + collect_standalone(&pages, content_path, ext) posts_path := fmt.tprintf("%s/posts", content_path) if os.exists(posts_path) { - collect_posts(&pages, posts_path, sectionate) + collect_posts(&pages, posts_path, ext) } if include_drafts { @@ -63,10 +63,10 @@ walk_content :: proc(site: ^Site) -> []Page { } } -collect_home :: proc(pages: ^[dynamic]Page, content_path: string, sectionate: bool) { +collect_home :: proc(pages: ^[dynamic]Page, content_path: string, ext: bit_set[Markdown_Extension]) { html_path := fmt.tprintf("%s/index.html", content_path) if os.exists(html_path) { - page, ok := load_page(html_path, .Home, "", sectionate) + page, ok := load_page(html_path, .Home, "", ext) if ok { page.permalink = "/" append(pages, page) @@ -76,7 +76,7 @@ collect_home :: proc(pages: ^[dynamic]Page, content_path: string, sectionate: bo md_path := fmt.tprintf("%s/index.md", content_path) if os.exists(md_path) { - page, ok := load_page(md_path, .Home, "", sectionate) + page, ok := load_page(md_path, .Home, "", ext) if ok { page.permalink = "/" append(pages, page) @@ -84,7 +84,7 @@ collect_home :: proc(pages: ^[dynamic]Page, content_path: string, sectionate: bo } } -collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string, sectionate: bool) { +collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string, ext: bit_set[Markdown_Extension]) { entries, err := os.read_all_directory_by_path(content_path, context.allocator) if err != nil { log.warnf("thor: cannot read %s: %v", content_path, err) @@ -104,14 +104,14 @@ collect_standalone :: proc(pages: ^[dynamic]Page, content_path: string, sectiona } slug := strip_extension(entry.name) - page, ok := load_page(entry.fullpath, .Standalone, slug, sectionate) + page, ok := load_page(entry.fullpath, .Standalone, slug, ext) if ok { append(pages, page) } } } -collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string, sectionate: bool) { +collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string, ext: bit_set[Markdown_Extension]) { entries, err := os.read_all_directory_by_path(posts_path, context.allocator) if err != nil { log.warnf("thor: cannot read %s: %v", posts_path, err) @@ -126,7 +126,7 @@ collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string, sectionate: boo continue } slug := strip_extension(entry.name) - page, ok := load_page(entry.fullpath, .Post, slug, sectionate) + page, ok := load_page(entry.fullpath, .Post, slug, ext) if ok { append(pages, page) } @@ -138,7 +138,7 @@ collect_posts :: proc(pages: ^[dynamic]Page, posts_path: string, sectionate: boo if !os.exists(index_path) { continue } - page, ok := load_page(index_path, .Post, entry.name, sectionate) + page, ok := load_page(index_path, .Post, entry.name, ext) if ok { page.bundle_dir = entry.fullpath append(pages, page) @@ -152,7 +152,7 @@ load_page :: proc( file_path: string, page_type: Page_Type, slug: string, - sectionate: bool, + ext: bit_set[Markdown_Extension], ) -> ( page: Page, ok: bool, @@ -182,11 +182,26 @@ load_page :: proc( if strings.has_suffix(file_path, ".html") { page.body_html = strings.clone(body) } else { - clean_body, sn_defs, mn_defs := strip_definitions(body) + sn_defs := make(map[string]string) + mn_defs := make(map[string]string) + clean_body := body + if .Sidenotes in ext { + clean_body, sn_defs, mn_defs = strip_definitions(body) + } html := cm.markdown_to_html_from_string(clean_body, {.Unsafe}) - html = expand_emoji(html) - html = highlight_code(inject_alerts(inject_notes(html, sn_defs, mn_defs)), file_path) - if sectionate { + if .Emoji in ext { + html = expand_emoji(html) + } + if .Sidenotes in ext { + html = inject_notes(html, sn_defs, mn_defs) + } + if .Alerts in ext { + html = inject_alerts(html) + } + if .Highlight in ext { + html = highlight_code(html, file_path) + } + if .Sections in ext { html = wrap_sections(html) } page.body_html = html diff --git a/site.odin b/site.odin index c255996..0b35ff7 100644 --- a/site.odin +++ b/site.odin @@ -9,49 +9,84 @@ 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.Value, - features: bit_set[Feature], + 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 { - Sections, 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"`, - title: string, - description: string, - base_url: string `args:"name=base-url"`, - content_dir: string `args:"name=content"`, - assets_dir: string `args:"name=assets"`, - output_dir: string `args:"name=output"`, - layouts_dir: string, - author: string, - params: json.Value, - sectionate: bool `args:"name=sections"`, - drafts: bool `args:"name=drafts"`, - watch: bool, - minify: bool `args:"name=minify"`, + 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) { - _flags: Flags - mem.dynamic_arena_init(&site.arena, alignment = 64) // FIXME: This is a hack + 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 @@ -65,106 +100,127 @@ init_site :: proc(site: ^Site, args: []string) { } } - cfg, cfg_ok := load_site_config(path, alloc) - if cfg_ok { - merge_flags(&cfg, _flags) - } else { - cfg = _flags - } + config: Config_File + config_loaded := load_config_file(&config, path, alloc) - site_apply_flags(site, cfg) - - // Determine config file's directory for relative defaults config_dir := "./" if idx := strings.last_index(path, "/"); idx >= 0 { config_dir = path[:idx] } - // Hardcoded defaults (lowest precedence) - // TODO: Probably shouldn't use temp allocator here? - if site.content_dir == "" { - site.content_dir = fmt.tprintf("%s/content", config_dir) - } - if site.assets_dir == "" { - site.assets_dir = fmt.tprintf("%s/assets", config_dir) - } - if site.output_dir == "" { - site.output_dir = fmt.tprintf("%s/public", config_dir) - } - if site.layouts_dir == "" { - site.layouts_dir = fmt.tprintf("%s/layouts", config_dir) - } - if site.base_url == "" { - site.base_url = "http://localhost:8080" + 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_site_config :: proc( +load_config_file :: proc( + config: ^Config_File, path: string, allocator := context.allocator, -) -> ( - config: Flags, - ok: bool, -) { +) -> bool { data, err := os.read_entire_file_from_path(path, allocator) if err != nil { - return + return false } - unmarshal_err := json.unmarshal_string(string(data), &config, allocator = allocator) + 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 + return false } - ok = true - return + return true } -merge_flags :: proc(config: ^Flags, flags: Flags) { - if flags.base_url != "" { - config.base_url = flags.base_url +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 } - if flags.content_dir != "" { - config.content_dir = flags.content_dir + + // Apply markdown extensions from config + if ext_obj, ok := config.markdown_extensions.(json.Object); ok { + apply_extension_config(&site.markdown_extensions, ext_obj) } - if flags.assets_dir != "" { - config.assets_dir = flags.assets_dir - } - if flags.output_dir != "" { - config.output_dir = flags.output_dir - } - if flags.drafts { - config.drafts = true - } - if flags.watch { - config.watch = true - } - if flags.sectionate { - config.sectionate = true - } - if flags.minify { - config.minify = true - } - config.config_path = flags.config_path } -site_apply_flags :: proc(site: ^Site, flags: Flags) { - site.title = flags.title - site.description = flags.description - site.author = flags.author - site.base_url = flags.base_url - site.config_path = flags.config_path - site.content_dir = flags.content_dir - site.assets_dir = flags.assets_dir - site.output_dir = flags.output_dir - site.layouts_dir = flags.layouts_dir - site.params = flags.params +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.sectionate {site.features += {.Sections}} 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 { diff --git a/site_test.odin b/site_test.odin index e8bdd69..e2fd379 100644 --- a/site_test.odin +++ b/site_test.odin @@ -18,7 +18,7 @@ write_temp_config :: proc(name: string, content: string) -> string { } @(test) -test_load_site_config :: proc(t: ^testing.T) { +test_load_config_file :: proc(t: ^testing.T) { path := write_temp_config( "valid", `{ @@ -35,7 +35,8 @@ test_load_site_config :: proc(t: ^testing.T) { ) defer os.remove(path) - cfg, ok := load_site_config(path, context.temp_allocator) + cfg: Config_File + ok := load_config_file(&cfg, path, context.temp_allocator) testing.expect(t, ok) testing.expect_value(t, cfg.title, "Test Site") @@ -62,30 +63,33 @@ test_load_site_config :: proc(t: ^testing.T) { } @(test) -test_load_site_config_missing_file :: proc(t: ^testing.T) { - _, ok := load_site_config("./nonexistent_thor_test.json", context.temp_allocator) +test_load_config_file_missing :: proc(t: ^testing.T) { + cfg: Config_File + ok := load_config_file(&cfg, "./nonexistent_thor_test.json", context.temp_allocator) testing.expect(t, !ok) } @(test) -test_load_site_config_invalid_json :: proc(t: ^testing.T) { +test_load_config_file_invalid_json :: proc(t: ^testing.T) { path := write_temp_config("invalid", `{not valid json}`) defer os.remove(path) + cfg: Config_File ok := false { context.logger = log.nil_logger() - _, ok = load_site_config(path, context.temp_allocator) + ok = load_config_file(&cfg, path, context.temp_allocator) } testing.expect(t, !ok) } @(test) -test_load_site_config_partial :: proc(t: ^testing.T) { +test_load_config_file_partial :: proc(t: ^testing.T) { path := write_temp_config("partial", `{"title":"Partial"}`) defer os.remove(path) - cfg, ok := load_site_config(path, context.temp_allocator) + cfg: Config_File + ok := load_config_file(&cfg, path, context.temp_allocator) testing.expect(t, ok) testing.expect_value(t, cfg.title, "Partial") @@ -94,73 +98,6 @@ test_load_site_config_partial :: proc(t: ^testing.T) { testing.expect(t, cfg.params == nil) } -@(test) -test_site_merge_overrides :: proc(t: ^testing.T) { - config := Flags { - base_url = "https://original.com", - content_dir = "./content", - } - flags := Flags { - base_url = "https://override.com", - } - - merge_flags(&config, flags) - - testing.expect_value(t, config.base_url, "https://override.com") - testing.expect_value(t, config.content_dir, "./content") -} - -@(test) -test_site_merge_empty_flags_keep_config :: proc(t: ^testing.T) { - config := Flags { - base_url = "https://keep.com", - content_dir = "./keep", - } - flags := Flags{} - - merge_flags(&config, flags) - - testing.expect_value(t, config.base_url, "https://keep.com") - testing.expect_value(t, config.content_dir, "./keep") -} - -@(test) -test_site_merge_drafts_true :: proc(t: ^testing.T) { - config := Flags { - drafts = false, - } - flags := Flags { - drafts = true, - } - - merge_flags(&config, flags) - testing.expect(t, config.drafts) -} - -@(test) -test_site_merge_drafts_false_preserves :: proc(t: ^testing.T) { - config := Flags { - drafts = false, - } - flags := Flags { - drafts = false, - } - - merge_flags(&config, flags) - testing.expect(t, !config.drafts) -} - -@(test) -test_site_merge_config_path :: proc(t: ^testing.T) { - config := Flags{} - flags := Flags { - config_path = "./custom/thor.json", - } - - merge_flags(&config, flags) - testing.expect_value(t, config.config_path, "./custom/thor.json") -} - @(test) test_init_site_defaults_no_config :: proc(t: ^testing.T) { site: Site @@ -169,9 +106,13 @@ test_init_site_defaults_no_config :: proc(t: ^testing.T) { defer destroy_site(&site) testing.expect_value(t, site.content_dir, "./content") + testing.expect_value(t, site.assets_dir, "./assets") testing.expect_value(t, site.output_dir, "./public") testing.expect_value(t, site.layouts_dir, "./layouts") testing.expect_value(t, site.base_url, "http://localhost:8080") + testing.expect(t, .Emoji in site.markdown_extensions) + testing.expect(t, .Sidenotes in site.markdown_extensions) + testing.expect(t, .Alerts in site.markdown_extensions) } @(test) @@ -182,6 +123,7 @@ test_init_site_config_dir_relative :: proc(t: ^testing.T) { defer destroy_site(&site) testing.expect_value(t, site.content_dir, "./sub/content") + testing.expect_value(t, site.assets_dir, "./sub/assets") testing.expect_value(t, site.output_dir, "./sub/public") testing.expect_value(t, site.layouts_dir, "./sub/layouts") } @@ -217,3 +159,16 @@ test_init_site_full_pipeline :: proc(t: ^testing.T) { testing.expect_value(t, site.base_url, "https://config.com") } +@(test) +test_init_site_md_enable_disable :: proc(t: ^testing.T) { + site: Site + args := []string{"thor", "-config:./nonexistent.json", "-ext:highlight,sections", "-no-ext:emoji"} + init_site(&site, args) + defer destroy_site(&site) + + testing.expect(t, .Highlight in site.markdown_extensions) + testing.expect(t, .Sections in site.markdown_extensions) + testing.expect(t, !(.Emoji in site.markdown_extensions)) + testing.expect(t, .Sidenotes in site.markdown_extensions) +} +