mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-04-12 03:38:30 +00:00
Documentation Site on GitHub Pages (#2376)
* Add initial documentations * Update documentation for Basic Client and add WebSocket section * feat: add a static site generator with multi-language support - Introduced a new Rust-based static site generator in the `docs-gen` directory. - Implemented core functionality for building sites from markdown files, including: - Configuration loading from `config.toml`. - Markdown rendering with frontmatter support. - Navigation generation based on page structure. - Static file copying and output directory management. - Added templates for base layout, pages, and portal. - Created a CSS file for styling and a JavaScript file for interactive features like language selection and theme toggling. - Updated documentation source with new configuration and example pages in English and Japanese. - Added a `justfile` target for building the documentation site. * Add language/theme toggle functionality - Created a new Japanese tour index page at docs/ja/tour/index.html - Implemented navigation links for various sections of the cpp-httplib tutorial - Added a language selector to switch between English and Japanese - Introduced theme toggle functionality to switch between light and dark modes - Added mobile sidebar toggle for better navigation on smaller screens
This commit is contained in:
1354
docs-gen/Cargo.lock
generated
Normal file
1354
docs-gen/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
docs-gen/Cargo.toml
Normal file
16
docs-gen/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "docs-gen"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pulldown-cmark = "0.12"
|
||||
tera = "1"
|
||||
walkdir = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yml = "0.0.12"
|
||||
toml = "0.8"
|
||||
syntect = "5"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
148
docs-gen/README.md
Normal file
148
docs-gen/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# docs-gen
|
||||
|
||||
A simple static site generator written in Rust. Designed for multi-language documentation sites with Markdown content, Tera templates, and syntax highlighting.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cargo build --release --manifest-path docs-gen/Cargo.toml
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
docs-gen [SRC] [--out OUT]
|
||||
```
|
||||
|
||||
- `SRC` — Source directory containing `config.toml` (default: `.`)
|
||||
- `--out OUT` — Output directory (default: `docs`)
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
./docs-gen/target/release/docs-gen docs-src --out docs
|
||||
```
|
||||
|
||||
## Source Directory Structure
|
||||
|
||||
```
|
||||
docs-src/
|
||||
├── config.toml # Site configuration
|
||||
├── pages/ # Markdown content (one subdirectory per language)
|
||||
│ ├── en/
|
||||
│ │ ├── index.md # Portal page (no sidebar)
|
||||
│ │ ├── tour/
|
||||
│ │ │ ├── index.md # Section index
|
||||
│ │ │ ├── 01-getting-started.md
|
||||
│ │ │ └── ...
|
||||
│ │ └── cookbook/
|
||||
│ │ └── index.md
|
||||
│ └── ja/
|
||||
│ └── ... # Same structure as en/
|
||||
├── templates/ # Tera HTML templates
|
||||
│ ├── base.html # Base layout (header, scripts)
|
||||
│ ├── page.html # Content page with sidebar navigation
|
||||
│ └── portal.html # Portal page without sidebar
|
||||
└── static/ # Static assets (copied as-is to output root)
|
||||
├── css/
|
||||
└── js/
|
||||
```
|
||||
|
||||
## config.toml
|
||||
|
||||
```toml
|
||||
[site]
|
||||
title = "My Project"
|
||||
base_url = "https://example.github.io/my-project"
|
||||
|
||||
[i18n]
|
||||
default_lang = "en"
|
||||
langs = ["en", "ja"]
|
||||
|
||||
[highlight]
|
||||
theme = "base16-eighties.dark" # Dark mode syntax theme (syntect built-in)
|
||||
theme_light = "base16-ocean.light" # Light mode syntax theme (optional)
|
||||
```
|
||||
|
||||
When `theme_light` is set, code blocks are rendered twice (dark and light) and toggled via CSS classes `.code-dark` / `.code-light`.
|
||||
|
||||
Available themes: `base16-ocean.dark`, `base16-ocean.light`, `base16-eighties.dark`, `base16-mocha.dark`, `InspiredGitHub`, `Solarized (dark)`, `Solarized (light)`.
|
||||
|
||||
## Markdown Frontmatter
|
||||
|
||||
Every `.md` file requires YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Page Title"
|
||||
order: 1
|
||||
---
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `title` | yes | Page title shown in heading and browser tab |
|
||||
| `order` | no | Sort order within the section (default: `0`) |
|
||||
| `status` | no | Set to `"draft"` to show a DRAFT banner |
|
||||
|
||||
## URL Routing
|
||||
|
||||
Markdown files are mapped to URLs as follows:
|
||||
|
||||
| File path | URL | Output file |
|
||||
|-----------------------|-----------------|--------------------------|
|
||||
| `en/index.md` | `/en/` | `en/index.html` |
|
||||
| `en/tour/index.md` | `/en/tour/` | `en/tour/index.html` |
|
||||
| `en/tour/01-foo.md` | `/en/tour/01-foo/` | `en/tour/01-foo/index.html` |
|
||||
|
||||
A root `index.html` is generated automatically, redirecting `/` to `/<default_lang>/` (respecting `localStorage` preference).
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation is generated automatically from the directory structure:
|
||||
|
||||
- Each subdirectory under a language becomes a **section**
|
||||
- The section's `index.md` title is used as the section heading
|
||||
- Pages within a section are sorted by `order`, then by filename
|
||||
- `portal.html` template is used for root `index.md` (no sidebar)
|
||||
- `page.html` template is used for all other pages (with sidebar)
|
||||
|
||||
## Template Variables
|
||||
|
||||
Templates use [Tera](https://keats.github.io/tera/) syntax. Available variables:
|
||||
|
||||
### All templates
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---------------|--------|-------------|
|
||||
| `page.title` | string | Page title from frontmatter |
|
||||
| `page.url` | string | Page URL path |
|
||||
| `page.status` | string? | `"draft"` or null |
|
||||
| `content` | string | Rendered HTML content (use `{{ content \| safe }}`) |
|
||||
| `lang` | string | Current language code |
|
||||
| `site.title` | string | Site title from config |
|
||||
| `site.base_url` | string | Base URL from config |
|
||||
| `site.langs` | list | Available language codes |
|
||||
|
||||
### page.html only
|
||||
|
||||
| Variable | Type | Description |
|
||||
|--------------------|--------|-------------|
|
||||
| `nav` | list | Navigation sections |
|
||||
| `nav[].title` | string | Section title |
|
||||
| `nav[].url` | string | Section URL |
|
||||
| `nav[].active` | bool | Whether this section contains the current page |
|
||||
| `nav[].children` | list | Child pages |
|
||||
| `nav[].children[].title` | string | Page title |
|
||||
| `nav[].children[].url` | string | Page URL |
|
||||
| `nav[].children[].active` | bool | Whether this is the current page |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [pulldown-cmark](https://crates.io/crates/pulldown-cmark) — Markdown parsing
|
||||
- [tera](https://crates.io/crates/tera) — Template engine
|
||||
- [syntect](https://crates.io/crates/syntect) — Syntax highlighting
|
||||
- [walkdir](https://crates.io/crates/walkdir) — Directory traversal
|
||||
- [serde](https://crates.io/crates/serde) / [serde_yml](https://crates.io/crates/serde_yml) / [toml](https://crates.io/crates/toml) — Serialization
|
||||
- [clap](https://crates.io/crates/clap) — CLI argument parsing
|
||||
- [anyhow](https://crates.io/crates/anyhow) — Error handling
|
||||
339
docs-gen/src/builder.rs
Normal file
339
docs-gen/src/builder.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use crate::config::SiteConfig;
|
||||
use crate::markdown::{Frontmatter, MarkdownRenderer};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tera::Tera;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PageContext {
|
||||
title: String,
|
||||
url: String,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct NavItem {
|
||||
title: String,
|
||||
url: String,
|
||||
children: Vec<NavItem>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SiteContext {
|
||||
title: String,
|
||||
version: Option<String>,
|
||||
base_url: String,
|
||||
langs: Vec<String>,
|
||||
}
|
||||
|
||||
struct Page {
|
||||
frontmatter: Frontmatter,
|
||||
html_content: String,
|
||||
url: String,
|
||||
out_path: PathBuf,
|
||||
rel_path: String,
|
||||
section: String,
|
||||
}
|
||||
|
||||
pub fn build(src: &Path, out: &Path) -> Result<()> {
|
||||
let config = SiteConfig::load(src)?;
|
||||
let renderer = MarkdownRenderer::new(config.highlight_theme(), config.highlight_theme_light());
|
||||
|
||||
let templates_dir = src.join("templates");
|
||||
let template_glob = format!("{}/**/*.html", templates_dir.display());
|
||||
let tera = Tera::new(&template_glob).context("Failed to load templates")?;
|
||||
|
||||
// Clean output directory
|
||||
if out.exists() {
|
||||
fs::remove_dir_all(out).context("Failed to clean output directory")?;
|
||||
}
|
||||
fs::create_dir_all(out)?;
|
||||
|
||||
// Copy static files
|
||||
let static_dir = src.join("static");
|
||||
if static_dir.exists() {
|
||||
copy_dir_recursive(&static_dir, out)?;
|
||||
}
|
||||
|
||||
// Build each language
|
||||
for lang in &config.i18n.langs {
|
||||
let pages_dir = src.join("pages").join(lang);
|
||||
if !pages_dir.exists() {
|
||||
eprintln!("Warning: pages directory not found for lang '{}', skipping", lang);
|
||||
continue;
|
||||
}
|
||||
|
||||
let pages = collect_pages(&pages_dir, lang, out, &renderer)?;
|
||||
let nav = build_nav(&pages);
|
||||
|
||||
for page in &pages {
|
||||
let template_name = if page.section.is_empty() {
|
||||
"portal.html"
|
||||
} else {
|
||||
"page.html"
|
||||
};
|
||||
|
||||
// Filter nav to only the current section
|
||||
let section_nav: Vec<&NavItem> = nav
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let item_section = extract_section(&item.url);
|
||||
item_section == page.section
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("page", &PageContext {
|
||||
title: page.frontmatter.title.clone(),
|
||||
url: page.url.clone(),
|
||||
status: page.frontmatter.status.clone(),
|
||||
});
|
||||
ctx.insert("content", &page.html_content);
|
||||
ctx.insert("lang", lang);
|
||||
ctx.insert("site", &SiteContext {
|
||||
title: config.site.title.clone(),
|
||||
version: config.site.version.clone(),
|
||||
base_url: config.site.base_url.clone(),
|
||||
langs: config.i18n.langs.clone(),
|
||||
});
|
||||
|
||||
// Set active state and pass nav
|
||||
let mut nav_with_active: Vec<NavItem> = section_nav
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(|mut item| {
|
||||
set_active(&mut item, &page.url);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If we're on a section index page, expand its children
|
||||
if let Some(item) = nav_with_active.first_mut() {
|
||||
if item.url == page.url {
|
||||
item.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.insert("nav", &nav_with_active);
|
||||
|
||||
let html = tera
|
||||
.render(template_name, &ctx)
|
||||
.with_context(|| format!("Failed to render template for {}", page.url))?;
|
||||
|
||||
if let Some(parent) = page.out_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&page.out_path, html)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate root redirect
|
||||
generate_root_redirect(out, &config)?;
|
||||
|
||||
println!(
|
||||
"Site generated: {} languages, output at {}",
|
||||
config.i18n.langs.len(),
|
||||
out.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_pages(
|
||||
pages_dir: &Path,
|
||||
lang: &str,
|
||||
out: &Path,
|
||||
renderer: &MarkdownRenderer,
|
||||
) -> Result<Vec<Page>> {
|
||||
let mut pages = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(pages_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().extension().map_or(false, |ext| ext == "md")
|
||||
})
|
||||
{
|
||||
let path = entry.path();
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
|
||||
let (frontmatter, body) = MarkdownRenderer::parse_frontmatter(&content)
|
||||
.with_context(|| format!("Failed to parse frontmatter in {}", path.display()))?;
|
||||
|
||||
let html_content = renderer.render(body);
|
||||
|
||||
let rel = path.strip_prefix(pages_dir)?;
|
||||
let rel_str = rel.to_string_lossy().to_string();
|
||||
|
||||
// Compute URL and output path
|
||||
let (url, out_path) = if rel.file_name().map_or(false, |f| f == "index.md") {
|
||||
// index.md -> /<lang>/dir/
|
||||
let parent = rel.parent().unwrap_or(Path::new(""));
|
||||
if parent.as_os_str().is_empty() {
|
||||
// Root index.md
|
||||
(
|
||||
format!("/{}/", lang),
|
||||
out.join(lang).join("index.html"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("/{}/{}/", lang, parent.display()),
|
||||
out.join(lang).join(parent).join("index.html"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// foo.md -> /<lang>/foo/
|
||||
let stem = rel.with_extension("");
|
||||
(
|
||||
format!("/{}/{}/", lang, stem.display()),
|
||||
out.join(lang).join(&stem).join("index.html"),
|
||||
)
|
||||
};
|
||||
|
||||
let section = extract_section(&url);
|
||||
|
||||
pages.push(Page {
|
||||
frontmatter,
|
||||
html_content,
|
||||
url,
|
||||
out_path,
|
||||
rel_path: rel_str,
|
||||
section,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
fn extract_section(url: &str) -> String {
|
||||
// URL format: /<lang>/ or /<lang>/section/...
|
||||
let parts: Vec<&str> = url.trim_matches('/').split('/').collect();
|
||||
if parts.len() >= 2 {
|
||||
parts[1].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_nav(pages: &[Page]) -> Vec<NavItem> {
|
||||
// Group pages by section (top-level directory)
|
||||
let mut sections: std::collections::BTreeMap<String, Vec<&Page>> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for page in pages {
|
||||
if page.section.is_empty() {
|
||||
continue; // Skip root index (portal)
|
||||
}
|
||||
sections
|
||||
.entry(page.section.clone())
|
||||
.or_default()
|
||||
.push(page);
|
||||
}
|
||||
|
||||
let mut nav = Vec::new();
|
||||
|
||||
for (section, mut section_pages) in sections {
|
||||
// Sort by order, then by filename
|
||||
section_pages.sort_by(|a, b| {
|
||||
a.frontmatter
|
||||
.order
|
||||
.cmp(&b.frontmatter.order)
|
||||
.then_with(|| a.rel_path.cmp(&b.rel_path))
|
||||
});
|
||||
|
||||
// Find the section index page
|
||||
let index_page = section_pages
|
||||
.iter()
|
||||
.find(|p| p.rel_path.ends_with("index.md") && extract_section(&p.url) == section);
|
||||
|
||||
let section_title = index_page
|
||||
.map(|p| p.frontmatter.title.clone())
|
||||
.unwrap_or_else(|| section.clone());
|
||||
let section_url = index_page
|
||||
.map(|p| p.url.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let children: Vec<NavItem> = section_pages
|
||||
.iter()
|
||||
.filter(|p| !p.rel_path.ends_with("index.md") || extract_section(&p.url) != section)
|
||||
.map(|p| NavItem {
|
||||
title: p.frontmatter.title.clone(),
|
||||
url: p.url.clone(),
|
||||
children: Vec::new(),
|
||||
active: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
nav.push(NavItem {
|
||||
title: section_title,
|
||||
url: section_url,
|
||||
children,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort nav sections by order of their index pages
|
||||
nav
|
||||
}
|
||||
|
||||
fn set_active(item: &mut NavItem, current_url: &str) {
|
||||
if item.url == current_url {
|
||||
item.active = true;
|
||||
}
|
||||
for child in &mut item.children {
|
||||
set_active(child, current_url);
|
||||
if child.active {
|
||||
item.active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_root_redirect(out: &Path, config: &SiteConfig) -> Result<()> {
|
||||
let html = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script>
|
||||
(function() {{
|
||||
var lang = localStorage.getItem('preferred-lang') || '{}';
|
||||
window.location.replace('/' + lang + '/');
|
||||
}})();
|
||||
</script>
|
||||
<meta http-equiv="refresh" content="0;url=/{default_lang}/">
|
||||
<title>Redirecting...</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to <a href="/{default_lang}/">/{default_lang}/</a>...</p>
|
||||
</body>
|
||||
</html>"#,
|
||||
config.i18n.default_lang,
|
||||
default_lang = config.i18n.default_lang,
|
||||
);
|
||||
|
||||
fs::write(out.join("index.html"), html)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
|
||||
for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(src)?;
|
||||
let target = dst.join(rel);
|
||||
|
||||
if path.is_dir() {
|
||||
fs::create_dir_all(&target)?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(path, &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
53
docs-gen/src/config.rs
Normal file
53
docs-gen/src/config.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteConfig {
|
||||
pub site: Site,
|
||||
pub i18n: I18n,
|
||||
pub highlight: Option<Highlight>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Site {
|
||||
pub title: String,
|
||||
pub version: Option<String>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct I18n {
|
||||
pub default_lang: String,
|
||||
pub langs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Highlight {
|
||||
pub theme: Option<String>,
|
||||
pub theme_light: Option<String>,
|
||||
}
|
||||
|
||||
impl SiteConfig {
|
||||
pub fn load(src_dir: &Path) -> Result<Self> {
|
||||
let path = src_dir.join("config.toml");
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let config: SiteConfig =
|
||||
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn highlight_theme(&self) -> &str {
|
||||
self.highlight
|
||||
.as_ref()
|
||||
.and_then(|h| h.theme.as_deref())
|
||||
.unwrap_or("base16-ocean.dark")
|
||||
}
|
||||
|
||||
pub fn highlight_theme_light(&self) -> Option<&str> {
|
||||
self.highlight
|
||||
.as_ref()
|
||||
.and_then(|h| h.theme_light.as_deref())
|
||||
}
|
||||
}
|
||||
23
docs-gen/src/main.rs
Normal file
23
docs-gen/src/main.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
mod builder;
|
||||
mod config;
|
||||
mod markdown;
|
||||
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "A simple static site generator")]
|
||||
struct Cli {
|
||||
/// Source directory containing config.toml
|
||||
#[arg(default_value = ".")]
|
||||
src: PathBuf,
|
||||
|
||||
/// Output directory
|
||||
#[arg(long, default_value = "docs")]
|
||||
out: PathBuf,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
builder::build(&cli.src, &cli.out)
|
||||
}
|
||||
140
docs-gen/src/markdown.rs
Normal file
140
docs-gen/src/markdown.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use anyhow::{Context, Result};
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||
use serde::Deserialize;
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::html::highlighted_html_for_string;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Frontmatter {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub struct MarkdownRenderer {
|
||||
syntax_set: SyntaxSet,
|
||||
theme_set: ThemeSet,
|
||||
theme_name: String,
|
||||
theme_light_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
pub fn new(theme_name: &str, theme_light_name: Option<&str>) -> Self {
|
||||
Self {
|
||||
syntax_set: SyntaxSet::load_defaults_newlines(),
|
||||
theme_set: ThemeSet::load_defaults(),
|
||||
theme_name: theme_name.to_string(),
|
||||
theme_light_name: theme_light_name.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
|
||||
let content = content.trim_start();
|
||||
if !content.starts_with("---") {
|
||||
anyhow::bail!("Missing frontmatter delimiter");
|
||||
}
|
||||
let after_first = &content[3..];
|
||||
let end = after_first
|
||||
.find("\n---")
|
||||
.context("Missing closing frontmatter delimiter")?;
|
||||
let yaml = &after_first[..end];
|
||||
let body = &after_first[end + 4..];
|
||||
let fm: Frontmatter =
|
||||
serde_yml::from_str(yaml).context("Failed to parse frontmatter YAML")?;
|
||||
Ok((fm, body))
|
||||
}
|
||||
|
||||
pub fn render(&self, markdown: &str) -> String {
|
||||
let options = Options::ENABLE_TABLES
|
||||
| Options::ENABLE_STRIKETHROUGH
|
||||
| Options::ENABLE_TASKLISTS;
|
||||
|
||||
let parser = Parser::new_ext(markdown, options);
|
||||
|
||||
let mut in_code_block = false;
|
||||
let mut code_lang = String::new();
|
||||
let mut code_buf = String::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
in_code_block = true;
|
||||
code_buf.clear();
|
||||
code_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) => lang.to_string(),
|
||||
CodeBlockKind::Indented => String::new(),
|
||||
};
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
in_code_block = false;
|
||||
let html = self.highlight_code(&code_buf, &code_lang);
|
||||
events.push(Event::Html(html.into()));
|
||||
}
|
||||
Event::Text(text) if in_code_block => {
|
||||
code_buf.push_str(&text);
|
||||
}
|
||||
other => events.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
let mut html_output = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html_output, events.into_iter());
|
||||
html_output
|
||||
}
|
||||
|
||||
fn highlight_code(&self, code: &str, lang: &str) -> String {
|
||||
if lang.is_empty() {
|
||||
return format!("<pre><code>{}</code></pre>", escape_html(code));
|
||||
}
|
||||
|
||||
let syntax = self
|
||||
.syntax_set
|
||||
.find_syntax_by_token(lang)
|
||||
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
|
||||
|
||||
let dark_html = self.highlight_with_theme(code, syntax, &self.theme_name);
|
||||
|
||||
if let Some(ref light_name) = self.theme_light_name {
|
||||
let light_html = self.highlight_with_theme(code, syntax, light_name);
|
||||
format!(
|
||||
"<div class=\"code-dark\">{}</div><div class=\"code-light\">{}</div>",
|
||||
dark_html, light_html
|
||||
)
|
||||
} else {
|
||||
dark_html
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_with_theme(
|
||||
&self,
|
||||
code: &str,
|
||||
syntax: &syntect::parsing::SyntaxReference,
|
||||
theme_name: &str,
|
||||
) -> String {
|
||||
let theme = self
|
||||
.theme_set
|
||||
.themes
|
||||
.get(theme_name)
|
||||
.unwrap_or_else(|| {
|
||||
self.theme_set
|
||||
.themes
|
||||
.values()
|
||||
.next()
|
||||
.expect("No themes available")
|
||||
});
|
||||
|
||||
match highlighted_html_for_string(code, &self.syntax_set, syntax, theme) {
|
||||
Ok(html) => html,
|
||||
Err(_) => format!("<pre><code>{}</code></pre>", escape_html(code)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
Reference in New Issue
Block a user