diff options
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | CMakeLists.txt | 63 | ||||
| -rw-r--r-- | README.md | 26 | ||||
| -rw-r--r-- | config.toml | 5 | ||||
| -rw-r--r-- | src/config.rs | 5 | ||||
| -rw-r--r-- | src/main.cpp | 250 | ||||
| -rw-r--r-- | static/style.css | 219 | ||||
| -rw-r--r-- | templates/index.html | 34 | ||||
| -rw-r--r-- | templates/post.html | 24 | ||||
| -rw-r--r-- | templates/search.html | 36 |
10 files changed, 662 insertions, 9 deletions
@@ -1,4 +1,5 @@ -/public -/content -todo.md -/target +core.* +build/ +content/ +target/ +public/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..947ebcd --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,63 @@ +cmake_minimum_required(VERSION 3.20) +project(ahsi VERSION 0.1.0 LANGUAGES CXX C) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) + +FetchContent_Declare( + tomlplusplus + GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git + GIT_TAG v3.4.0 +) +FetchContent_MakeAvailable(tomlplusplus) + +FetchContent_Declare( + json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 +) +FetchContent_MakeAvailable(json) + +FetchContent_Declare( + inja + GIT_REPOSITORY https://github.com/pantor/inja.git + GIT_TAG v3.4.0 +) + +set(INJA_USE_EMBEDDED_JSON OFF CACHE BOOL "" FORCE) +set(INJA_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) +set(INJA_INSTALL OFF CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(inja) + +FetchContent_Declare( + cmark + GIT_REPOSITORY https://github.com/commonmark/cmark.git + GIT_TAG 0.31.1 +) +FetchContent_MakeAvailable(cmark) + +file(GLOB AHSI_SOURCES CONFIGURE_DEPENDS src/*.cpp) + +add_executable(ahsi ${AHSI_SOURCES}) + +target_include_directories(ahsi PRIVATE + src + ${tomlplusplus_SOURCE_DIR}/include + ${json_SOURCE_DIR}/include + ${inja_SOURCE_DIR}/include + ${cmark_SOURCE_DIR}/include +) + +target_link_libraries(ahsi PRIVATE cmark) + +set_target_properties(ahsi PROPERTIES + VERSION ${PROJECT_VERSION} + OUTPUT_NAME "ahsi" +) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..506c8e6 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +ahsi +==== + +Simple static site generator written in C++. It uses markdown for source text +and toml for site configuration. + +Build +----- + +I use CMake for building the program. + +```sh +$ mkdir build && cd build +$ cmake .. +$ cmake --build . +``` + +Libraries +--------- + +I would thank to the folks. Ahsi never would have existed without those libaries. + +- Tomlplusplus by marzer +- Json by nlohman +- Inja by pantor +- Commonmark by cmark diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..cb12ed0 --- /dev/null +++ b/config.toml @@ -0,0 +1,5 @@ +[main] +title = "radhitya.org" +url = "/" +author = "radhitya" +posts_per_page = 5 diff --git a/src/config.rs b/src/config.rs index 8dc8a6f..bfffd54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,14 +9,9 @@ pub struct Config { } pub fn parse_file(filename: &str) { - println!("loaded {} config file", filename); let contents = fs::read_to_string(filename) .expect("something went wrong reading to file"); let config: Config = toml::from_str(&contents) .expect("failed to parse toml formatting"); - - println!("title: {}", config.title); - println!("url: {}", config.url); - println!("author: {}", config.author); } diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..88a2f57 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,250 @@ +#include <iostream> +#include <string> +#include <string_view> +#include <toml++/toml.hpp> +#include <fstream> +#include <cassert> +#include <cmark.h> +#include <cstdlib> +#include <inja/inja.hpp> +#include <nlohmann/json.hpp> +#include <cstring> +#include <dirent.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <vector> +#include <algorithm> + +using json = nlohmann::json; +using namespace std::string_literals; + +struct MainConfig { + std::string title; + std::string url; + std::string author; + int posts_per_page = 5; +}; + +struct FrontmatterConfig { + std::string title; + std::string date; + std::string categories; + std::string tags; + bool draft; + std::string body; +}; + +struct Config { + MainConfig main; + FrontmatterConfig frontmatter; +}; + + +struct Post { + std::string slug; + std::string title; + std::string date; + std::string body_html; +}; + +Config +parse_config(const std::string& filepath) +{ + toml::table tbl; + Config cfg; + + try { + tbl = toml::parse_file(filepath); + } catch (const toml::parse_error& err) { + std::cerr << "parsing failed: " << err << std::endl; + throw; + } + + if (auto* main = tbl["main"].as_table()) { + cfg.main.title = (*main)["title"].value_or("untitled's blog"); + cfg.main.url = (*main)["url"].value_or("localhost"); + cfg.main.author = (*main)["author"].value_or("linus"); + cfg.main.posts_per_page = (int)(*main)["posts_per_page"].value_or(5LL); + } + return cfg; +} + +Config +frontmatter(const std::string& filepath) +{ + Config cfg; + std::ifstream file(filepath); + std::string content((std::istreambuf_iterator<char>(file)), + std::istreambuf_iterator<char>()); + + const size_t first_fence = content.find("+++"); + const size_t second_fence = (first_fence == std::string::npos) + ? std::string::npos + : content.find("+++", first_fence + 3); + + if (first_fence != std::string::npos && second_fence != std::string::npos) { + std::string fm = content.substr(first_fence + 3, + second_fence - (first_fence+3)); + try { + toml::parse_result result = toml::parse(fm); + const toml::table& tbl = result; + cfg.frontmatter.title = tbl["title"].value_or(""s); + cfg.frontmatter.date = tbl["date"].value_or(""s); + cfg.frontmatter.categories = tbl["categories"].value_or(""s); + cfg.frontmatter.tags = tbl["tags"].value_or(""s); + cfg.frontmatter.draft = tbl["draft"].value_or(true); + } catch(const toml::parse_error& err) { + std::cerr << "parsing failed: " << err << std::endl; + throw; + } + cfg.frontmatter.body = content.substr(second_fence + 3); + } + return cfg; +} + +std::string +markdown_processing(const std::string& md) { + char *raw = cmark_markdown_to_html(md.c_str(), md.size(),0); + std::string html(raw); + free(raw); + return html; +} + +std::string +strip_html(const std::string& html) { + std::string out; + bool in_tag = false; + for (char c : html) { + if (c == '<') in_tag = true; + else if (c == '>') in_tag = false; + else if (!in_tag) out += c; + } + return out; +} + +int main() { + inja::Environment env; + nlohmann::json site; + system("rm -rf public"); + if (mkdir("public", 0755) != 0) { + std::cerr << "cant create public" << std::endl; + return 1; + } + system("cp -r static/. public/"); + + Config my_config = parse_config("config.toml"); + int ppp = my_config.main.posts_per_page; + if (ppp < 1) ppp = 5; + + site["site"]["title"] = my_config.main.title; + site["site"]["author"] = my_config.main.author; + site["site"]["url"] = my_config.main.url; + + std::vector<Post> posts; + std::string intro_html; + + DIR *dir = opendir("content"); + if (!dir) { + std::cerr << "cant open content/" << std::endl; + return 1; + } + + struct dirent* ent; + while ((ent = readdir(dir)) != nullptr) { + if (ent->d_name[0] == '.') continue; + const char* ext = strrchr(ent->d_name, '.'); + if (!ext || strcmp(ext, ".md") != 0) continue; + + std::string fname(ent->d_name); + std::string slug(fname.c_str(), ext - ent->d_name); + std::string fpath = "content/" + fname; + + Config page = frontmatter(fpath); + + if (slug == "_index") { + intro_html = markdown_processing(page.frontmatter.body); + continue; + } + + if (page.frontmatter.draft) continue; + + posts.push_back({ + slug, + page.frontmatter.title.empty() ? slug : page.frontmatter.title, + page.frontmatter.date, + markdown_processing(page.frontmatter.body) + }); + } + closedir(dir); + + std::sort(posts.begin(), posts.end(), + [](const Post& a, const Post& b) { return a.date > b.date; }); + + for (const auto& p : posts) { + nlohmann::json data = site; + data["title"] = p.title; + data["date"] = p.date; + data["draft"] = false; + data["body"] = p.body_html; + std::string html = env.render_file("templates/post.html", data); + std::string outdir = "public/" + p.slug; + mkdir(outdir.c_str(), 0755); + std::ofstream out(outdir + "/index.html"); + out << html; + } + + int total = (int)posts.size(); + int pages = total > 0 ? (total + ppp - 1) / ppp : 1; + + for (int page = 1; page <= pages; page++) { + nlohmann::json data = site; + data["intro"] = intro_html; + json arr = json::array(); + int start = (page - 1) * ppp; + int end = std::min(start + ppp, total); + for (int i = start; i < end; i++) { + arr.push_back({ + {"title", posts[i].title}, + {"slug", posts[i].slug}, + {"date", posts[i].date}, + {"excerpt", posts[i].body_html} + }); + } + data["posts"] = arr; + data["page"] = page; + data["total_pages"] = pages; + data["has_prev"] = (page > 1); + data["has_next"] = (page < pages); + data["prev_url"] = (page == 2) ? "/" : ("/page/" + std::to_string(page - 1) + "/"); + data["next_url"] = "/page/" + std::to_string(page + 1) + "/"; + + std::string outdir = (page == 1) ? "public" : ("public/page/" + std::to_string(page)); + if (page > 1) system(("mkdir -p " + outdir).c_str()); + std::string html = env.render_file("templates/index.html", data); + std::ofstream out(outdir + "/index.html"); + out << html; + } + + json search_idx = json::array(); + for (const auto& p : posts) { + search_idx.push_back({ + {"title", p.title}, + {"slug", p.slug}, + {"date", p.date}, + {"text", strip_html(p.body_html)} + }); + } + std::ofstream sj("public/search.json"); + sj << search_idx.dump(); + + nlohmann::json data = site; + data["title"] = "Search"; + system("mkdir -p public/search"); + std::string html = env.render_file("templates/search.html", data); + std::ofstream so("public/search/index.html"); + so << html; + + std::cout << "wrote " << posts.size() << " posts, " + << pages << " page(s), search" << std::endl; + return 0; +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..3eb2fad --- /dev/null +++ b/static/style.css @@ -0,0 +1,219 @@ +:root { + --bg: #fff; + --bg-alt: #eee; + --text: #000; + --link: #00b; + --border: #ccc; + --max-w: 800px; + --pad: 1rem; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #141414; + --bg-alt: #1c1c1c; + --text: #fff; + --link: #8ab4ff; + --border: #444; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +header, main, footer { + max-width: var(--max-w); + margin: auto; + padding: 0.75rem var(--pad); +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.75rem; +} + +header h1 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +header h1 a { + color: var(--text); + text-decoration: none; +} + +header nav a { + color: var(--link); + text-decoration: none; + font-size: 0.875rem; +} + +header nav a:hover { + text-decoration: underline; +} + +main { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + min-height: 60vh; +} + +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* post list */ +.post-list { + list-style: none; + padding: 0; + margin: 0; +} + +.post-list li { + padding: 0.25rem 0; +} + +.post-list .date { + display: block; + font-size: 0.8125rem; + opacity: 0.7; + line-height: 1.4; +} + +.post-list li a { + font-size: 0.9375rem; +} + +/* pagination */ +.pagination { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.5rem; + font-family: monospace; + align-items: center; +} + +.pagination a { + border: 1px solid var(--border); + padding: 0.2rem 0.55rem; + color: var(--link); + text-decoration: none; +} + +.pagination a:hover { + background: var(--bg-alt); +} + +.pagination .disabled { + color: var(--border); +} + +.pagination .info { + opacity: 0.7; +} + +/* content pages */ +article h1 { + font-size: 1.5rem; + margin-bottom: 0.25rem; +} + +article .meta { + display: block; + font-size: 0.875rem; + opacity: 0.8; + margin-bottom: 1rem; +} + +article h2 { font-size: 1.2rem; margin: 1.5rem 0 0.5rem; } +article h3 { font-size: 1.1rem; margin: 1.25rem 0 0.4rem; } + +p, ul, ol, blockquote, pre { margin-bottom: 1rem; } +ul, ol { padding-left: 1.5rem; } +li { margin-bottom: 0.25rem; } + +pre, code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.9em; +} + +code { + background: var(--bg-alt); + padding: 0.15em 0.4em; + border-radius: 3px; +} + +pre { + padding: 0.5rem 1rem; + margin: 0 -1rem; + overflow-x: auto; + background: #272822; + border-radius: 0; +} + +pre code { + color: #f8f8f2; + background: none; + padding: 0; + font-size: 0.875em; +} + +blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + opacity: 0.85; + font-style: italic; +} + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +img { max-width: 100%; height: auto; } + +/* search */ +input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + font-size: 1rem; + font-family: inherit; + outline: none; + margin-bottom: 1.5rem; +} + +input[type="search"]:focus { + border-color: var(--link); +} + +footer { + margin-top: 2rem; + padding-top: 0.75rem; + border-top: none; + font-size: 0.875rem; + opacity: 0.8; +} + +footer p { margin: 0; } + +@media (max-width: 767px) { + header, main, footer { + padding: 0.6rem; + } + header { margin-top: 0.6rem; } + article h1 { font-size: 1.3rem; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f981b3b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ site.title }}</title> +<link rel="stylesheet" href="{{ site.url }}style.css"> +</head> +<body> +<header> + <h1><a href="/">{{ site.title }}</a></h1> + <nav><a href="/search/">search</a></nav> +</header> +<main> +{% if intro %}<section class="intro">{{ intro }}</section>{% endif %} +<ul class="post-list"> +{% for post in posts %} + <li> + <span class="date">{{ post.date }}</span> + <a href="/{{ post.slug }}/">{{ post.title }}</a> + </li> +{% endfor %} +</ul> +{% if total_pages > 1 %} +<nav class="pagination"> + <span>{% if has_prev %}<a href="{{ prev_url }}">← newer</a>{% else %}<span class="disabled">← newer</span>{% endif %}</span> + <span class="info">page {{ page }} of {{ total_pages }}</span> + <span>{% if has_next %}<a href="{{ next_url }}">older →</a>{% else %}<span class="disabled">older →</span>{% endif %}</span> +</nav> +{% endif %} +</main> +<footer><p>{{ site.author }}</p></footer> +</body> +</html> diff --git a/templates/post.html b/templates/post.html new file mode 100644 index 0000000..531ea63 --- /dev/null +++ b/templates/post.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ title }} — {{ site.title }}</title> +<link rel="stylesheet" href="{{ site.url }}style.css"> +</head> +<body> +<header> + <h1><a href="/">{{ site.title }}</a></h1> + <nav><a href="/search/">search</a></nav> +</header> +<main> + <article> + <h1>{{ title }}</h1> + <span class="meta">{{ date }}{% if draft %} ◈ draft{% endif %}</span> + {{ body }} + </article> + <p><a href="/">← back</a></p> +</main> +<footer><p>{{ site.author }}</p></footer> +</body> +</html> diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..b022c8d --- /dev/null +++ b/templates/search.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Search — {{ site.title }}</title> +<link rel="stylesheet" href="{{ site.url }}style.css"> +</head> +<body> +<header> + <h1><a href="/">{{ site.title }}</a></h1> + <nav><a href="/">home</a></nav> +</header> +<main> + <input type="search" id="q" placeholder="search posts..." autofocus> + <ul id="results" class="post-list"></ul> +</main> +<footer><p>{{ site.author }}</p></footer> +<script> +var idx = []; +fetch("/search.json").then(function(r){return r.json()}).then(function(d){idx=d}); +document.getElementById("q").addEventListener("input", function(){ + var q = this.value.toLowerCase().trim(); + var html = ""; + if (q.length < 2) { document.getElementById("results").innerHTML = ""; return; } + for (var i = 0; i < idx.length; i++) { + var p = idx[i]; + if (p.title.toLowerCase().indexOf(q) >= 0 || p.text.toLowerCase().indexOf(q) >= 0) { + html += '<li><h2><a href="/' + p.slug + '/">' + p.title + '</a></h2><span class="meta">' + p.date + '</span></li>'; + } + } + document.getElementById("results").innerHTML = html || "<li class='none'>no results</li>"; +}); +</script> +</body> +</html> |
