diff --git a/TODOS.md b/TODOS.md index 99966eb..e15f6ec 100644 --- a/TODOS.md +++ b/TODOS.md @@ -6,10 +6,9 @@ - [ ] Instead of loading the site fresh each time in watch mode, create a `reload_site` proc, that just updates changed resources. - [ ] mustache data keys for opengraph, etc. -- [ ] Infer page types / collections rather than having dedicated procs for - everything - [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md - [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin +- [ ] follow symlinks in `scan_content`? - [ ] ensure sidenote numbers render in display order and not in declaration order. - [ ] OpenGraph meta tags — verify all fields match production site - [ ] Add Opt-in deflist support. @@ -34,13 +33,16 @@ - [ ] filesystem poll loop - [ ] event based - [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode +- [ ] Rename `{{#is_post}}` to `{{#is_article}}` in templates and data model — currently hardcoded to posts section, should use `is_article` (any page with a section) instead. - [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely - [ ] commands - [ ] `build` alias of default - [ ] `init` set up new project - [ ] Use spall to find ways to reduce run time. - [ ] warn/error when unknown key used in mustache. +- [ ] Import/export packages. Hugo, jekyll, WordPress, etc. - [ ] Review every file in thor + - [ ] Review assets.odin - [ ] Review alerts.odin - [ ] Review content.odin - [ ] Review emoji.odin diff --git a/assets.odin b/assets.odin new file mode 100644 index 0000000..66465dc --- /dev/null +++ b/assets.odin @@ -0,0 +1,61 @@ +package main + +import "core:fmt" +import "core:log" +import "core:os" +import "core:strings" + +// copy_assets_dir recursively copies files from assets_dir to output_dir. +// .css files are minified when .Minify is enabled; all other files are copied verbatim. +// Silently skips if assets_dir doesn't exist. +copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) { + if !os.exists(assets_dir) { + return + } + copy_assets_recursive(assets_dir, "", output_dir, features) +} + +copy_assets_recursive :: proc( + current: string, + rel_prefix: string, + output_dir: string, + features: bit_set[Feature], +) { + entries, err := os.read_all_directory_by_path(current, context.allocator) + if err != nil { + log.warnf("thor: cannot read %s: %v", current, err) + return + } + defer os.file_info_slice_delete(entries, context.allocator) + + for entry in entries { + rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name) + switch entry.type { + case .Regular: + dest := fmt.tprintf("%s/%s", output_dir, rel) + if idx := strings.last_index(dest, "/"); idx >= 0 { + if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist { + log.warnf("thor: cannot create %s: %v", dest[:idx], err) + continue + } + } + if .Minify in features && strings.has_suffix(entry.name, ".css") { + data, read_err := os.read_entire_file_from_path(entry.fullpath, context.allocator) + if read_err != nil { + log.warnf("thor: cannot read %s: %v", entry.fullpath, read_err) + continue + } + minified := minify_css(string(data)) + write_file(dest, minified) + } else { + if err := os.copy_file(dest, entry.fullpath); err != nil { + log.warnf("thor: cannot copy %s: %v", entry.fullpath, err) + } + } + case .Directory: + copy_assets_recursive(entry.fullpath, rel, output_dir, features) + case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device: + } + } +} + diff --git a/content.odin b/content.odin index 0fa1ef4..e4f748f 100644 --- a/content.odin +++ b/content.odin @@ -7,100 +7,36 @@ import "core:log" import "core:os" import "core:strings" -Page_Type :: enum { - Page, - Post, - Home, -} - +// Fields with underscores should never be set by the user. Page :: struct { - type: Page_Type, + section: string, slug: string, + layout: string, permalink: string, title: string, description: string, date: string, - draft: bool, - is_starred: bool, menu: string, body_html: string, - bundle_dir: string, + draft: bool, + is_starred: bool, + _is_index: bool `private`, } // site_load_content reads the content directory and populates site.pages. // Drafts are excluded unless .Drafts is enabled. site_load_content :: proc(site: ^Site) { site.pages = make([dynamic]Page, 0, 8, site_allocator(site)) - - load_homepage(site) - load_pages(site) - load_posts(site) + scan_content(site, site.content_dir, "") } -load_homepage :: proc(site: ^Site) { - content_path := site.content_dir - ext := site.markdown_extensions - - html_path := fmt.tprintf("%s/index.html", content_path) - if os.exists(html_path) { - page, ok := load_page(html_path, .Home, "", ext) - if ok && (!page.draft || .Drafts in site.features) { - page.permalink = "/" - append(&site.pages, page) - } - return - } - - md_path := fmt.tprintf("%s/index.md", content_path) - if os.exists(md_path) { - page, ok := load_page(md_path, .Home, "", ext) - if ok && (!page.draft || .Drafts in site.features) { - page.permalink = "/" - append(&site.pages, page) - } - } -} - -load_pages :: proc(site: ^Site) { - content_path := site.content_dir - ext := site.markdown_extensions - - entries, err := os.read_all_directory_by_path(content_path, context.allocator) +// scan_content walks the content directory. At the root level (section=""), +// directories are treated as sections. Within a section, directories are +// treated as leaf bundles (directory with an index file). +scan_content :: proc(site: ^Site, dir: string, section: string) { + entries, err := os.read_all_directory_by_path(dir, context.allocator) if err != nil { - log.warnf("thor: cannot read %s: %v", content_path, err) - return - } - defer os.file_info_slice_delete(entries, context.allocator) - - for entry in entries { - if !is_content_file(entry.name) { - continue - } - if entry.name == "index.md" || entry.name == "index.html" { - continue - } - if entry.type != .Regular { - continue - } - - slug := strip_extension(entry.name) - page, ok := load_page(entry.fullpath, .Page, slug, ext) - if ok && (!page.draft || .Drafts in site.features) { - append(&site.pages, page) - } - } -} - -load_posts :: proc(site: ^Site) { - posts_path := fmt.tprintf("%s/posts", site.content_dir) - if !os.exists(posts_path) { - return - } - - ext := site.markdown_extensions - 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) + log.warnf("thor: cannot read %s: %v", dir, err) return } defer os.file_info_slice_delete(entries, context.allocator) @@ -111,33 +47,60 @@ load_posts :: proc(site: ^Site) { if !is_content_file(entry.name) { continue } - slug := strip_extension(entry.name) - page, ok := load_page(entry.fullpath, .Post, slug, ext) + + filename := strip_extension(entry.name) + is_idx := filename == "index" + slug := is_idx ? "" : filename + + page, ok := load_page(entry.fullpath, section, slug, is_idx, site.markdown_extensions) if ok && (!page.draft || .Drafts in site.features) { append(&site.pages, page) } + case .Directory: - index_path := fmt.tprintf("%s/index.html", entry.fullpath) - if !os.exists(index_path) { - index_path = fmt.tprintf("%s/index.md", entry.fullpath) - } - if !os.exists(index_path) { - continue - } - page, ok := load_page(index_path, .Post, entry.name, ext) - if ok && (!page.draft || .Drafts in site.features) { - page.bundle_dir = entry.fullpath - append(&site.pages, page) + if section == "" { + scan_content(site, entry.fullpath, entry.name) + } else { + index_path := fmt.tprintf("%s/index.html", entry.fullpath) + if !os.exists(index_path) { + index_path = fmt.tprintf("%s/index.md", entry.fullpath) + } + if os.exists(index_path) { + page, ok := load_page( + index_path, + section, + entry.name, + false, + site.markdown_extensions, + ) + if ok && (!page.draft || .Drafts in site.features) { + append(&site.pages, page) + } + } } case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device: } } } +infer_layout :: proc(section: string, is_index: bool) -> string { + if section == "" && is_index { + return "home" + } + if is_index { + return fmt.tprintf("%s_index", section) + } + if section != "" { + return section + } + return "page" +} + load_page :: proc( file_path: string, - page_type: Page_Type, + section: string, slug: string, + is_index: bool, ext: bit_set[Markdown_Extension], ) -> ( page: Page, @@ -155,14 +118,16 @@ load_page :: proc( body = strings.trim_left(content, " \t\r\n") } - page.type = page_type + page.section = section page.slug = slug + page._is_index = is_index page.title = fm.title page.description = fm.description page.date = fm.date page.draft = fm.draft page.is_starred = fm.isStarred page.menu = fm.menu + page.layout = fm.layout if fm.layout != "" else infer_layout(section, is_index) if strings.has_suffix(file_path, ".html") { page.body_html = strings.clone(body) @@ -192,13 +157,14 @@ load_page :: proc( page.body_html = html } - switch page_type { - case .Home: + if section == "" && is_index { page.permalink = "/" - case .Post: - page.permalink = fmt.aprintf("/posts/%s/", slug) - case .Page: + } else if is_index { + page.permalink = fmt.aprintf("/%s/", section) + } else if section == "" { page.permalink = fmt.aprintf("/%s/", slug) + } else { + page.permalink = fmt.aprintf("/%s/%s/", section, slug) } ok = true @@ -217,57 +183,3 @@ strip_extension :: proc(name: string) -> string { return name[:dot] } -// copy_assets_dir recursively copies files from assets_dir to output_dir. -// .css files are minified when .Minify is enabled; all other files are copied verbatim. -// Silently skips if assets_dir doesn't exist. -copy_assets_dir :: proc(assets_dir: string, output_dir: string, features: bit_set[Feature]) { - if !os.exists(assets_dir) { - return - } - copy_assets_recursive(assets_dir, "", output_dir, features) -} - -copy_assets_recursive :: proc( - current: string, - rel_prefix: string, - output_dir: string, - features: bit_set[Feature], -) { - entries, err := os.read_all_directory_by_path(current, context.allocator) - if err != nil { - log.warnf("thor: cannot read %s: %v", current, err) - return - } - defer os.file_info_slice_delete(entries, context.allocator) - - for entry in entries { - rel := rel_prefix == "" ? entry.name : fmt.tprintf("%s/%s", rel_prefix, entry.name) - switch entry.type { - case .Regular: - dest := fmt.tprintf("%s/%s", output_dir, rel) - if idx := strings.last_index(dest, "/"); idx >= 0 { - if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist { - log.warnf("thor: cannot create %s: %v", dest[:idx], err) - continue - } - } - if .Minify in features && strings.has_suffix(entry.name, ".css") { - data, read_err := os.read_entire_file_from_path(entry.fullpath, context.allocator) - if read_err != nil { - log.warnf("thor: cannot read %s: %v", entry.fullpath, read_err) - continue - } - minified := minify_css(string(data)) - write_file(dest, minified) - } else { - if err := os.copy_file(dest, entry.fullpath); err != nil { - log.warnf("thor: cannot copy %s: %v", entry.fullpath, err) - } - } - case .Directory: - copy_assets_recursive(entry.fullpath, rel, output_dir, features) - case .Undetermined, .Symlink, .Named_Pipe, .Socket, .Block_Device, .Character_Device: - } - } -} - diff --git a/feed.odin b/feed.odin index 64fb01a..d17e483 100644 --- a/feed.odin +++ b/feed.odin @@ -7,8 +7,10 @@ import "core:time" generate_rss :: proc(site: ^Site) -> string { sb := strings.builder_make() - strings.write_string(&sb, fmt.aprintf( - ` + strings.write_string( + &sb, + fmt.aprintf( + ` %s @@ -16,14 +18,15 @@ generate_rss :: proc(site: ^Site) -> string { %s en-us `, - xml_escape(site.title), - site.base_url, - xml_escape(site.description), - site.base_url, - )) + xml_escape(site.title), + site.base_url, + xml_escape(site.description), + site.base_url, + ), + ) for page in site.pages { - if page.type == .Home { + if page.section == "" && page._is_index { continue } @@ -32,8 +35,10 @@ generate_rss :: proc(site: ^Site) -> string { pub_date = format_rfc822(page.date) } - strings.write_string(&sb, fmt.aprintf( - ` + strings.write_string( + &sb, + fmt.aprintf( + ` %s %s%s %s @@ -41,14 +46,15 @@ generate_rss :: proc(site: ^Site) -> string { %s `, - xml_escape(page.title), - site.base_url, - page.permalink, - pub_date, - site.base_url, - page.permalink, - xml_escape(page.body_html), - )) + xml_escape(page.title), + site.base_url, + page.permalink, + pub_date, + site.base_url, + page.permalink, + xml_escape(page.body_html), + ), + ) } strings.write_string(&sb, "\n") @@ -58,34 +64,59 @@ generate_rss :: proc(site: ^Site) -> string { generate_sitemap :: proc(site: ^Site) -> string { sb := strings.builder_make() - strings.write_string(&sb, ` + strings.write_string( + &sb, + ` -`) +`, + ) for page in site.pages { lastmod := "" if page.date != "" { lastmod = fmt.aprintf("%s", page.date) } - strings.write_string(&sb, fmt.aprintf( - "%s%s%s\n", site.base_url, page.permalink, lastmod, - )) + strings.write_string( + &sb, + fmt.aprintf("%s%s%s\n", site.base_url, page.permalink, lastmod), + ) } - // Posts list page - posts_lastmod := "" + // Section index pages (for sections without an index in content) + sections := make(map[string]bool) + defer delete(sections) for page in site.pages { - if page.type == .Post && page.date > posts_lastmod { - posts_lastmod = page.date + if page.section != "" && !page._is_index { + sections[page.section] = true } } - posts_lm := "" - if posts_lastmod != "" { - posts_lm = fmt.aprintf("%s", posts_lastmod) + for section in sections { + has_index := false + for page in site.pages { + if page.section == section && page._is_index { + has_index = true + break + } + } + if has_index { + continue + } + + section_lastmod := "" + for page in site.pages { + if page.section == section && !page._is_index && page.date > section_lastmod { + section_lastmod = page.date + } + } + lm := "" + if section_lastmod != "" { + lm = fmt.aprintf("%s", section_lastmod) + } + strings.write_string( + &sb, + fmt.aprintf("%s/%s/%s\n", site.base_url, section, lm), + ) } - strings.write_string(&sb, fmt.aprintf( - "%s/posts/%s\n", site.base_url, posts_lm, - )) strings.write_string(&sb, "") return strings.to_string(sb) diff --git a/frontmatter.odin b/frontmatter.odin index cb7e26f..d299480 100644 --- a/frontmatter.odin +++ b/frontmatter.odin @@ -12,6 +12,7 @@ Frontmatter :: struct { draft: bool, isStarred: bool, menu: string, + layout: string, } // parse_frontmatter splits raw file content into a Frontmatter struct and the @@ -52,6 +53,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok fm.draft = json_get_bool(obj, "draft") fm.isStarred = json_get_bool(obj, "isStarred") fm.menu = json_get_string(obj, "menu") + fm.layout = json_get_string(obj, "layout") ok = true return diff --git a/render.odin b/render.odin index b1eaef6..9d54200 100644 --- a/render.odin +++ b/render.odin @@ -54,12 +54,13 @@ Home_Data :: struct { list_pages: [dynamic]Page_Context, } -Posts_Data :: struct { +Section_Data :: struct { using base: Base_Data, + page_title: string, year_sections: [dynamic]Year_Section, } - build_page_context :: proc(page: Page) -> Page_Context { +build_page_context :: proc(page: Page) -> Page_Context { return Page_Context { permalink = page.permalink, title = page.title, @@ -114,6 +115,41 @@ load_template :: proc(layouts_dir: string, name: string) -> mustache.Template { return tpl } +get_template :: proc( + layouts_dir: string, + layout: string, + cache: ^map[string]mustache.Template, +) -> mustache.Template { + if cached, ok := cache[layout]; ok { + return cached + } + + filename := fmt.tprintf("%s.html", layout) + if os.exists(fmt.tprintf("%s/%s", layouts_dir, filename)) { + tpl := load_template(layouts_dir, filename) + cache[layout] = tpl + return tpl + } + + if layout != "page" { + log.warnf("thor: template %s not found, falling back to page.html", filename) + return get_template(layouts_dir, "page", cache) + } + + log.errorf("thor: default template page.html not found in %s", layouts_dir) + return load_template(layouts_dir, "page.html") +} + +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, @@ -131,10 +167,12 @@ render_site :: proc(site: ^Site) { pages := site.pages[:] sort_pages_by_date(pages) - // Load shared resources once + // Load shared resources partials := load_partials(site.layouts_dir) partials["base"] = load_template(site.layouts_dir, "base.html") - post_tpl := load_template(site.layouts_dir, "post.html") + + template_cache: map[string]mustache.Template + defer delete(template_cache) now, ok := time.time_to_datetime(time.now()) assert(ok) @@ -153,28 +191,67 @@ render_site :: proc(site: ^Site) { home: Page has_home := false for page in pages { - if page.type == .Home { + if page.section == "" && page._is_index { home = page has_home = true break } } - // Render individual content pages (skip home) + // Collect sections + sections := make(map[string]bool) + defer delete(sections) for page in pages { - if page.type == .Home { + if page.section != "" { + sections[page.section] = true + } + } + + // Render individual content pages (skip all index pages) + for page in pages { + if page._is_index { continue } - html := render_page_html(page, site, post_tpl, partials, base) + 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 := load_template(site.layouts_dir, "home.html") + 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) @@ -182,14 +259,6 @@ render_site :: proc(site: ^Site) { write_file(fmt.tprintf("%s/index.html", site.output_dir), home_html) } - // Render posts list page - posts_tpl := load_template(site.layouts_dir, "posts_list.html") - posts_html := render_posts_html(site, posts_tpl, partials, base) - if .Minify in site.features { - posts_html = minify_html(posts_html) - } - write_page(site.output_dir, "/posts/", posts_html) - // Generate RSS feed rss := generate_rss(site) write_file(fmt.tprintf("%s/index.xml", site.output_dir), rss) @@ -205,7 +274,7 @@ render_site :: proc(site: ^Site) { 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) + 1 + total := len(pages) + len(sections) if !has_home { total += 1 } @@ -219,7 +288,7 @@ render_page_html :: proc( partials: map[string]mustache.Template, base: Base_Data, ) -> string { - is_article := page.type == .Post + is_article := page.section != "" data := Page_Data { base = base, } @@ -233,7 +302,7 @@ render_page_html :: proc( data.og_title = strip_html_tags(page.title) data.og_type = og_type(is_article) data.is_article = is_article - data.og_section = "posts" + data.og_section = page.section data.og_published = page.date return render_template(content_tpl, data, partials) } @@ -248,7 +317,7 @@ render_home_html :: proc( list_pages := make([dynamic]Page_Context) defer delete(list_pages) for page in site.pages { - if page.type == .Home { + if page._is_index { continue } append(&list_pages, build_page_context(page)) @@ -267,8 +336,11 @@ render_home_html :: proc( return render_template(content_tpl, data, partials) } -render_posts_html :: proc( +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, @@ -277,7 +349,7 @@ render_posts_html :: proc( defer delete(year_sections) current_year := "" for page in site.pages { - if page.type != .Post { + if page.section != section || page._is_index { continue } year := get_year(page.date) @@ -288,13 +360,21 @@ render_posts_html :: proc( append(&year_sections[len(year_sections) - 1].posts, build_page_context(page)) } - data := Posts_Data { + data := Section_Data { base = base, } - data.title = fmt.tprintf("Posts | %s", site.title) + 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/posts/", site.base_url) - data.og_title = "Posts" + 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)