From 566d6f076877db943358e7ca41c6ebb0f9de5c92 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:27:27 -0400 Subject: [PATCH] feat: Improved opengraph defaults. --- TODOS.md | 13 +++-- content.odin | 4 ++ frontmatter.odin | 27 +++++++++- html.odin | 137 +++++++++++++++++++++++++++++++++++++++++++++++ opengraph.odin | 119 ++++++++++++++++++++++++++++++++-------- render.odin | 44 +++------------ site.odin | 7 +++ 7 files changed, 285 insertions(+), 66 deletions(-) create mode 100644 html.odin diff --git a/TODOS.md b/TODOS.md index 628983c..10b0d2d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -6,6 +6,7 @@ - [ ] Only publish referenced assets. - [ ] Split `load_page` into frontmatter-parse + body-process phases so draft pages can skip the markdown pipeline entirely - [ ] Use spall to find ways to reduce run time. +- [ ] Consider using `#soa` for Page lists. ## Memory Management @@ -28,14 +29,12 @@ - [ ] Clean up the default layouts - [ ] Add `-production` flag - sets `-minify` -- [ ] Open Graph +- [x] Open Graph - [x] mustache data keys for opengraph, etc. - - [ ] OpenGraph meta tags — verify all fields match production site - - [ ] set opengraph tags / description automatically if unset. (Like hugo does) - - [ ] We can't use avatar.jpg as the default site image, that's unique to sbrow.github.io. We need to set that in the frontmatter of content/index.html. or possibly in the config - - [ ] Add `og Open_Graph` to `Config_File` and if `Some`, use it as the base - site og instead of `og_init()`? - - If we go this route, `og_init` might not be the best name. + - [x] OpenGraph meta tags — verify all fields match production site + - [x] set opengraph tags / description automatically if unset. (Like hugo does) + - [x] We can't use avatar.jpg as the default site image. + - [x] Add `og Open_Graph` to `Config_File`. - [ ] Author should be a struct adhering to https://schema.org/author - [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md - [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin diff --git a/content.odin b/content.odin index 7615a08..1c756db 100644 --- a/content.odin +++ b/content.odin @@ -17,8 +17,10 @@ Page :: struct { title: string, description: string, date: string, + lastmod: string, menu: string, body_html: string, + og: Open_Graph, draft: bool, is_starred: bool, _is_index: bool `private`, @@ -132,10 +134,12 @@ load_page :: proc( page.title = fm.title page.description = fm.description page.date = fm.date + page.lastmod = fm.lastmod 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) + page.og = fm.og if strings.has_suffix(file_path, ".html") { page.body_html = strings.clone(body) diff --git a/frontmatter.odin b/frontmatter.odin index 7b25031..731320a 100644 --- a/frontmatter.odin +++ b/frontmatter.odin @@ -8,11 +8,13 @@ Frontmatter :: struct { title: string, description: string, date: string, + lastmod: string, publishDate: string, - draft: bool, - isStarred: bool, menu: string, layout: string, + og: Open_Graph, + draft: bool, + isStarred: bool, } // parse_frontmatter splits raw file content into a Frontmatter struct and the @@ -49,11 +51,13 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok fm.title = json_get_string(obj, "title") fm.description = json_get_string(obj, "description") fm.date = json_get_string(obj, "date") + fm.lastmod = json_get_string(obj, "lastmod") fm.publishDate = json_get_string(obj, "publishDate") 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") + fm.og = json_get_open_graph(obj, "og") ok = true return @@ -77,3 +81,22 @@ json_get_bool :: proc(obj: json.Object, key: string) -> bool { return false } +json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph { + og: Open_Graph + if v, ok := obj[key]; ok { + if inner, ok2 := v.(json.Object); ok2 { + og.title = json_get_string(inner, "title") + og.type = json_get_string(inner, "type") + og.image = json_get_string(inner, "image") + og.url = json_get_string(inner, "url") + og.description = json_get_string(inner, "description") + og.locale = json_get_string(inner, "locale") + og.site_name = json_get_string(inner, "site_name") + og.published_time = json_get_string(inner, "published_time") + og.modified_time = json_get_string(inner, "modified_time") + og.section = json_get_string(inner, "section") + } + } + return og +} + diff --git a/html.odin b/html.odin new file mode 100644 index 0000000..08c2c8c --- /dev/null +++ b/html.odin @@ -0,0 +1,137 @@ +package main + +import "core:strings" + +strip_html_tags :: proc(s: string, allocator := context.allocator) -> string { + sb := strings.builder_make(allocator) + 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) +} + +unescape_html :: proc(s: string) -> string { + sb := strings.builder_make() + defer strings.builder_destroy(&sb) + + start := 0 + for i in 0 ..< len(s) { + if s[i] != '&' { + continue + } + semi := strings.index(s[i:], ";") + if semi < 0 { + break + } + entity := s[i : i + semi + 1] + replacement := "" + switch entity { + case "&": replacement = "&" + case "<": replacement = "<" + case ">": replacement = ">" + case """: replacement = "\"" + case "'", "'": replacement = "'" + case: continue + } + if i > start { + strings.write_string(&sb, s[start:i]) + } + strings.write_string(&sb, replacement) + start = i + semi + 1 + } + if start == 0 { + return s + } + if start < len(s) { + strings.write_string(&sb, s[start:]) + } + return strings.to_string(sb) +} + +// generate_summary produces a plain-text summary of an HTML fragment. +// Blocks (paragraphs, headings, list items) are extracted, their tags +// stripped, entities decoded, and accumulated word-by-word until the +// max_words threshold is crossed — at which point the rest of the +// current block is included before stopping. Mirrors Hugo's default +// summary behavior. +generate_summary :: proc(html: string, max_words: int = 70) -> string { + separated, _ := strings.replace_all(html, "

