markdown_live_preview/
http_server.rs1use 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 {
63 let mut state = state.write().await;
64 state.ws_clients.push(tx);
65 }
66
67 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 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}