# Blog Blog authoring and reading helpers with file-based storage and Medium-style rendering. --- ## Table of Contents 1. [Overview](#overview) 2. [Features](#features) 3. [Configuration](#configuration) 4. [Routes](#routes) 5. [Usage](#usage) 6. [API Reference](#api-reference) 7. [Internals / Flow](#internals--flow) 8. [Related Documentation](#related-documentation) --- ## Overview `Blog` is a stateless utility module that provides a complete blog system on top of the Summernote WYSIWYG editor. Posts can be authored as HTML (via the browser editor at `/blog`) or as Markdown files dropped into `asset/blogs/`. Both formats are served with Medium-style typography at `/blog/:filename`. Key design decisions: - **File-based storage** -- No database. Each post is a standalone `.html` or `.md` file in `asset/blogs/`. - **Dual format support** -- HTML files are rendered as-is; Markdown files are converted to HTML at render time. - **HTML preservation** -- Summernote HTML (with all `

`, ``, `` tags) is saved and rendered as-is. No stripping or sanitization. - **Public reading** -- Individual posts at `GET /blog/:filename` bypass authentication. Anyone with the URL can read. - **Authenticated listing** -- The index at `GET /blogs` requires authentication. - **Smart slugs** -- Filenames are derived from the first sentence of content. Duplicates get a timestamp suffix. - **Title extraction** -- Titles are extracted from content (HTML `

` or Markdown `# heading`), not filenames. - **Search integration** -- Blog listing page includes semantic and full-text search via `BlogStore` API. ## Features | Feature | Description | |---------|-------------| | WYSIWYG editing | Full Summernote editor with dark theme and one-click publishing | | Markdown support | `.md` files detected by content (no leading `<`), titles from `# heading` | | HTML preservation | All Summernote HTML tags saved and rendered as-is | | Smart slugs | Filenames derived from first sentence, max 80 chars | | Medium-style reading | Source Serif 4 serif typography, dark/light theme toggle, code highlighting | | Public sharing | `/blog/:filename` bypasses auth -- shareable URLs | | Traversal protection | Path traversal attacks blocked before file access | | Edit & delete | In-place editing for HTML posts, delete with confirmation | | Search | Semantic and full-text search on the listing page via BlogStore | | Code highlighting | highlight.js integration for fenced code blocks | ## Configuration | Setting | Value | Override | |---------|-------|---------| | Storage directory | `asset/blogs/` | `Application.get_env(:mcp, :blogs_dir)` | | Max slug length | 80 characters | Hardcoded | | File formats | `.html`, `.md` | Hardcoded | The `asset/blogs/` directory is created automatically by `save_post/1` via `File.mkdir_p!/1`. ## Routes | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/blog` | Required | Blog editor (create new post) | | `GET` | `/blogs` | Required | Blog listing page with search | | `GET` | `/blog/edit/:filename` | Required | Blog editor (edit existing post) | | `GET` | `/blog/:filename` | **Public** | Read a single blog post | | `POST` | `/blog/save` | Required | Save a new blog post | | `POST` | `/blog` | Required | Update an existing blog post | | `DELETE` | `/blog` | Required | Delete a blog post | ## Usage ### Create a post programmatically ```elixir {:ok, filename} = Blog.save_post("

My First Post

Content here.

") # => {:ok, "my-first-post.html"} ``` ### Read a post ```elixir {:ok, content} = Blog.read_post("my-first-post.html") title = Blog.extract_title(content) # => "My First Post" ``` ### Update a post ```elixir :ok = Blog.update_post("my-first-post.html", "

Updated Title

New content.

") ``` ### Delete a post ```elixir :ok = Blog.delete_post("my-first-post.html") ``` ### List all posts ```elixir Blog.list_posts() # => ["2026-03-13-10-30-topic.md", "my-first-post.html", ...] ``` ### Extract title and body separately ```elixir {title, body} = Blog.extract_title_and_body(content) # For HTML: title from

, body is HTML with

removed # For Markdown: title from # heading, body is remaining markdown ``` ## API Reference ### `blogs_path/0` Returns the absolute path to the blogs directory. **Returns:** `String.t()` ```elixir Blog.blogs_path() # => "/Users/jaspinwall/github/elixir/mcp/asset/blogs" ``` ### `save_post/1` Save HTML content from Summernote as a blog post file. Parses HTML with Floki to extract plain text for filename derivation. **Parameters:** - `html_content` (string) -- raw HTML from Summernote **Returns:** `{:ok, filename}` or `{:error, reason}` ```elixir {:ok, "my-first-post.html"} = Blog.save_post("

My First Post. Content here.

