Skip to main content

markdown_live_preview/
http_server.rs

1use axum::{
2    Json, Router,
3    extract::{
4        State, WebSocketUpgrade,
5        ws::{Message, WebSocket},
6    },
7    response::{Html, IntoResponse},
8    routing::get,
9};
10use comrak::markdown_to_html;
11use futures::{SinkExt, StreamExt};
12use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
13use tokio_stream::wrappers::UnboundedReceiverStream;
14
15use crate::{SharedState, messages::OutgoingMessage};
16const GITHUB_MARKDOWN_CSS: &str = include_str!("../assets/github-markdown-dark.css");
17
18pub async fn run_http_server(state: SharedState) -> anyhow::Result<()> {
19    let app = build_router(state);
20
21    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
22    println!("🌐 Serving preview at http://localhost:3000");
23
24    if webbrowser::open("http://localhost:3000").is_ok() {
25        println!("🚀 Browser launched");
26    }
27
28    axum::serve(listener, app).await?;
29
30    Ok(())
31}
32
33pub fn build_router(state: SharedState) -> Router {
34    Router::new()
35        .route("/", get(serve_preview))
36        .route("/ws", get(ws_handler))
37        .route("/messages", get(get_state))
38        .with_state(state)
39}
40async fn ws_handler(ws: WebSocketUpgrade, State(state): State<SharedState>) -> impl IntoResponse {
41    ws.on_upgrade(move |socket| handle_socket(socket, state))
42}
43
44async fn handle_socket(ws: WebSocket, state: SharedState) {
45    let (tx, rx): (UnboundedSender<Message>, UnboundedReceiver<Message>) =
46        tokio::sync::mpsc::unbounded_channel();
47    let rx = UnboundedReceiverStream::new(rx);
48
49    let (mut sender, mut _receiver) = ws.split();
50
51    let send_task = tokio::spawn(async move {
52        tokio::pin!(rx);
53        while let Some(msg) = rx.next().await {
54            if sender.send(msg).await.is_err() {
55                break;
56            }
57        }
58    });
59
60    // Register this sender in the shared state
61
62    {
63        let mut state = state.write().await;
64        state.ws_clients.push(tx);
65    }
66
67    // Optionally: handle incoming message here (not needed in our case)
68
69    send_task.await.ok();
70}
71async fn serve_preview(State(state): State<SharedState>) -> impl IntoResponse {
72    let state = state.read().await;
73
74    let mut options = comrak::Options::default();
75    options.extension.alerts = true;
76    options.extension.table = true;
77    options.extension.autolink = true;
78    options.extension.tasklist = true;
79    options.extension.superscript = true;
80    options.extension.footnotes = true;
81    options.extension.description_lists = true;
82    options.extension.front_matter_delimiter = Some("---".into());
83    options.extension.alerts = true;
84    options.render.unsafe_ = true;
85    let lines = inject_cursor(state.content.clone(), state.cursor);
86    let html = markdown_to_html(&lines.join("\n"), &options);
87
88    let live_js = include_str!("../static/live.js");
89
90    let full = format!(
91        r#"<!DOCTYPE html>
92<html>
93<head>
94  <meta charset="UTF-8">
95  <title>Live Preview</title>
96  <style>{GITHUB_MARKDOWN_CSS}</style>
97  <style>
98    body {{
99      margin: 2rem auto;
100      max-width: 800px;
101      padding: 0 1rem;
102    }}
103  </style>
104  <script src="https://unpkg.com/morphdom@2.7.5/dist/morphdom-umd.min.js"></script>
105  <script>
106  {live_js}</script>
107</head>
108<body class="markdown-body">
109<div id="content">
110  {html}
111  </div>
112</body>
113</html>"#
114    );
115    Html(full)
116}
117
118async fn get_state(
119    axum::extract::State(state): axum::extract::State<SharedState>,
120) -> impl IntoResponse {
121    let messages = {
122        let state = state.read().await;
123        state.messages.clone()
124    };
125    Json(messages)
126}
127fn inject_cursor(mut lines: Vec<String>, cursor: (usize, usize)) -> Vec<String> {
128    use html_escape::encode_text;
129
130    if cursor.0 < lines.len() {
131        let line = &mut lines[cursor.0];
132        if cursor.1 <= line.len() {
133            let (before, after) = line.split_at(cursor.1);
134
135            let (cursor_html, rest) = if let Some(cursor_char) = after.chars().next() {
136                let char_len = cursor_char.len_utf8();
137                (
138                    format!(
139                        "<span id=\"cursor\" class=\"cursor-position\">{}</span>",
140                        encode_text(&cursor_char.to_string())
141                    ),
142                    &after[char_len..],
143                )
144            } else {
145                (
146                    r#"<span id="cursor" class="cursor-position"> </span>"#.to_string(),
147                    "",
148                )
149            };
150
151            *line = format!(
152                "{}{}{}",
153                encode_text(before),
154                cursor_html,
155                encode_text(rest)
156            );
157        }
158    } else {
159        // Handle case where cursor is on a new line beyond existing lines
160        lines.push(r#"<span id="cursor" class="cursor-position"> </span>"#.to_string());
161    }
162
163    lines
164}
165
166pub async fn render_and_broadcast(state: &SharedState) {
167    let state_guard = state.read().await;
168
169    let mut options = comrak::Options::default();
170    options.extension.alerts = true;
171    options.extension.table = true;
172    options.extension.autolink = true;
173    options.extension.tasklist = true;
174    options.extension.superscript = true;
175    options.extension.footnotes = true;
176    options.extension.description_lists = true;
177    options.extension.front_matter_delimiter = Some("---".into());
178    options.render.unsafe_ = true;
179
180    let lines = inject_cursor(state_guard.content.clone(), state_guard.cursor);
181    let html = markdown_to_html(&lines.join("\n"), &options);
182
183    let wrapped = format!(r#"<div id="content">{html}</div>"#);
184    let msg = OutgoingMessage::FullRender { html: wrapped };
185    let json = match serde_json::to_string(&msg) {
186        Ok(j) => j,
187        Err(e) => {
188            eprintln!("❌ Failed to serialize message: {e}");
189            return;
190        }
191    };
192
193    for client in &state_guard.ws_clients {
194        let _ = client.send(axum::extract::ws::Message::Text(json.clone().into()));
195    }
196}