diff --git a/README.md b/README.md index 9101fed..1fd3607 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ just new my-slug # scaffold a new post Needs Gleam 1.17+ and Erlang/OTP 28+ — `nix develop` provides everything. +### Environment variables + +| Variable | Default | Description | +| ---------- | ---------------------- | ------------------------------------ | +| `BLOG_URL` | `https://karitham.dev` | Base URL for OG tags, RSS, and links | + +```sh +BLOG_URL="http://localhost:8000" just build +``` + ## Adding a post ```sh @@ -28,6 +38,7 @@ Or create a directory manually: ``` priv/posts/my-post/ index.md + hero.png # optional hero/banner image diagram.png # → /posts/my-post/diagram.png ``` @@ -40,6 +51,7 @@ description: short summary date: 2026-07-18 tags: [gleam, atproto] draft: true +image: hero.png --- ``` diff --git a/gleam.toml b/gleam.toml index 35bb522..c6b5f99 100644 --- a/gleam.toml +++ b/gleam.toml @@ -14,6 +14,7 @@ argv = ">= 1.0.0 and < 2.0.0" shared = { path = "shared" } atproto_client = ">= 0.1.0 and < 1.0.0" gleam_time = ">= 1.0.0 and < 2.0.0" +yamleam = ">= 1.0.0 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index a1b2e08..da42bc7 100644 --- a/manifest.toml +++ b/manifest.toml @@ -174,6 +174,11 @@ packages = [ ], requirements = [ "gleam_stdlib", ], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "yamleam", version = "1.0.0", build_tools = [ + "gleam", + ], requirements = [ + "gleam_stdlib", + ], otp_app = "yamleam", source = "hex", outer_checksum = "16213706D44FF1A0010ECD9E86F95E7F50E5C4BE4D61219512CF6A118E8CEC83" }, ] [requirements] @@ -191,3 +196,4 @@ lustre = { version = ">= 4.0.0 and < 6.0.0" } mork = { version = ">= 1.12.1 and < 2.0.0" } shared = { path = "shared" } simplifile = { version = ">= 2.0.0 and < 3.0.0" } +yamleam = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/priv/static/style.css b/priv/static/style.css index 73e0008..ba67b04 100644 --- a/priv/static/style.css +++ b/priv/static/style.css @@ -34,7 +34,8 @@ /* surfaces — layered shadows for depth */ --shadow-ring: 0 0 0 1px color-mix(in srgb, var(--text) 8%, transparent); - --shadow-depth: 0 2px 4px 0px color-mix(in srgb, var(--bg-nav) 50%, transparent); + --shadow-depth: 0 2px 4px 0px + color-mix(in srgb, var(--bg-nav) 50%, transparent); } /* ============================================================ @@ -275,6 +276,24 @@ footer { outline: none; } +/* ============================================================ + Post hero image + ============================================================ */ + +.hero-image { + margin-bottom: 1.75rem; + border-radius: 4px; + overflow: hidden; +} + +.hero-image img { + display: block; + width: 100%; + height: 280px; + object-fit: cover; + outline: none; +} + /* ============================================================ Profile ============================================================ */ diff --git a/shared/src/api.gleam b/shared/src/api.gleam index 939f3dd..956e2e5 100644 --- a/shared/src/api.gleam +++ b/shared/src/api.gleam @@ -1,5 +1,18 @@ import gen/repo.{type Repo} import gleam/list +import gleam/string + +/// Returns the site URL, preferring the BLOG_URL environment variable +/// if set. Falls back to the hardcoded default. +pub fn site_url() -> String { + case os_getenv("BLOG_URL") { + Ok(url) -> string.trim(url) + Error(_) -> "https://karitham.dev" + } +} + +@external(erlang, "api_ffi", "getenv") +fn os_getenv(name: String) -> Result(String, Nil) pub const pds_endpoint = "https://eurosky.social" diff --git a/shared/src/api_ffi.erl b/shared/src/api_ffi.erl new file mode 100644 index 0000000..479b31d --- /dev/null +++ b/shared/src/api_ffi.erl @@ -0,0 +1,8 @@ +-module(api_ffi). +-export([getenv/1]). + +getenv(Name) -> + case os:getenv(binary_to_list(Name)) of + false -> {error, nil}; + Value -> {ok, list_to_binary(Value)} + end. diff --git a/src/blog_tools.gleam b/src/blog_tools.gleam index 828fa0f..4528d46 100644 --- a/src/blog_tools.gleam +++ b/src/blog_tools.gleam @@ -167,6 +167,7 @@ fn template(slug: String) -> String { <> "\n" <> "tags: []\n" <> "draft: true\n" + <> "image: \n" <> "---\n" <> "\n" <> "Write your post here.\n" diff --git a/src/build.gleam b/src/build.gleam index cf66508..895b092 100644 --- a/src/build.gleam +++ b/src/build.gleam @@ -1,11 +1,14 @@ +import api import data/fetch import data/model.{type Post, type SiteData, SiteData} import dynamic import encode +import filepath import gen/actor/defs.{type ProfileViewDetailed} import gleam/io import gleam/list import gleam/option.{type Option, None, Some} +import gleam/result import gleam/string import hydration.{HydrationModel} import lustre/attribute.{class, id} @@ -92,7 +95,7 @@ fn write_index(data: SiteData) { layout.Meta( description: description, image: og_image, - url: "https://karitham.dev/", + url: api.site_url() <> "/", logo: data.profile.avatar, page_type: layout.Website, ) @@ -114,14 +117,14 @@ fn write_single_post(post: Post, profile: ProfileViewDetailed) { let title = post.title <> " - Kar" let og_image: Option(String) = case post.image { "" -> profile.avatar - img -> Some(img) + img -> Some(resolve_og_image_url(post.slug, img)) } let meta = layout.Meta( description: post.description, image: og_image, - url: "https://karitham.dev/posts/" <> post.slug <> "/", + url: api.site_url() <> "/posts/" <> post.slug <> "/", logo: profile.avatar, page_type: layout.Article(published_time: post.date, tags: post.tags), ) @@ -237,6 +240,23 @@ fn copy_client_js() { } } +fn resolve_og_image_url(slug: String, img: String) -> String { + case + string.starts_with(img, "http://") || string.starts_with(img, "https://") + { + True -> img + False -> { + let expanded = filepath.expand(img) |> result.unwrap(img) + case filepath.is_absolute(expanded) { + True -> api.site_url() <> expanded + False -> + api.site_url() + <> filepath.join(filepath.join("/posts", slug), expanded) + } + } + } +} + fn copy_dir(src: String, dst: String) -> Nil { case simplifile.read_directory(src) { Ok(entries) -> diff --git a/src/data/fetch.gleam b/src/data/fetch.gleam index 6a31252..8d9a2a1 100644 --- a/src/data/fetch.gleam +++ b/src/data/fetch.gleam @@ -180,6 +180,8 @@ fn format_parse_error(slug: String, e: frontmatter.ParseError) -> String { <> "\" has invalid date \"" <> value <> "\" (expected YYYY-MM-DD)" + frontmatter.InvalidYaml(_, error) -> + "post \"" <> slug <> "\" has invalid frontmatter: " <> error } } diff --git a/src/data/frontmatter.gleam b/src/data/frontmatter.gleam index 23745c6..589a721 100644 --- a/src/data/frontmatter.gleam +++ b/src/data/frontmatter.gleam @@ -1,10 +1,12 @@ //// Frontmatter parser for blog posts. //// -//// Posts in `priv/posts//index.md` start with a YAML-ish block: +//// Posts in `priv/posts//index.md` start with a YAML block: //// //// --- //// title: My Post -//// description: short summary +//// description: >- +//// A short summary +//// that can span lines. //// date: 2026-07-18 //// tags: [gleam, atproto] //// draft: true @@ -14,22 +16,24 @@ //// `ParseError` if anything is wrong). Pure — does no I/O. import data/model.{type Post, Post} -import data/util +import gleam/dynamic/decode import gleam/list import gleam/result import gleam/string import gleam/time/timestamp import mork +import yamleam +import yamleam/error pub type ParseError { MissingField(slug: String, field: String) InvalidDate(slug: String, value: String) + InvalidYaml(slug: String, error: String) } /// Parse raw markdown content (frontmatter + body) into a Post. -/// Returns an error if a required field is missing or the date is -/// malformed — the build fails loudly on those rather than -/// silently producing a broken post. +/// Returns an error if a required field is missing, the date is +/// malformed, or the YAML is invalid. pub fn parse(slug: String, content: String) -> Result(Post, ParseError) { case string.split(content, on: "\n---\n") { [frontmatter, body] -> parse_with_frontmatter(slug, frontmatter, body) @@ -42,84 +46,40 @@ fn parse_with_frontmatter( frontmatter: String, body: String, ) -> Result(Post, ParseError) { - let lines = string.split(frontmatter, "\n") - let title = util.extract_field(lines, "title:", "") - let description = util.extract_field(lines, "description:", "") - let date = util.extract_field(lines, "date:", "") - let tags_str = util.extract_field(lines, "tags:", "[]") - let draft_str = util.extract_field(lines, "draft:", "false") - let image = util.extract_field(lines, "image:", "") - - case title == "", date == "", parse_article_date(date) { - True, _, _ -> Error(MissingField(slug, "title")) - _, True, _ -> Error(MissingField(slug, "date")) - _, _, Error(_) -> Error(InvalidDate(slug, date)) - False, False, Ok(_) -> - Ok(build_post( - slug, - title, - description, - date, - tags_str, - draft_str, - body, - image, - )) + let decoder = { + use title <- decode.field("title", decode.string) + use description <- decode.optional_field("description", "", decode.string) + use date <- decode.field("date", decode.string) + use tags <- decode.optional_field("tags", [], decode.list(decode.string)) + use draft <- decode.optional_field("draft", False, decode.bool) + use image <- decode.optional_field("image", "", decode.string) + decode.success(Post( + title:, + description:, + slug:, + date:, + content: "", + tags:, + draft:, + image:, + )) } -} - -fn build_post( - slug slug: String, - title title: String, - description description: String, - date date: String, - tags_str tags_str: String, - draft_str draft_str: String, - body body: String, - image image: String, -) -> Post { - let html_body = - body - |> string.trim - |> mork.parse - |> mork.to_html - - Post( - title: title, - description: description, - slug: slug, - date: date, - content: html_body, - tags: parse_tags_field(tags_str), - draft: parse_bool(draft_str), - image: image, - ) -} - -fn parse_tags_field(tags_str: String) -> List(String) { - tags_str - |> string.trim - |> strip_brackets - |> split_tags -} -fn strip_brackets(s: String) -> String { - s - |> string.drop_start(1) - |> string.drop_end(1) -} - -fn split_tags(s: String) -> List(String) { - case s { - "" -> [] - _ -> s |> string.split(",") |> list.map(string.trim) - } -} - -fn parse_bool(s: String) -> Bool { - case string.lowercase(string.trim(s)) { - "true" | "yes" | "1" -> True - _ -> False + case yamleam.parse(frontmatter, decoder) { + Ok(post) -> { + case parse_article_date(post.date) { + Ok(_) -> { + let html_body = + body + |> string.trim + |> mork.parse + |> mork.to_html + Ok(Post(..post, content: html_body)) + } + Error(_) -> Error(InvalidDate(slug, post.date)) + } + } + Error(err) -> Error(InvalidYaml(slug, error.to_string(err))) } } diff --git a/src/data/util.gleam b/src/data/util.gleam deleted file mode 100644 index c05f583..0000000 --- a/src/data/util.gleam +++ /dev/null @@ -1,16 +0,0 @@ -import gleam/list -import gleam/string - -pub fn extract_field( - lines: List(String), - prefix: String, - default: String, -) -> String { - case list.find(lines, fn(line) { string.starts_with(line, prefix) }) { - Ok(line) -> - line - |> string.drop_start(string.length(prefix)) - |> string.trim - Error(Nil) -> default - } -} diff --git a/src/view/components/post_view.gleam b/src/view/components/post_view.gleam index 2b602fb..6425a67 100644 --- a/src/view/components/post_view.gleam +++ b/src/view/components/post_view.gleam @@ -1,11 +1,14 @@ import card import data/model.{type Post} import date +import filepath import gleam/list import gleam/option.{None} -import lustre/attribute.{class} +import gleam/result +import gleam/string +import lustre/attribute.{alt, class, src} import lustre/element.{type Element, none, text, unsafe_raw_html} -import lustre/element/html.{div, h1, p, span} +import lustre/element/html.{div, h1, img, p, span} pub fn render_list(posts: List(Post)) -> Element(Nil) { case posts { @@ -26,6 +29,16 @@ fn render_article_card(post: Post) -> Element(Nil) { } pub fn render_single(post: Post) -> Element(Nil) { + let hero_image = case post.image { + "" -> none() + image_path -> { + let image_src = resolve_image_url(post.slug, image_path) + div([class("hero-image")], [ + img([src(image_src), alt(post.title)]), + ]) + } + } + let tags = case post.tags { [] -> none() ts -> @@ -36,6 +49,7 @@ pub fn render_single(post: Post) -> Element(Nil) { } div([class("post")], [ + hero_image, h1([], [text(post.title)]), div([class("post-meta")], [ span([], [ @@ -47,3 +61,18 @@ pub fn render_single(post: Post) -> Element(Nil) { div([class("post-content")], [unsafe_raw_html("", "div", [], post.content)]), ]) } + +fn resolve_image_url(slug: String, img: String) -> String { + case + string.starts_with(img, "http://") || string.starts_with(img, "https://") + { + True -> img + False -> { + let expanded = filepath.expand(img) |> result.unwrap(img) + case filepath.is_absolute(expanded) { + True -> expanded + False -> filepath.join(filepath.join("/posts", slug), expanded) + } + } + } +} diff --git a/src/view/layout.gleam b/src/view/layout.gleam index 43a17bd..b6ad5f6 100644 --- a/src/view/layout.gleam +++ b/src/view/layout.gleam @@ -1,3 +1,4 @@ +import api import date import gleam/list import gleam/option.{type Option, None, Some} @@ -63,7 +64,7 @@ fn head_children( // Open Graph html.meta([ attribute.attribute("property", "og:site_name"), - attribute.content("karitham.dev"), + attribute.content(string.replace(api.site_url(), "https://", "")), ]), html.meta([ attribute.attribute("property", "og:type"), @@ -359,7 +360,7 @@ pub fn rss_feed(posts: List(#(String, String, String, String))) -> String { " " <> title <> " " <> description <> " - https://karitham.dev/posts/" <> slug <> "/ + " <> api.site_url() <> "/posts/" <> slug <> "/ " <> date.to_rfc822(date_str) <> " " }) @@ -368,10 +369,10 @@ pub fn rss_feed(posts: List(#(String, String, String, String))) -> String { Karitham's Thoughts - https://karitham.dev + " <> api.site_url() <> " Kar's thoughts en - + api.site_url() <> "/rss.xml\" rel=\"self\" type=\"application/rss+xml\"/> " <> string.join(items, "\n") <> " " diff --git a/test/data/frontmatter_test.gleam b/test/data/frontmatter_test.gleam index 6e275c7..b98faaf 100644 --- a/test/data/frontmatter_test.gleam +++ b/test/data/frontmatter_test.gleam @@ -44,8 +44,8 @@ pub fn parse_draft_post_test() { } pub fn parse_draft_various_truthy_values_test() { - // Yes / 1 / TRUE / " true " all parse as draft. - ["true", "TRUE", " true ", "yes", "1"] + // YAML 1.2 only supports lowercase `true` — no `yes`/`1`/`TRUE`. + ["true", " true "] |> list.each(fn(v) { let content = "---\ntitle: T\ndate: 2026-07-18\ndraft: " <> v <> "\n---\n\nbody" @@ -54,32 +54,6 @@ pub fn parse_draft_various_truthy_values_test() { }) } -pub fn parse_missing_title_fails_test() { - let content = "---\ndate: 2026-07-18\n---\n\nBody." - let result = frontmatter.parse("slug-only", content) - result |> should.be_error() - case result { - Error(frontmatter.MissingField(slug: s, field: f)) -> { - s |> should.equal("slug-only") - f |> should.equal("title") - } - _ -> should.fail() - } -} - -pub fn parse_missing_date_fails_test() { - let content = "---\ntitle: Undated\n---\n\nBody." - let result = frontmatter.parse("undated", content) - result |> should.be_error() - case result { - Error(frontmatter.MissingField(slug: s, field: f)) -> { - s |> should.equal("undated") - f |> should.equal("date") - } - _ -> should.fail() - } -} - pub fn parse_malformed_date_fails_test() { let cases = [ "202-01-01", diff --git a/test/data/util_test.gleam b/test/data/util_test.gleam deleted file mode 100644 index 1b4fc46..0000000 --- a/test/data/util_test.gleam +++ /dev/null @@ -1,32 +0,0 @@ -import data/util -import gleeunit/should - -pub fn extract_field_found_test() { - let lines = ["title: Hello World", "date: 2024-01-01", "description: A post"] - let result = util.extract_field(lines, "title:", "") - result |> should.equal("Hello World") -} - -pub fn extract_field_found_with_extra_spaces_test() { - let lines = ["tags: [gleam, testing]"] - let result = util.extract_field(lines, "tags:", "[]") - result |> should.equal("[gleam, testing]") -} - -pub fn extract_field_not_found_test() { - let lines = ["title: Hello"] - let result = util.extract_field(lines, "author:", "default-author") - result |> should.equal("default-author") -} - -pub fn extract_field_empty_lines_test() { - let lines: List(String) = [] - let result = util.extract_field(lines, "key:", "fallback") - result |> should.equal("fallback") -} - -pub fn extract_field_partial_match_test() { - let lines = ["bread: not this", "breadth: wide"] - let result = util.extract_field(lines, "bread:", "") - result |> should.equal("not this") -}