Initial commit
commit
a41218e0d9
@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
/target/
|
||||
/config.kdl
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@
|
||||
[workspace]
|
||||
|
||||
members = [
|
||||
"blog_server",
|
||||
"utils/css_gen"
|
||||
]
|
||||
@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "blog_server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# My own utilities library
|
||||
libshire = { git = "https://github.com/pantonshire/libshire" }
|
||||
# Async runtime for Axum
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# Web server framework
|
||||
axum = "0.5"
|
||||
# Middleware for the web server
|
||||
tower = { version = "0.4", features = ["limit"] }
|
||||
tower-http = { version = "0.3", features = ["fs", "trace"] }
|
||||
# Compile-time HTTP templating
|
||||
maud = "0.23"
|
||||
# KDL parsing
|
||||
knuffel = "2"
|
||||
# CommonMark parsing
|
||||
pulldown-cmark = "0.9"
|
||||
# Syntax highlighting
|
||||
syntect = "4"
|
||||
# Filesystem event watcher
|
||||
notify = "4"
|
||||
# Time library
|
||||
chrono = "0.4"
|
||||
# Logging for observability
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
# Pretty errors
|
||||
miette = { version = "4", features = ["fancy"] }
|
||||
@ -0,0 +1,67 @@
|
||||
use maud::{html, Markup, PreEscaped};
|
||||
use syntect::html::{ClassedHTMLGenerator, ClassStyle};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "cb_" };
|
||||
|
||||
pub struct CodeBlockRenderer {
|
||||
syntax_set: SyntaxSet,
|
||||
}
|
||||
|
||||
impl CodeBlockRenderer {
|
||||
pub fn new() -> Self {
|
||||
// Load Syntect's default syntax set from Sublime syntax definitions embedded in the
|
||||
// binary.
|
||||
let default_syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
Self::new_with_syntax_set(default_syntax_set)
|
||||
}
|
||||
|
||||
pub fn new_with_syntax_set(syntax_set: SyntaxSet) -> Self {
|
||||
Self {
|
||||
syntax_set,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, lang: &str, source: &str) -> Markup {
|
||||
const CONTEXT_DELIM: &str = "@@";
|
||||
|
||||
// Grab the optional context information between @@s from the first line of the code block.
|
||||
let (context, source) = source.split_once('\n')
|
||||
.and_then(|(context, source)| context
|
||||
.trim()
|
||||
.strip_prefix(CONTEXT_DELIM)
|
||||
.and_then(|context| context.strip_suffix(CONTEXT_DELIM))
|
||||
.map(|context| (Some(context.trim()), source)))
|
||||
.unwrap_or((None, source));
|
||||
|
||||
// Search the syntax set for the syntax definition for the language specified for the code
|
||||
// block (after the triple backtick), and default to plaintext if no syntax definition is
|
||||
// found.
|
||||
let syntax = self.syntax_set
|
||||
.find_syntax_by_token(lang)
|
||||
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
|
||||
|
||||
let mut html_gen = ClassedHTMLGenerator::new_with_class_style(
|
||||
syntax,
|
||||
&self.syntax_set,
|
||||
CLASS_STYLE
|
||||
);
|
||||
|
||||
for line in LinesWithEndings::from(source) {
|
||||
html_gen.parse_html_for_line_which_includes_newline(line);
|
||||
}
|
||||
|
||||
let html_out = html_gen.finalize();
|
||||
|
||||
//TODO: show context & language
|
||||
|
||||
html! {
|
||||
pre .codeblock {
|
||||
code {
|
||||
(PreEscaped(html_out))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::mpsc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use miette::{IntoDiagnostic, WrapErr};
|
||||
use notify::{
|
||||
DebouncedEvent,
|
||||
FsEventWatcher,
|
||||
RecursiveMode,
|
||||
Watcher,
|
||||
watcher,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
pub fn start_watching(
|
||||
tx: mpsc::Sender<DebouncedEvent>,
|
||||
watch_path: &Path
|
||||
) -> miette::Result<FsEventWatcher>
|
||||
{
|
||||
let mut watcher = watcher(tx, Duration::from_secs(2))
|
||||
.into_diagnostic()
|
||||
.wrap_err("Failed to create filesystem watcher")?;
|
||||
|
||||
// Watch the path in non-recursive mode, so events are not generated for nodes in
|
||||
// sub-directories.
|
||||
watcher.watch(watch_path, RecursiveMode::NonRecursive)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to watch directory {}", watch_path.to_string_lossy()))?;
|
||||
|
||||
info!(path = %watch_path.to_string_lossy(), "Watching directory");
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,211 @@
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::{self, Write};
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::http::{self, StatusCode};
|
||||
use maud::{html, Markup, Render, Escaper, DOCTYPE};
|
||||
|
||||
pub struct HtmlResponse {
|
||||
status: StatusCode,
|
||||
title: Cow<'static, str>,
|
||||
head: Option<Markup>,
|
||||
body: Option<Markup>,
|
||||
crawler_hints: CrawlerHints,
|
||||
}
|
||||
|
||||
impl HtmlResponse {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: StatusCode::OK,
|
||||
title: Cow::Borrowed("untitled"),
|
||||
head: None,
|
||||
body: None,
|
||||
crawler_hints: CrawlerHints::restrictive(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_status(self, status: StatusCode) -> Self {
|
||||
Self { status, ..self }
|
||||
}
|
||||
|
||||
pub fn with_title(self, title: Cow<'static, str>) -> Self {
|
||||
Self { title, ..self }
|
||||
}
|
||||
|
||||
pub fn with_title_static(self, title: &'static str) -> Self {
|
||||
self.with_title(Cow::Borrowed(title))
|
||||
}
|
||||
|
||||
pub fn with_title_owned(self, title: String) -> Self {
|
||||
self.with_title(Cow::Owned(title))
|
||||
}
|
||||
|
||||
pub fn with_head(self, head: Markup) -> Self {
|
||||
Self { head: Some(head), ..self }
|
||||
}
|
||||
|
||||
pub fn with_body(self, body: Markup) -> Self {
|
||||
Self { body: Some(body), ..self }
|
||||
}
|
||||
|
||||
pub fn with_crawler_hints(self, crawler_hints: CrawlerHints) -> Self {
|
||||
Self { crawler_hints, ..self }
|
||||
}
|
||||
|
||||
pub fn with_crawler_restrictive(self) -> Self {
|
||||
self.with_crawler_hints(CrawlerHints::restrictive())
|
||||
}
|
||||
|
||||
pub fn with_crawler_permissive(self) -> Self {
|
||||
self.with_crawler_hints(CrawlerHints::permissive())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HtmlResponse {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for HtmlResponse {
|
||||
fn into_response(self) -> Response {
|
||||
let html_doc = html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
meta name="robots" content=(self.crawler_hints);
|
||||
title { (self.title) }
|
||||
@if let Some(head) = self.head {
|
||||
(head)
|
||||
}
|
||||
}
|
||||
body {
|
||||
@if let Some(body) = self.body {
|
||||
(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut response = (self.status, html_doc.into_string())
|
||||
.into_response();
|
||||
|
||||
response.headers_mut()
|
||||
.append("Content-Type", http::HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CrawlerHints {
|
||||
index: bool,
|
||||
follow: bool,
|
||||
archive: bool,
|
||||
snippet: bool,
|
||||
image_index: bool,
|
||||
}
|
||||
|
||||
impl CrawlerHints {
|
||||
pub const fn restrictive() -> Self {
|
||||
Self {
|
||||
index: false,
|
||||
follow: false,
|
||||
archive: false,
|
||||
snippet: false,
|
||||
image_index: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn permissive() -> Self {
|
||||
Self {
|
||||
index: true,
|
||||
follow: true,
|
||||
archive: true,
|
||||
snippet: true,
|
||||
image_index: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_index(self, index: bool) -> Self {
|
||||
Self { index, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_follow(self, follow: bool) -> Self {
|
||||
Self { follow, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_archive(self, archive: bool) -> Self {
|
||||
Self { archive, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_snippet(self, snippet: bool) -> Self {
|
||||
Self { snippet, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_image_index(self, image_index: bool) -> Self {
|
||||
Self { image_index, ..self }
|
||||
}
|
||||
|
||||
fn index_str(self) -> &'static str {
|
||||
if self.index {
|
||||
"index"
|
||||
} else {
|
||||
"noindex"
|
||||
}
|
||||
}
|
||||
|
||||
fn follow_str(self) -> &'static str {
|
||||
if self.follow {
|
||||
"follow"
|
||||
} else {
|
||||
"nofollow"
|
||||
}
|
||||
}
|
||||
|
||||
fn archive_strs(self) -> Option<[&'static str; 2]> {
|
||||
if self.archive {
|
||||
None
|
||||
} else {
|
||||
Some(["noarchive", "nocache"])
|
||||
}
|
||||
}
|
||||
|
||||
fn snippet_str(self) -> Option<&'static str> {
|
||||
if self.snippet {
|
||||
None
|
||||
} else {
|
||||
Some("nosnippet")
|
||||
}
|
||||
}
|
||||
|
||||
fn image_index_str(self) -> Option<&'static str> {
|
||||
if self.image_index {
|
||||
None
|
||||
} else {
|
||||
Some("noimageindex")
|
||||
}
|
||||
}
|
||||
|
||||
fn write_meta_list_to<W: Write>(self, mut buf: W) -> fmt::Result {
|
||||
write!(buf, "{},{}", self.index_str(), self.follow_str())?;
|
||||
if let Some([archive_str, cache_str]) = self.archive_strs() {
|
||||
write!(buf, ",{},{}", archive_str, cache_str)?;
|
||||
}
|
||||
if let Some(snippet_str) = self.snippet_str() {
|
||||
write!(buf, ",{}", snippet_str)?;
|
||||
}
|
||||
if let Some(image_index_str) = self.image_index_str() {
|
||||
write!(buf, ",{}", image_index_str)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CrawlerHints {
|
||||
fn render_to(&self, buf: &mut String) {
|
||||
let escaper = Escaper::new(buf);
|
||||
let _result = self.write_meta_list_to(escaper);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
mod codeblock;
|
||||
mod fs_watcher;
|
||||
mod handlers;
|
||||
mod html_response;
|
||||
mod post;
|
||||
mod posts_store;
|
||||
mod render;
|
||||
|
||||
use std::{env, fs, io, path::PathBuf, thread};
|
||||
|
||||
use axum::{
|
||||
{routing::{get, get_service}, Router},
|
||||
extract::{Extension, Path},
|
||||
response::{IntoResponse, Response},
|
||||
handler::Handler,
|
||||
http::StatusCode
|
||||
};
|
||||
use libshire::convert::infallible_elim;
|
||||
use maud::html;
|
||||
use miette::{IntoDiagnostic, Context};
|
||||
use tower::{
|
||||
limit::ConcurrencyLimitLayer,
|
||||
ServiceExt,
|
||||
};
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
|
||||
use codeblock::CodeBlockRenderer;
|
||||
use html_response::HtmlResponse;
|
||||
use posts_store::ConcurrentPostsStore;
|
||||
use render::Renderer;
|
||||
|
||||
#[derive(knuffel::Decode)]
|
||||
struct Config {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
bind: String,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
posts_dir: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
static_dir: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
concurrency_limit: usize,
|
||||
}
|
||||
|
||||
fn main() -> miette::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Load the configuration from the KDL config file specified by the first command-line
|
||||
// argument.
|
||||
let config = {
|
||||
let config_path = env::args().nth(1)
|
||||
.ok_or_else(|| miette::Error::msg("No config file specified"))?;
|
||||
|
||||
info!(path = %config_path, "Loading config");
|
||||
|
||||
let contents = fs::read_to_string(&config_path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to read config file {}", config_path))?;
|
||||
|
||||
knuffel::parse::<Config>(&config_path, &contents)
|
||||
.wrap_err_with(|| format!("Failed to parse config file {}", config_path))?
|
||||
};
|
||||
|
||||
// Create the data structure used to store the rendered posts. This uses an `Arc` internally,
|
||||
// so clones will point to the same underlying data.
|
||||
let posts_store = ConcurrentPostsStore::new();
|
||||
|
||||
let code_renderer = CodeBlockRenderer::new();
|
||||
|
||||
// Create the post renderer and the mpsc channel that will be used to communicate with it.
|
||||
let (renderer, tx) = Renderer::new(
|
||||
posts_store.clone(),
|
||||
code_renderer,
|
||||
config.posts_dir.clone()
|
||||
);
|
||||
|
||||
// Dropping the watcher stops its thread, so keep it alive until `main` returns.
|
||||
let _watcher = fs_watcher::start_watching(tx, &config.posts_dir)?;
|
||||
|
||||
thread::spawn(move || {
|
||||
renderer.handle_events();
|
||||
});
|
||||
|
||||
info!("Started renderer thread");
|
||||
|
||||
// To run the web server, we need to be in an async context, so create a new Tokio runtime and
|
||||
// pass control to it.
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.into_diagnostic()
|
||||
.wrap_err("Failed to create async runtime")?
|
||||
.block_on(run(config, posts_store))
|
||||
}
|
||||
|
||||
async fn run(config: Config, posts_store: ConcurrentPostsStore) -> miette::Result<()> {
|
||||
let static_service = get_service(ServeDir::new(&config.static_dir)
|
||||
.fallback(handle_fallback
|
||||
.into_service()
|
||||
.map_err(infallible_elim::<io::Error>)))
|
||||
.handle_error(handle_static_io_error);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/", get(handle_index))
|
||||
.route("/posts/:post_id", get(handle_post_page))
|
||||
.nest("/static", static_service)
|
||||
.fallback(handle_fallback.into_service())
|
||||
.layer(ConcurrencyLimitLayer::new(config.concurrency_limit))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(Extension(posts_store));
|
||||
|
||||
let bind_address = &config.bind
|
||||
.parse()
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to parse socket address \"{}\"", config.bind))?;
|
||||
|
||||
info!(address = %bind_address, "Starting server");
|
||||
|
||||
axum::Server::try_bind(bind_address)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to bind {}", bind_address))?
|
||||
.serve(router.into_make_service())
|
||||
.await
|
||||
.into_diagnostic()
|
||||
.wrap_err("Fatal error while running the server")
|
||||
}
|
||||
|
||||
async fn handle_fallback() -> Error {
|
||||
Error::NotFound
|
||||
}
|
||||
|
||||
async fn handle_static_io_error(_err: io::Error) -> Error {
|
||||
Error::Internal
|
||||
}
|
||||
|
||||
async fn handle_index(Extension(posts): Extension<ConcurrentPostsStore>) -> HtmlResponse {
|
||||
HtmlResponse::new()
|
||||
.with_title_static("Placeholder title")
|
||||
.with_crawler_permissive()
|
||||
.with_body(html! {
|
||||
h1 { "Here is my great heading" }
|
||||
p { "Hello world" }
|
||||
ul {
|
||||
@for post in posts.read().await.iter_by_created().rev() {
|
||||
li {
|
||||
a href={ "/posts/" (post.id_str()) } {
|
||||
(post.title())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_post_page(
|
||||
Path(post_id): Path<String>,
|
||||
Extension(posts): Extension<ConcurrentPostsStore>
|
||||
) -> Result<HtmlResponse, Error>
|
||||
{
|
||||
let post = posts.get(&post_id)
|
||||
.await
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
Ok(HtmlResponse::new()
|
||||
.with_crawler_permissive()
|
||||
.with_title_owned(post.title().to_owned())
|
||||
.with_head(html! {
|
||||
link href="/static/style/code.css" rel="stylesheet";
|
||||
})
|
||||
.with_body(html! {
|
||||
h1 { (post.title()) }
|
||||
p { "by " (post.author()) }
|
||||
article {
|
||||
(post.html())
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// TODO: store diagnostic information in Error struct which is output to trace
|
||||
#[derive(Debug)]
|
||||
enum Error {
|
||||
Internal,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Error::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Error::NotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let status_code = self.status_code();
|
||||
|
||||
// Create a string buffer containing the full error text, e.g. "404 Not Found".
|
||||
let status_text = {
|
||||
let status_code_str = status_code.as_str();
|
||||
let reason = status_code.canonical_reason();
|
||||
let mut buf = String::with_capacity(
|
||||
status_code_str.len() + reason.map(|reason| reason.len() + 1).unwrap_or(0));
|
||||
buf.push_str(status_code_str);
|
||||
if let Some(reason) = reason {
|
||||
buf.push(' ');
|
||||
buf.push_str(reason);
|
||||
}
|
||||
buf
|
||||
};
|
||||
|
||||
HtmlResponse::new()
|
||||
.with_status(status_code)
|
||||
.with_body(html! {
|
||||
p { (status_text) }
|
||||
})
|
||||
.with_title_owned(status_text)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,295 @@
|
||||
use std::{borrow, error, fmt, ops};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::strings::ShString22;
|
||||
use maud::{Markup, PreEscaped};
|
||||
|
||||
use crate::codeblock::CodeBlockRenderer;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct PostId(ShString22);
|
||||
|
||||
impl PostId {
|
||||
pub fn from_file_name(file_name: &str) -> Option<Self> {
|
||||
const POST_FILE_EXTENSION: &str = ".kdl.md";
|
||||
|
||||
fn is_invalid_char(c: char) -> bool {
|
||||
c == '/' || c == '\\' || c == '.'
|
||||
}
|
||||
|
||||
let prefix = file_name
|
||||
.strip_suffix(POST_FILE_EXTENSION)?;
|
||||
|
||||
if prefix.contains(is_invalid_char) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self(ShString22::new_from_str(prefix)))
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Deref for PostId {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::DerefMut for PostId {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for PostId {
|
||||
fn as_ref(&self) -> &str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AsMut<str> for PostId {
|
||||
fn as_mut(&mut self) -> &mut str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl borrow::Borrow<str> for PostId {
|
||||
fn borrow(&self) -> &str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl borrow::BorrowMut<str> for PostId {
|
||||
fn borrow_mut(&mut self) -> &mut str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PostId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&**self, f)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
id: PostId,
|
||||
title: String,
|
||||
author: String,
|
||||
html: Markup,
|
||||
tags: Vec<ShString22>,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub fn id_str(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &PostId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn author(&self) -> &str {
|
||||
&self.author
|
||||
}
|
||||
|
||||
pub fn html(&self) -> PreEscaped<&str> {
|
||||
PreEscaped(&self.html.0)
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> &[ShString22] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
pub fn created(&self) -> DateTime<Utc> {
|
||||
self.created
|
||||
}
|
||||
|
||||
pub fn updated(&self) -> DateTime<Utc> {
|
||||
self.updated
|
||||
}
|
||||
|
||||
pub fn parse(
|
||||
code_renderer: &CodeBlockRenderer,
|
||||
post_id: PostId,
|
||||
file_name: &str,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
source: &str,
|
||||
) -> Result<Self, ParseError>
|
||||
{
|
||||
let mdpost = MdPost::parse(file_name, source)?;
|
||||
Ok(Self::from_mdpost(post_id, code_renderer, created, updated, mdpost))
|
||||
}
|
||||
|
||||
fn from_mdpost(
|
||||
id: PostId,
|
||||
code_renderer: &CodeBlockRenderer,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
mdpost: MdPost,
|
||||
) -> Self
|
||||
{
|
||||
use pulldown_cmark::{Options, Parser, html::push_html};
|
||||
|
||||
const PARSER_OPTIONS: Options = Options::ENABLE_TABLES
|
||||
.union(Options::ENABLE_FOOTNOTES)
|
||||
.union(Options::ENABLE_STRIKETHROUGH);
|
||||
|
||||
let mut parser = PostMdParser::new(
|
||||
Parser::new_ext(&mdpost.markdown, PARSER_OPTIONS),
|
||||
code_renderer
|
||||
);
|
||||
|
||||
let mut html_buf = String::new();
|
||||
push_html(&mut html_buf, parser.by_ref());
|
||||
|
||||
Self {
|
||||
id,
|
||||
title: mdpost.title,
|
||||
author: mdpost.author,
|
||||
html: PreEscaped(html_buf),
|
||||
tags: mdpost.tags,
|
||||
created,
|
||||
updated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator struct which wraps another event iterator in order to render code blocks, collect the links
|
||||
/// encountered and generate a summary of the text content.
|
||||
struct PostMdParser<'p, I> {
|
||||
iter: I,
|
||||
code_renderer: &'p CodeBlockRenderer,
|
||||
links: Vec<String>,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
impl<'p, I> PostMdParser<'p, I> {
|
||||
fn new(iter: I, code_renderer: &'p CodeBlockRenderer) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
code_renderer,
|
||||
links: Vec::new(),
|
||||
summary: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'e, 'p, I> Iterator for PostMdParser<'p, I> where I: Iterator<Item = pulldown_cmark::Event<'e>> {
|
||||
type Item = pulldown_cmark::Event<'e>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
use pulldown_cmark::{CodeBlockKind, CowStr, Event, LinkType, Tag};
|
||||
|
||||
self.iter.next().map(|event| match event {
|
||||
// When we reach a code block, we want to collect the text content until the code block finishes
|
||||
// and have the `CodeBlockRenderer` render it
|
||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
|
||||
let mut code_buf = String::new();
|
||||
|
||||
for event in self.iter.by_ref() {
|
||||
match event {
|
||||
// The code block has finished, so break out of the loop
|
||||
Event::End(Tag::CodeBlock(_)) => break,
|
||||
// All text events until the end of the code block should be considered as code, so
|
||||
// add the text to the `code_buf` to be rendered later
|
||||
Event::Text(text) => code_buf.push_str(&text),
|
||||
// Ignore all other events
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
let highlighted = self.code_renderer.render(&lang, &code_buf);
|
||||
Event::Html(CowStr::Boxed(highlighted.into_string().into_boxed_str()))
|
||||
},
|
||||
|
||||
event => {
|
||||
match &event {
|
||||
Event::Start(Tag::Link(LinkType::Inline | LinkType::Autolink, destination, _title)) => {
|
||||
self.links.push(destination.clone().into_string());
|
||||
},
|
||||
|
||||
//TODO: better way of generating a summary
|
||||
Event::Text(text) => {
|
||||
if self.summary.is_empty() {
|
||||
self.summary = text.clone().into_string();
|
||||
}
|
||||
},
|
||||
|
||||
_ => (),
|
||||
}
|
||||
|
||||
event
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode)]
|
||||
struct HeaderNode {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
title: String,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
author: String,
|
||||
#[knuffel(children(name="tag"))]
|
||||
tags: Vec<TagNode>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode)]
|
||||
struct TagNode {
|
||||
#[knuffel(argument)]
|
||||
tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MdPost {
|
||||
markdown: String,
|
||||
title: String,
|
||||
author: String,
|
||||
tags: Vec<ShString22>,
|
||||
}
|
||||
|
||||
impl MdPost {
|
||||
fn parse(file_name: &str, source: &str) -> Result<Self, ParseError> {
|
||||
const END_OF_HEADER_DELIM: &str = "\n---\n";
|
||||
|
||||
let (header, md) = source.split_once(END_OF_HEADER_DELIM)
|
||||
.ok_or(ParseError::MissingHeader)?;
|
||||
|
||||
let header = knuffel::parse::<HeaderNode>(file_name, header)
|
||||
.map_err(|err| ParseError::InvalidHeader(Box::new(err)))?;
|
||||
|
||||
let md = md.trim_start();
|
||||
|
||||
Ok(Self {
|
||||
markdown: md.to_owned(),
|
||||
title: header.title,
|
||||
author: header.author,
|
||||
tags: header.tags.into_iter().map(|tag| tag.tag.into()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ParseError {
|
||||
MissingHeader,
|
||||
InvalidHeader(Box<knuffel::Error>),
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ParseError::MissingHeader => write!(f, "Post file has no header"),
|
||||
ParseError::InvalidHeader(err) => fmt::Display::fmt(err, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for ParseError {}
|
||||
@ -0,0 +1,159 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, hash_map, HashMap, HashSet},
|
||||
iter::FusedIterator,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::strings::ShString22;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
|
||||
use crate::post::{Post, PostId};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConcurrentPostsStore {
|
||||
inner: Arc<RwLock<PostsStore>>,
|
||||
}
|
||||
|
||||
impl ConcurrentPostsStore {
|
||||
pub fn new() -> Self {
|
||||
Self { inner: Arc::new(RwLock::new(PostsStore::new())) }
|
||||
}
|
||||
|
||||
pub async fn read(&self) -> RwLockReadGuard<'_, PostsStore> {
|
||||
self.inner.read().await
|
||||
}
|
||||
|
||||
pub fn write_blocking(&self) -> RwLockWriteGuard<'_, PostsStore> {
|
||||
self.inner.blocking_write()
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Option<Arc<Post>> {
|
||||
self.read().await.get(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConcurrentPostsStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostsStore {
|
||||
posts: HashMap<PostId, Arc<Post>>,
|
||||
created_ix: BTreeSet<CreatedIxEntry>,
|
||||
tags_ix: HashMap<ShString22, HashSet<PostId>>,
|
||||
}
|
||||
|
||||
// TODO: shrink the various collections on removal to deallocate unneeded space
|
||||
|
||||
impl PostsStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
posts: HashMap::new(),
|
||||
created_ix: BTreeSet::new(),
|
||||
tags_ix: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Arc<Post>> {
|
||||
self.posts.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, post: Post) -> Option<Arc<Post>> {
|
||||
let old_post = self.remove(post.id_str());
|
||||
|
||||
// Insert the post into each of the tag indexes.
|
||||
for tag in post.tags() {
|
||||
// First, get the existing `HashSet` for the tag, or create a new one if one does not
|
||||
// already exist. Then, insert the post's ID into the `HashSet`.
|
||||
match self.tags_ix.entry(tag.clone()) {
|
||||
hash_map::Entry::Occupied(entry) => entry.into_mut(),
|
||||
hash_map::Entry::Vacant(entry) => entry.insert(HashSet::new()),
|
||||
}.insert(post.id().clone());
|
||||
}
|
||||
|
||||
// Insert the post into the correct position of the created BTree index.
|
||||
self.created_ix.insert(CreatedIxEntry::new(&post));
|
||||
|
||||
// Wrap the post with an atomic reference counter and insert it into the main posts
|
||||
// `HashMap`.
|
||||
self.posts.insert(post.id().clone(), Arc::new(post));
|
||||
|
||||
old_post
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &str) -> Option<Arc<Post>> {
|
||||
match self.posts.remove(id) {
|
||||
Some(post) => {
|
||||
// Remove the post's entry in the created index.
|
||||
self.created_ix
|
||||
.remove(&CreatedIxEntry::new(&post));
|
||||
|
||||
// Remove every occurence of the post from the tags index.
|
||||
for tag in post.tags() {
|
||||
if let Some(tag_ix) = self.tags_ix.get_mut(tag) {
|
||||
tag_ix.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
Some(post)
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.tags_ix.clear();
|
||||
self.created_ix.clear();
|
||||
self.posts.clear();
|
||||
}
|
||||
|
||||
pub fn iter(&self)
|
||||
-> impl '_
|
||||
+ Iterator<Item = Arc<Post>>
|
||||
+ ExactSizeIterator
|
||||
+ FusedIterator
|
||||
+ Clone
|
||||
{
|
||||
self.posts.values().cloned()
|
||||
}
|
||||
|
||||
pub fn iter_by_created(&self)
|
||||
-> impl '_
|
||||
+ Iterator<Item = Arc<Post>>
|
||||
+ DoubleEndedIterator
|
||||
+ ExactSizeIterator
|
||||
+ FusedIterator
|
||||
+ Clone
|
||||
{
|
||||
// For each entry of the created index, look up the corresponding post in the posts map and
|
||||
// return the post. Every entry in the created index should contain the ID of a post in the
|
||||
// posts map, so the `expect` should never fail.
|
||||
self.created_ix
|
||||
.iter()
|
||||
.map(|entry| self.get(&entry.id)
|
||||
.expect("invalid entry in `created_ix` pointing to a post that does not exist"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PostsStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct CreatedIxEntry {
|
||||
created: DateTime<Utc>,
|
||||
id: PostId,
|
||||
}
|
||||
|
||||
impl CreatedIxEntry {
|
||||
fn new(post: &Post) -> Self {
|
||||
Self {
|
||||
created: post.created(),
|
||||
id: post.id().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
use std::{
|
||||
fmt,
|
||||
fs,
|
||||
io::{self, Read},
|
||||
path::PathBuf,
|
||||
sync::mpsc,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use notify::DebouncedEvent;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
use crate::codeblock::CodeBlockRenderer;
|
||||
use crate::post::{ParseError, Post, PostId};
|
||||
use crate::posts_store::ConcurrentPostsStore;
|
||||
|
||||
pub struct Renderer {
|
||||
posts: ConcurrentPostsStore,
|
||||
code_renderer: CodeBlockRenderer,
|
||||
posts_dir_path: PathBuf,
|
||||
rx: mpsc::Receiver<DebouncedEvent>,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn new(
|
||||
posts: ConcurrentPostsStore,
|
||||
code_renderer: CodeBlockRenderer,
|
||||
posts_dir_path: PathBuf,
|
||||
) -> (Self, mpsc::Sender<DebouncedEvent>)
|
||||
{
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
// Buffer a rescan event here so that it will be the first event received when
|
||||
// `handle_events` is called. This will cause the `Renderer` to perform an "initial scan"
|
||||
// of the post files.
|
||||
tx.send(DebouncedEvent::Rescan).unwrap();
|
||||
|
||||
(Self {
|
||||
posts,
|
||||
code_renderer,
|
||||
posts_dir_path,
|
||||
rx,
|
||||
}, tx)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn handle_events(self) {
|
||||
while let Ok(notify_event) = self.rx.recv() {
|
||||
let fs_event = match notify_event {
|
||||
// Convert create & write events for valid post file names to update events.
|
||||
DebouncedEvent::Create(path) | DebouncedEvent::Write(path) => {
|
||||
EventTarget::from_path(path)
|
||||
.map(Event::Update)
|
||||
},
|
||||
|
||||
// Convert remove events for valid post file names.
|
||||
DebouncedEvent::Remove(path) => {
|
||||
EventTarget::from_path(path)
|
||||
.map(Event::Remove)
|
||||
},
|
||||
|
||||
// Convert rename events depending on whether the old / new paths are valid post
|
||||
// file names.
|
||||
DebouncedEvent::Rename(old_path, new_path) => {
|
||||
match (EventTarget::from_path(old_path), EventTarget::from_path(new_path)) {
|
||||
(Some(old_target), Some(new_target)) => Some(Event::Rename(old_target, new_target)),
|
||||
(None, Some(new_target)) => Some(Event::Update(new_target)),
|
||||
(Some(old_target), None) => Some(Event::Remove(old_target)),
|
||||
(None, None) => None,
|
||||
}
|
||||
},
|
||||
|
||||
// Convert rescan events, where it is necessary to read the directory's contents.
|
||||
DebouncedEvent::Rescan => Some(Event::Scan),
|
||||
|
||||
// Ignore all other events.
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(fs_event) = fs_event {
|
||||
self.handle_event(&fs_event);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Filesystem events channel closed, exiting");
|
||||
}
|
||||
|
||||
fn handle_event(&self, event: &Event) {
|
||||
info!(event = ?event);
|
||||
match event {
|
||||
Event::Update(target) => self.update(target),
|
||||
Event::Rename(old_target, new_target) => self.rename(old_target, new_target),
|
||||
Event::Remove(target) => self.remove(target),
|
||||
Event::Scan => self.scan(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn update(&self, target: &EventTarget) {
|
||||
match self.parse_post_from_target(target) {
|
||||
Ok(post) => {
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.insert(post);
|
||||
},
|
||||
Err(err) => {
|
||||
err.log();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn rename(&self, old_target: &EventTarget, new_target: &EventTarget) {
|
||||
let post_res = self.parse_post_from_target(new_target);
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.remove(&old_target.id);
|
||||
match post_res {
|
||||
Ok(post) => {
|
||||
guard.insert(post);
|
||||
},
|
||||
Err(err) => {
|
||||
err.log();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn remove(&self, target: &EventTarget) {
|
||||
self.posts.write_blocking().remove(&target.id);
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn scan(&self) {
|
||||
let posts_dir = match fs::read_dir(&self.posts_dir_path) {
|
||||
Ok(posts_dir) => posts_dir,
|
||||
Err(err) => {
|
||||
Error::Io(Box::new(err)).log();
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let mut posts = Vec::new();
|
||||
|
||||
for dir_entry in posts_dir {
|
||||
let dir_entry = match dir_entry {
|
||||
Ok(dir_entry) => dir_entry,
|
||||
Err(err) => {
|
||||
Error::Io(Box::new(err)).log();
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(target) = EventTarget::from_path(dir_entry.path()) {
|
||||
posts.push(match self.parse_post_from_target(&target) {
|
||||
Ok(post) => post,
|
||||
Err(err) => {
|
||||
err.log();
|
||||
continue;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.clear();
|
||||
for post in posts {
|
||||
guard.insert(post);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_post_from_target(&self, target: &EventTarget) -> Result<Post, Error> {
|
||||
let mut fd = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&target.path)
|
||||
.map_err(|err| Error::Io(Box::new(err)))?;
|
||||
|
||||
let metadata = fd.metadata()
|
||||
.map_err(|err| Error::Io(Box::new(err)))?;
|
||||
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(Error::NotAFile);
|
||||
}
|
||||
|
||||
let (created, updated) = metadata.created()
|
||||
.and_then(|created| metadata.modified()
|
||||
.map(|modified| (DateTime::<Utc>::from(created), DateTime::<Utc>::from(modified))))
|
||||
.unwrap_or_else(|_| {
|
||||
let now = Utc::now();
|
||||
(now, now)
|
||||
});
|
||||
|
||||
let contents = {
|
||||
let mut buf = String::new();
|
||||
fd.read_to_string(&mut buf)
|
||||
.map_err(|err| Error::Io(Box::new(err)))?;
|
||||
buf
|
||||
};
|
||||
|
||||
drop(fd);
|
||||
|
||||
Post::parse(
|
||||
&self.code_renderer,
|
||||
target.id.clone(),
|
||||
&target.path.to_string_lossy(),
|
||||
created,
|
||||
updated,
|
||||
&contents
|
||||
).map_err(|err| Error::Parsing(Box::new(err)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Event {
|
||||
Update(EventTarget),
|
||||
Rename(EventTarget, EventTarget),
|
||||
Remove(EventTarget),
|
||||
Scan,
|
||||
}
|
||||
|
||||
struct EventTarget {
|
||||
pub path: PathBuf,
|
||||
pub id: PostId,
|
||||
}
|
||||
|
||||
impl fmt::Debug for EventTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}@{}", self.id, self.path.to_string_lossy())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventTarget {
|
||||
pub fn from_path(path: PathBuf) -> Option<Self> {
|
||||
path.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.and_then(PostId::from_file_name)
|
||||
.map(|id| Self {
|
||||
path,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Error {
|
||||
Io(Box<io::Error>),
|
||||
NotAFile,
|
||||
Parsing(Box<ParseError>),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn log(&self) {
|
||||
match self {
|
||||
Error::Io(err) => {
|
||||
error!(error = %err, "IO error while processing event");
|
||||
},
|
||||
Error::NotAFile => {
|
||||
warn!("Event target is not a regular file");
|
||||
},
|
||||
Error::Parsing(err) => {
|
||||
warn!(error = %err, "Parsing error while processing event");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,344 @@
|
||||
.cb_code {
|
||||
color: #323232;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.cb_comment {
|
||||
color: #969896;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_string {
|
||||
color: #183691;
|
||||
}
|
||||
|
||||
.cb_regexp-operator {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.cb_string.cb_regexp.cb_characterclass .cb_punctuation.cb_definition.cb_string.cb_begin,
|
||||
.cb_string.cb_regexp.cb_characterclass .cb_punctuation.cb_definition.cb_string.cb_end {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.cb_constant.cb_numeric {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_constant.cb_language {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_constant.cb_character,
|
||||
.cb_constant.cb_other,
|
||||
.cb_variable.cb_other.cb_constant {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_variable {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_keyword {
|
||||
color: #a71d5d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_bitwise-operator {
|
||||
color: #a71d5d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_storage {
|
||||
color: #a71d5d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_storage.cb_type {
|
||||
color: #a71d5d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_class {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_entity.cb_other.cb_inherited-class {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_function {
|
||||
color: #795da3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_variable.cb_parameter {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_tag {
|
||||
color: #63a35c;
|
||||
}
|
||||
|
||||
.cb_entity.cb_other.cb_attribute-name {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.cb_support.cb_function {
|
||||
color: #62a35c;
|
||||
}
|
||||
|
||||
.cb_support.cb_constant {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_support.cb_type,
|
||||
.cb_support.cb_class {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_support.cb_other.cb_variable {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_invalid,
|
||||
.cb_invalid.cb_illegal,
|
||||
.cb_invalid.cb_deprecated {
|
||||
color: #b52a1d;
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_filename.cb_find-in-files {
|
||||
color: #323232;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_constant.cb_numeric.cb_line-number.cb_find-in-files,
|
||||
.cb_constant.cb_numeric.cb_line-number.cb_match.cb_find-in-files {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.cb_meta.cb_diff.cb_header {
|
||||
color: #969896;
|
||||
background-color: #ffffff;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_meta.cb_diff.cb_header .cb_punctuation.cb_definition.cb_from-file.cb_diff {
|
||||
color: #bd2c00;
|
||||
background-color: #ffecec;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_meta.cb_diff.cb_header .cb_punctuation.cb_definition.cb_to-file.cb_diff {
|
||||
color: #55a532;
|
||||
background-color: #eaffea;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_meta.cb_diff.cb_range {
|
||||
color: #969896;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_markup.cb_deleted {
|
||||
background-color: #ffecec;
|
||||
}
|
||||
|
||||
.cb_markup.cb_deleted .cb_punctuation.cb_definition.cb_inserted {
|
||||
color: #bd2c00;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_markup.cb_inserted {
|
||||
background-color: #eaffea;
|
||||
}
|
||||
|
||||
.cb_markup.cb_inserted .cb_punctuation.cb_definition.cb_inserted {
|
||||
color: #55a532;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_markup.cb_deleted.cb_git_gutter {
|
||||
color: #bd2c00;
|
||||
}
|
||||
|
||||
.cb_markup.cb_inserted.cb_git_gutter {
|
||||
color: #55a532;
|
||||
}
|
||||
|
||||
.cb_markup.cb_changed.cb_git_gutter {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_markup.cb_ignored.cb_git_gutter {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.cb_markup.cb_untracked.cb_git_gutter {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.cb_source.cb_css .cb_punctuation.cb_definition.cb_entity {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_source.cb_css .cb_entity.cb_other.cb_attribute-name.cb_pseudo-class,
|
||||
.cb_source.cb_css .cb_entity.cb_other.cb_attribute-name.cb_pseudo-element {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.cb_source.cb_css .cb_meta.cb_value,
|
||||
.cb_source.cb_css .cb_support.cb_constant,
|
||||
.cb_source.cb_css .cb_support.cb_function {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_source.cb_css .cb_constant.cb_other.cb_color {
|
||||
color: #ed6a43;
|
||||
}
|
||||
|
||||
.cb_source.cb_scss .cb_punctuation.cb_definition.cb_entity {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_source.cb_scss .cb_entity.cb_other.cb_attribute-name.cb_pseudo-class,
|
||||
.cb_source.cb_scss .cb_entity.cb_other.cb_attribute-name.cb_pseudo-element {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.cb_source.cb_scss .cb_support.cb_constant.cb_property-value,
|
||||
.cb_source.cb_scss .cb_support.cb_function {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_source.cb_scss .cb_variable {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.cb_variable.cb_language.cb_this.cb_js {
|
||||
color: #ed6a43;
|
||||
}
|
||||
|
||||
.cb_source.cb_js .cb_entity.cb_name.cb_function {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_source.cb_js .cb_meta.cb_function .cb_entity.cb_name.cb_function,
|
||||
.cb_source.cb_js .cb_entity.cb_name.cb_function .cb_meta.cb_function {
|
||||
color: #795da3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_type.cb_new.cb_js {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.cb_variable.cb_language.cb_prototype.cb_js {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_source.cb_js .cb_support.cb_function {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.cb_support.cb_type.cb_object.cb_console.cb_js {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.cb_source.cb_python .cb_keyword {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_source.cb_python .cb_storage {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_source.cb_python .cb_storage.cb_type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_source.cb_python .cb_entity.cb_name.cb_function {
|
||||
color: #323232;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_source.cb_php .cb_entity.cb_name.cb_type.cb_class {
|
||||
color: #323232;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_variable.cb_language.cb_ruby {
|
||||
color: #ed6a43;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_type.cb_module.cb_ruby {
|
||||
color: #795da3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_entity.cb_name.cb_type.cb_class.cb_ruby {
|
||||
color: #795da3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_entity.cb_other.cb_inherited-class.cb_ruby {
|
||||
color: #795da3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_punctuation.cb_definition {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_meta.cb_separator {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_heading {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_raw.cb_block {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_raw.cb_inline {
|
||||
color: #323232;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_meta.cb_link,
|
||||
.cb_text.cb_html.cb_markdown .cb_meta.cb_image {
|
||||
color: #4183c4;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_underline.cb_link,
|
||||
.cb_text.cb_html.cb_markdown .cb_constant.cb_other.cb_reference {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_list {
|
||||
color: #ed6a43;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_bold .cb_markup.cb_italic {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cb_text.cb_html.cb_markdown .cb_markup.cb_italic .cb_markup.cb_bold {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "css_gen"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
syntect = "4"
|
||||
@ -0,0 +1,30 @@
|
||||
use std::env;
|
||||
use std::process;
|
||||
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
|
||||
|
||||
const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "cb_" };
|
||||
|
||||
fn main() {
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
let theme_name = env::args().nth(1).unwrap_or_else(|| {
|
||||
eprintln!("No theme specified");
|
||||
eprint_available_themes(&theme_set);
|
||||
process::exit(1)
|
||||
});
|
||||
let theme = theme_set.themes.get(&theme_name).unwrap_or_else(|| {
|
||||
eprintln!("Theme not found: {}", theme_name);
|
||||
eprint_available_themes(&theme_set);
|
||||
process::exit(1)
|
||||
});
|
||||
let css = css_for_theme_with_class_style(theme, CLASS_STYLE);
|
||||
println!("{}", css);
|
||||
}
|
||||
|
||||
fn eprint_available_themes(theme_set: &ThemeSet) {
|
||||
eprintln!("Available themes:");
|
||||
for key in theme_set.themes.keys() {
|
||||
eprintln!(" {}", key);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue