# 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 `
`, ``, ` Content here. New content. My First Post. Content here. New content. Body Body Body Content ... `, `` 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
Updated Title
, 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("
Updated
My Post
Title
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", "`, 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*