", "\n\n", context.temp_allocator) + separated, _ = strings.replace_all(separated, "", "\n\n") + separated, _ = strings.replace_all(separated, "", "\n\n") + separated, _ = strings.replace_all(separated, "", "\n\n") + separated,_ = strings.replace_all(separated, "", "\n\n") + separated, _ = strings.replace_all(separated, "", "\n\n") + separated, _ = strings.replace_all(separated, "", "\n\n") + separated, _ = strings.replace_all(separated, "", "\n\n") + separated, _ = strings.replace_all(separated, "", "\n\n") + + stripped := strip_html_tags(separated, context.temp_allocator) + plain := unescape_html(stripped) + + blocks := strings.split(plain, "\n\n", allocator = context.temp_allocator) + defer delete(blocks) + + sb := strings.builder_make(context.temp_allocator) + defer strings.builder_destroy(&sb) + + word_count := 0 + first := true + for raw_block in blocks { + block := strings.trim_space(raw_block) + if len(block) == 0 { + continue + } + + // Collapse internal whitespace to single spaces. + block_sb := strings.builder_make(context.temp_allocator) + has_content := false + in_space := true + for c in block { + if c == ' ' || c == '\t' || c == '\n' || c == '\r' { + in_space = true + } else { + if in_space && has_content { + strings.write_byte(&block_sb, ' ') + } + strings.write_rune(&block_sb, c) + in_space = false + has_content = true + } + } + collapsed := strings.to_string(block_sb) + words := strings.split(collapsed, " ", allocator = context.temp_allocator) + + if !first && word_count > 0 { + strings.write_byte(&sb, ' ') + } + strings.write_string(&sb, collapsed) + word_count += len(words) + first = false + + delete(words) + + if word_count >= max_words { + break + } + } + + return strings.to_string(sb) +} diff --git a/opengraph.odin b/opengraph.odin index dcf0cde..20766cd 100644 --- a/opengraph.odin +++ b/opengraph.odin @@ -1,7 +1,5 @@ package main -import "core:fmt" - Open_Graph :: struct { title: string, type: string, @@ -10,31 +8,110 @@ Open_Graph :: struct { description: string, locale: string, site_name: string, - is_article: bool, + is_article: Maybe(bool), published_time: string, modified_time: string, section: string, } -og_init :: proc(site: Site) -> Open_Graph { - return { - site_name = site.title, - description = site.description, - image = fmt.tprintf("%s/avatar.jpg", site.base_url), - locale = "en_US", +og_for_site :: proc(site: ^Site) -> Open_Graph { + og := site.og + if og.site_name == "" { + og.site_name = site.title + } + if og.description == "" { + og.description = site.description + } + if og.locale == "" { + og.locale = "en_US" } -} - -og_for_page :: proc(site: Site, page: Page, base: Open_Graph) -> Open_Graph { - og := base - is_article := page.section != "" - og.url = page.url - og.title = strip_html_tags(page.title, context.temp_allocator) - og.type = "article" if is_article else "website" - og.is_article = is_article - og.section = page.section - og.published_time = page.date - return og } +og_for_page :: proc(site_og: Open_Graph, page: Page) -> Open_Graph { + og := site_og + + is_article := !page._is_index + if page.url != "" { + og.url = page.url + } + if page.title != "" { + og.title = strip_html_tags(page.title, context.temp_allocator) + } else { + og.title = og.site_name + } + og.type = "article" if is_article else "website" + og.is_article = is_article + if page.section != "" { + og.section = page.section + } + if is_article { + if page.date != "" { + og.published_time = page.date + } + if page.lastmod != "" { + og.modified_time = page.lastmod + } else if page.date != "" { + og.modified_time = page.date + } + } + + // Description priority for articles: + // page.og.description > page.description > body summary > inherited + // For non-articles (home, section index), inherited site.og.description + // is the fallback (matches production behavior — home inherits site + // description, section index is empty). + description_set := false + if page.og.description != "" { + og.description = page.og.description + description_set = true + } + if !description_set && page.description != "" { + og.description = page.description + description_set = true + } + if !description_set && is_article && page.body_html != "" { + og.description = generate_summary(page.body_html) + description_set = true + } + if !description_set { + if page._is_index && page.section == "" { + // Home: keep inherited site.og.description. + } else { + og.description = "" + } + } + + if page.og.title != "" { + og.title = page.og.title + } + if page.og.type != "" { + og.type = page.og.type + } + if page.og.image != "" { + og.image = page.og.image + } + if page.og.url != "" { + og.url = page.og.url + } + if page.og.locale != "" { + og.locale = page.og.locale + } + if page.og.site_name != "" { + og.site_name = page.og.site_name + } + if page.og.published_time != "" { + og.published_time = page.og.published_time + } + if page.og.modified_time != "" { + og.modified_time = page.og.modified_time + } + if page.og.section != "" { + og.section = page.og.section + } + if page.og.is_article != nil { + og.is_article = page.og.is_article + } + + return og +} diff --git a/render.odin b/render.odin index d346a13..e874245 100644 --- a/render.odin +++ b/render.odin @@ -57,32 +57,6 @@ build_page_context :: proc(page: Page) -> Page_Context { } } -strip_html_tags :: proc(s: string, allocator := context.allocator) -> string { - sb := strings.builder_make(allocator) - 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) -} - load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template { data, ok := vfs_get(vfs, virtual_path) if !ok { @@ -176,7 +150,7 @@ render_site :: proc(site: ^Site) { now = now, author = site.author, params = site.params, - og = og_init(site^), + og = site.og, } // Find home page @@ -289,7 +263,7 @@ render_page_html :: proc( data.body = page.body_html data.date_iso = page.date data.date_display = format_date(page.date) - data.og = og_for_page(site^, page, base.og) + data.og = og_for_page(site.og, page) return render_template(content_tpl, data, partials) } @@ -315,10 +289,7 @@ render_home_html :: proc( 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.og.is_article = false + data.og = og_for_page(site.og, home) return render_template(content_tpl, data, partials) } @@ -347,16 +318,17 @@ render_section :: proc( 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 + data.og = og_for_page(site.og, section_index) } else { data.page_title = capitalize(section) data.title = fmt.tprintf("%s | %s", capitalize(section), site.title) data.og.title = capitalize(section) + data.og.description = "" + data.og.url = fmt.tprintf("%s/%s/", site.base_url, section) + data.og.type = "website" + data.og.is_article = false } data.posts = posts - data.og.url = fmt.tprintf("%s/%s/", site.base_url, section) - data.og.type = "website" - data.og.is_article = false return render_template(content_tpl, data, partials) } diff --git a/site.odin b/site.odin index 14b2237..00fb9ba 100644 --- a/site.odin +++ b/site.odin @@ -29,6 +29,7 @@ Site :: struct { params: json.Object, features: bit_set[Feature], markdown_extensions: bit_set[md.Extension], + og: Open_Graph, } Feature :: enum { @@ -51,6 +52,7 @@ Config_File :: struct { markdown_extensions: json.Value, params: json.Value, modules: json.Value, + og: Open_Graph, } // Configuration loaded from command line arguments. Gets folded in to Site @@ -107,6 +109,9 @@ init_site :: proc(site: ^Site, args: []string) { site_apply_cli_flags(site, _flags) site.config_path = path + + // Build the resolved site-level OG now that every other field is set. + site.og = og_for_site(site) } load_config_file :: proc( @@ -159,6 +164,8 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string) } } } + + site.og = config.og } site_apply_path_defaults :: proc(site: ^Site, config_dir: string) {