") ``` ### `derive_filename/1` Derive a URL-friendly filename from plain text. Extracts first sentence (up to `.`, `!`, `?`, or 80 chars), slugifies it, appends `.html`. **Parameters:** - `text` (string) -- plain text extracted from blog post **Returns:** `String.t()` ```elixir Blog.derive_filename("Hello World. More text.") # => "hello-world.html" Blog.derive_filename("") # => "untitled-1709395200.html" ``` ### `list_posts/0` List all `.html` and `.md` blog post files, sorted by file creation time (newest first). **Returns:** `[String.t()]` ```elixir Blog.list_posts() # => ["2026-03-13-10-30-topic.md", "hello-world.html"] ``` ### `read_post/1` Read a blog post file's raw content. Validates the resolved path stays within the blogs directory. **Parameters:** - `filename` (string) -- blog post filename **Returns:** `{:ok, content}` or `{:error, :not_found | :traversal}` ```elixir {:ok, content} = Blog.read_post("hello-world.html") {:error, :traversal} = Blog.read_post("../../etc/passwd") ``` ### `update_post/2` Update an existing blog post. Overwrites file content while keeping the original filename/URL stable. **Parameters:** - `filename` (string) -- existing blog post filename - `html_content` (string) -- new content **Returns:** `:ok` or `{:error, :not_found | :traversal | term()}` ```elixir :ok = Blog.update_post("hello-world.html", "

Updated

New content.

") ``` ### `delete_post/1` Delete a blog post file. **Parameters:** - `filename` (string) -- blog post filename **Returns:** `:ok` or `{:error, :not_found | :traversal}` ```elixir :ok = Blog.delete_post("hello-world.html") ``` ### `display_name/1` Convert a slug filename to a human-readable title. **Parameters:** - `filename` (string) -- slug filename **Returns:** `String.t()` ```elixir Blog.display_name("my-first-post.html") # => "My First Post" Blog.display_name("2026-03-13-10-30-topic.md") # => "2026 03 13 10 30 Topic" ``` ### `extract_title/1` Extract the title from blog post content (HTML or Markdown). **Parameters:** - `content` (string) -- blog post content **Returns:** `String.t()` ```elixir Blog.extract_title("

My Post

Body

") # => "My Post" Blog.extract_title("# My Post\n\nBody text") # => "My Post" ``` ### `extract_title_and_body/1` Extract the title and remaining body from blog post content. **Parameters:** - `content` (string) -- blog post content **Returns:** `{String.t(), String.t()}` ```elixir {title, body} = Blog.extract_title_and_body("

Title

Body

") # => {"Title", "

Body

"} {title, body} = Blog.extract_title_and_body("# Title\n\nBody text") # => {"Title", "Body text"} ``` ### `markdown?/1` Check if content appears to be Markdown rather than HTML. **Parameters:** - `content` (string) -- content to check **Returns:** `boolean` ```elixir Blog.markdown?("# Hello") # => true Blog.markdown?("

Hello

") # => false ``` ### `render_editor/0` Render the blog editor page for creating a new post. **Returns:** `String.t()` -- complete HTML page with Summernote editor ```elixir html = Blog.render_editor() ``` ### `render_editor/2` Render the blog editor page for editing an existing post. **Parameters:** - `filename` (string) -- existing post filename - `content` (string) -- current post content **Returns:** `String.t()` -- complete HTML page with Summernote editor pre-filled ```elixir html = Blog.render_editor("hello-world.html", existing_content) ``` ### `render_index/1` Render the blog listing page HTML with search functionality. **Parameters:** - `files` (list of strings) -- blog post filenames **Returns:** `String.t()` -- complete HTML page ```elixir html = Blog.render_index(Blog.list_posts()) ``` ### `render_post/2` Render a single blog post in a Medium-style article wrapper with Source Serif 4 typography, dark/light theme toggle, and code highlighting. **Parameters:** - `title` (string) -- post title - `html_content` (string) -- post body HTML **Returns:** `String.t()` -- complete HTML page ```elixir html = Blog.render_post("My Post", "

Content

") ``` ## Internals / Flow ### Publish Flow ``` Browser (Summernote) | POST /blog/save {content: "

...

"} Router (post "/blog/save") | Blog.save_post(content) Blog.save_post/1 |-- Floki.parse_document(html) -> extract text |-- derive_filename(text) -> "my-post.html" |-- ensure_unique_filename -> append timestamp if duplicate +-- File.write(path, html_content) | JSON {status: "ok", filename, url} Browser -> redirect to new post ``` ### Read Flow ``` Browser -> GET /blog/hello-world.html | Auth bypass (public route) Router (get "/blog/:filename") |-- Blog.read_post(filename) -> {:ok, content} |-- Blog.extract_title_and_body(content) -> {title, body} |-- If markdown: convert body to HTML via Earmark +-- Blog.render_post(title, body_html) -> full HTML page | Browser -> Medium-style article with theme toggle ``` ### Content Detection `markdown?/1` checks if the trimmed content starts with `<` (HTML) or not (Markdown). This simple heuristic works because: - Summernote always produces HTML starting with `

`, `

`, etc. - Markdown files start with `#`, `*`, `-`, `>`, or plain text ### Filename Generation 1. Parse HTML with Floki, extract plain text 2. Take first sentence (up to `.`, `!`, `?`, or 80 chars) 3. Slugify: lowercase, strip non-alphanumeric, replace spaces with hyphens 4. Append `.html` 5. If file already exists, append `-{timestamp}` before extension ## Related Documentation - [BlogStore](blog_store.md) -- Vector + FTS search for blog content - [BlogFileWatcher](blog_file_watcher.md) -- Watches `asset/blogs/` for new/modified files and syncs to BlogStore --- *Source: `lib/blog.ex` -- Last updated: 2026-03-13*