Touch up the mobile view, add syntax highlighting for markdown

This commit is contained in:
mrkmntal 2026-09-28 18:37:13 -04:00
commit 16450f4c1c
11 changed files with 863 additions and 57 deletions

View file

@ -3,6 +3,7 @@ mod notes;
mod pages;
mod routes;
mod storage;
mod syntax;
mod themes;
use axum::Router;
@ -30,6 +31,9 @@ async fn main() {
// Ensure notes directory exists
std::fs::create_dir_all("notes").expect("Failed to create notes directory");
// Eagerly initialize syntax highlighting state so the first request isn't slow.
let _ = syntax::syntax_state();
let app = Router::new()
.merge(routes::router())
.nest_service("/static", ServeDir::new("static"));

View file

@ -1,6 +1,9 @@
use pulldown_cmark::{html, Options, Parser};
use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
pub fn render_markdown(input: &str) -> String {
use crate::syntax::{highlight_code, syntax_state};
use crate::themes::Theme;
pub fn render_markdown(input: &str, theme: &Theme) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_TASKLISTS);
@ -8,19 +11,50 @@ pub fn render_markdown(input: &str) -> String {
options.insert(Options::ENABLE_FOOTNOTES);
let parser = Parser::new_ext(input, options);
let mut events: Vec<Event<'_>> = Vec::new();
let mut parser = parser.peekable();
while let Some(event) = parser.next() {
if let Event::Start(Tag::CodeBlock(kind)) = event {
let lang = match kind {
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
CodeBlockKind::Indented => None,
};
let mut code = String::new();
loop {
match parser.next() {
Some(Event::Text(text)) => code.push_str(&text),
Some(Event::End(TagEnd::CodeBlock)) => break,
Some(other) => {
// Defensive: if we see something unexpected, push it through.
events.push(other);
}
None => break,
}
}
let highlighted = highlight_code(&code, lang.as_deref(), theme, syntax_state());
events.push(Event::Html(highlighted.into()));
} else {
events.push(event);
}
}
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html::push_html(&mut html_output, events.into_iter());
html_output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::themes::get_theme;
#[test]
fn renders_headings_and_paragraphs() {
let md = "# Hello\n\nThis is a paragraph.";
let html = render_markdown(md);
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<p>This is a paragraph.</p>"));
}
@ -28,7 +62,7 @@ mod tests {
#[test]
fn renders_tasklists() {
let md = "- [x] Done\n- [ ] Todo";
let html = render_markdown(md);
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<input"));
assert!(html.contains("checked"));
}
@ -36,8 +70,27 @@ mod tests {
#[test]
fn renders_tables() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let html = render_markdown(md);
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<table>"));
assert!(html.contains("<th>A</th>"));
}
#[test]
fn highlights_rust_code_blocks() {
let md = "```rust\nfn main() {\n println!(\"hello\");\n}\n```";
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<pre"));
assert!(html.contains("fn"));
assert!(html.contains("main"));
// Syntect emits inline style spans for highlighted tokens.
assert!(html.contains("style=\"color:"));
}
#[test]
fn falls_back_for_unknown_language() {
let md = "```notareallanguage\nsome code\n```";
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("some code"));
assert!(html.contains("<pre"));
}
}

View file

@ -14,15 +14,23 @@ pub fn render_app(
let theme = get_theme(theme_name);
let css = theme.to_css_block();
let rendered_preview = current_note
.map(|n| render_markdown(&n.content))
.map(|n| render_markdown(&n.content, theme))
.unwrap_or_default();
// Mobile lands on the note list when there is no note selected,
// otherwise on the editor. Server-rendered so it works without JS.
let default_tab = if current_note.is_some() {
"write"
} else {
"notes"
};
html! {
(DOCTYPE)
html lang="en" data-theme=(theme_name) {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover";
title { "Rasuti Notes" }
style { (PreEscaped(css)) }
style { (PreEscaped(include_str!("../static/style.css"))) }
@ -34,10 +42,11 @@ pub fn render_app(
@if let Some(msg) = message {
div class="flash-message" { (msg) }
}
(render_tab_bar(default_tab))
main class="app-body" {
(render_sidebar(notes, current_note))
(render_editor(current_note))
(render_preview(&rendered_preview))
(render_sidebar(notes, current_note, default_tab))
(render_editor(current_note, default_tab))
(render_preview(&rendered_preview, default_tab))
}
(render_status_bar(notes.len(), theme_name))
script {
@ -57,11 +66,44 @@ fn escape_js(s: &str) -> String {
.replace('\r', "\\r")
}
fn render_tab_bar(default_tab: &str) -> Markup {
html! {
nav class="tab-bar" {
button type="button" class="tab-button" data-tab-target="notes"
x-on:click="setTab('notes')"
x-bind:class="{ 'active': tab === 'notes' }" {
span class="tab-icon" { "\u{1F4C3}" }
span { "Notes" }
}
button type="button" class="tab-button" data-tab-target="write"
x-on:click="setTab('write')"
x-bind:class="{ 'active': tab === 'write' }" {
span class="tab-icon" { "\u{270E}" }
span { "Write" }
}
button type="button" class="tab-button" data-tab-target="preview"
x-on:click="setTab('preview')"
x-bind:class="{ 'active': tab === 'preview' }" {
span class="tab-icon" { "\u{1F4C4}" }
span { "Preview" }
}
script {
(PreEscaped(format!(
"window.__DEFAULT_TAB__ = \"{}\";",
default_tab
)))
}
}
}
}
fn render_menu_bar() -> Markup {
html! {
nav class="menu-bar" {
ul class="menu-list" {
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('file')"
x-bind:class="{ 'open': openMenu === 'file' }" {
span { "File" }
ul class="menu-dropdown" {
li {
@ -75,21 +117,27 @@ fn render_menu_bar() -> Markup {
}
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('edit')"
x-bind:class="{ 'open': openMenu === 'edit' }" {
span { "Edit" }
ul class="menu-dropdown" {
li { button x-on:click="focusEditor()" { "Focus Editor" } }
li { button x-on:click="saveCurrentNote()" { "Save Note" } }
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('view')"
x-bind:class="{ 'open': openMenu === 'view' }" {
span { "View" }
ul class="menu-dropdown" {
li { a href="/" { "Show All Notes" } }
li { button x-on:click="togglePreview()" { "Toggle Preview" } }
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('theme')"
x-bind:class="{ 'open': openMenu === 'theme' }" {
span { "Theme" }
ul class="menu-dropdown" {
@for theme in PREDEFINED_THEMES {
@ -102,7 +150,9 @@ fn render_menu_bar() -> Markup {
}
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('help')"
x-bind:class="{ 'open': openMenu === 'help' }" {
span { "Help" }
ul class="menu-dropdown" {
li { a href="/about" { "About Rasuti Notes" } }
@ -154,9 +204,15 @@ fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name:
}
}
fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
fn render_sidebar(notes: &[Note], current_note: Option<&Note>, default_tab: &str) -> Markup {
let active = if default_tab == "notes" {
" mobile-active"
} else {
""
};
html! {
aside class="sidebar" {
aside class={"sidebar" (active)} data-tab-pane="notes"
x-bind:class="{ 'mobile-active': tab === 'notes' }" {
div class="sidebar-header" { "Notes" }
ul class="note-list" {
@if notes.is_empty() {
@ -179,9 +235,16 @@ fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
}
}
fn render_editor(current_note: Option<&Note>) -> Markup {
fn render_editor(current_note: Option<&Note>, default_tab: &str) -> Markup {
let active = if default_tab == "write" {
" mobile-active"
} else {
""
};
html! {
section class="editor-pane" x-data="{ saving: false }" {
section class={"editor-pane" (active)} data-tab-pane="write"
x-data="{ saving: false }"
x-bind:class="{ 'mobile-active': tab === 'write' }" {
@if let Some(note) = current_note {
form
id="note-form"
@ -220,9 +283,15 @@ fn render_editor(current_note: Option<&Note>) -> Markup {
}
}
fn render_preview(html_content: &str) -> Markup {
fn render_preview(html_content: &str, default_tab: &str) -> Markup {
let active = if default_tab == "preview" {
" mobile-active"
} else {
""
};
html! {
section class="preview-pane" x-bind:class="{ 'hidden': !previewVisible }" {
section class={"preview-pane" (active)} data-tab-pane="preview"
x-bind:class="{ 'hidden': !previewVisible, 'mobile-active': tab === 'preview' }" {
div id="preview" class="markdown-body" x-html="previewHtml" {
(PreEscaped(html_content))
}

View file

@ -6,18 +6,12 @@ use axum::{
Router,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::markdown::render_markdown;
use crate::notes::{self, Note};
use crate::pages;
use crate::themes::get_theme;
#[derive(Clone)]
pub struct AppState {
// Could hold shared config in the future
}
pub fn router() -> Router {
Router::new()
.route("/", get(index))
@ -30,7 +24,6 @@ pub fn router() -> Router {
.route("/theme", post(theme_route))
.route("/about", get(about_route))
.route("/export", get(export_route))
.with_state(Arc::new(AppState {}))
}
fn get_theme_from_cookie(headers: &HeaderMap) -> String {
@ -143,8 +136,13 @@ struct PreviewForm {
content: String,
}
async fn preview_route(Form(form): Form<PreviewForm>) -> Result<impl IntoResponse, AppError> {
let html = render_markdown(&form.content);
async fn preview_route(
headers: HeaderMap,
Form(form): Form<PreviewForm>,
) -> Result<impl IntoResponse, AppError> {
let theme_name = get_theme_from_cookie(&headers);
let theme = get_theme(&theme_name);
let html = render_markdown(&form.content, theme);
Ok(Html(pages::render_preview_fragment(&html).into_string()))
}

121
src/syntax.rs Normal file
View file

@ -0,0 +1,121 @@
use crate::themes::{Theme, PREDEFINED_THEMES};
use std::collections::HashMap;
use std::sync::OnceLock;
use syntect::highlighting::{Theme as SyntectTheme, ThemeSet};
use syntect::html::highlighted_html_for_string;
use syntect::parsing::SyntaxSet;
pub struct SyntaxState {
pub syntax_set: SyntaxSet,
pub themes: HashMap<String, SyntectTheme>,
}
impl SyntaxState {
pub fn new() -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
let mut themes = HashMap::new();
for theme in PREDEFINED_THEMES {
let tmtheme = generate_tmtheme(theme);
let syntect_theme = ThemeSet::load_from_reader(&mut std::io::Cursor::new(tmtheme))
.unwrap_or_else(|e| {
eprintln!("Failed to load generated theme for {}: {}", theme.name, e);
ThemeSet::load_defaults().themes["InspiredGitHub"].clone()
});
themes.insert(theme.name.to_string(), syntect_theme);
}
Self { syntax_set, themes }
}
}
pub fn highlight_code(
code: &str,
lang: Option<&str>,
theme: &Theme,
state: &SyntaxState,
) -> String {
let syntect_theme = state
.themes
.get(theme.name)
.expect("Syntect theme should exist for every Rasuti theme");
let syntax = match lang {
Some(lang) => state.syntax_set.find_syntax_by_token(lang),
None => None,
}
.unwrap_or_else(|| state.syntax_set.find_syntax_plain_text());
highlighted_html_for_string(code, &state.syntax_set, syntax, syntect_theme)
.unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", html_escape(code)))
}
static SYNTAX_STATE: OnceLock<SyntaxState> = OnceLock::new();
pub fn syntax_state() -> &'static SyntaxState {
SYNTAX_STATE.get_or_init(SyntaxState::new)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
let scopes = [
("comment", theme.ansi_bright[0]),
("string", theme.ansi_bright[2]),
("constant.numeric", theme.ansi_bright[3]),
("constant", theme.ansi_bright[3]),
("keyword", theme.ansi_bright[5]),
("storage", theme.ansi_bright[5]),
("entity.name.function", theme.ansi_bright[4]),
("support.function", theme.ansi_bright[4]),
("entity.name.type", theme.ansi_bright[6]),
("variable", theme.ansi_bright[1]),
("invalid", theme.ansi_bright[1]),
];
let mut scope_entries = String::new();
for (scope, color) in scopes {
scope_entries.push_str(&format!(
" <dict>\n\
<key>name</key>\n\
<string>{scope}</string>\n\
<key>scope</key>\n\
<string>{scope}</string>\n\
<key>settings</key>\n\
<dict>\n\
<key>foreground</key>\n\
<string>{color}</string>\n\
</dict>\n\
</dict>\n"
));
}
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
<key>name</key>\n\
<string>Rasuti {}</string>\n\
<key>settings</key>\n\
<array>\n\
<dict>\n\
<key>settings</key>\n\
<dict>\n\
<key>background</key>\n\
<string>{}</string>\n\
<key>foreground</key>\n\
<string>{}</string>\n\
</dict>\n\
</dict>\n\
{}\
</array>\n\
</dict>\n\
</plist>\n",
theme.name, theme.bg, theme.fg, scope_entries
)
}

View file

@ -227,7 +227,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -238,7 +238,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -249,7 +249,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -260,7 +260,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -271,7 +271,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -395,11 +395,13 @@ mod tests {
#[test]
fn dark_themes_use_readable_button_colors() {
// Themes with very dark bright-blue colors should fall back to the
// theme foreground color so primary buttons remain readable.
// Bright accent colors should be luminous enough to use as button
// backgrounds; very dark accents fall back to the theme foreground.
let cyan = get_theme("Cyan on Black");
let (bg, fg) = cyan.button_colors(4);
assert_eq!(bg, cyan.fg);
// After replacing #0000ff with #a9a9f9, the bright blue is readable
// and no longer falls back to the foreground color.
assert_eq!(bg, cyan.ansi_bright[4]);
assert_eq!(fg, cyan.bg);
}