summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore9
-rw-r--r--CMakeLists.txt63
-rw-r--r--README.md26
-rw-r--r--config.toml5
-rw-r--r--src/config.rs5
-rw-r--r--src/main.cpp250
-rw-r--r--static/style.css219
-rw-r--r--templates/index.html34
-rw-r--r--templates/post.html24
-rw-r--r--templates/search.html36
10 files changed, 662 insertions, 9 deletions
diff --git a/.gitignore b/.gitignore
index b781b2f..aa59418 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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>