Compare commits

2 Commits

Author SHA1 Message Date
Gato 7d2f56a364 refactor: module infrastructure avec comrak_renderer, file_repository, notify_watcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:06:43 +02:00
Gato ec239bb42e feat: module domain avec trait MarkdownRenderer et RenderOptions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:01:41 +02:00
12 changed files with 196 additions and 106 deletions
+14 -42
View File
@@ -1,54 +1,25 @@
use comrak::plugins::syntect::SyntectAdapterBuilder;
use comrak::{markdown_to_html_with_plugins, Options, Plugins};
use std::fs;
use crate::domain::MarkdownRenderer;
use crate::infrastructure::comrak_renderer::{ComrakPreviewRenderer, ComrakRenderer};
use crate::infrastructure::file_repository;
use std::path::Path;
pub fn render_markdown(content: String) -> String {
let adapter = SyntectAdapterBuilder::new()
.theme("base16-ocean.dark")
.build();
let options = Options::default();
let mut plugins = Plugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adapter);
markdown_to_html_with_plugins(&content, &options, &plugins)
ComrakPreviewRenderer {
theme: "base16-ocean.dark".to_string(),
}
.render(&content)
}
pub fn convert_file(path: String) -> Result<String, String> {
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
let adapter = SyntectAdapterBuilder::new()
.theme("InspiredGitHub")
.build();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
let mut plugins = Plugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adapter);
Ok(markdown_to_html_with_plugins(&content, &options, &plugins))
let content = file_repository::read_file(&path)?;
Ok(ComrakRenderer {
theme: "InspiredGitHub".to_string(),
}
.render(&content))
}
pub(crate) fn collect_md_files(dir: &Path, result: &mut Vec<String>) -> std::io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
collect_md_files(&path, result)?;
} else if path.extension().is_some_and(|e| e == "md") {
if let Some(s) = path.to_str() {
result.push(s.to_string());
}
}
}
Ok(())
file_repository::collect_md_files(dir, result)
}
pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
@@ -65,6 +36,7 @@ pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn tmpdir(name: &str) -> PathBuf {
+27
View File
@@ -0,0 +1,27 @@
#[allow(dead_code)]
pub trait MarkdownRenderer: Send + Sync {
fn render(&self, content: &str) -> String;
}
#[derive(Default)]
#[allow(dead_code)]
pub struct RenderOptions {
pub tables: bool,
pub strikethrough: bool,
pub autolink: bool,
pub tasklist: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_options_default_all_false() {
let opts = RenderOptions::default();
assert!(!opts.tables);
assert!(!opts.strikethrough);
assert!(!opts.autolink);
assert!(!opts.tasklist);
}
}
+4
View File
@@ -0,0 +1,4 @@
mod markdown;
#[allow(unused_imports)]
pub use markdown::{MarkdownRenderer, RenderOptions};
@@ -0,0 +1,36 @@
use comrak::plugins::syntect::SyntectAdapterBuilder;
use comrak::{markdown_to_html_with_plugins, Options, Plugins};
use crate::domain::MarkdownRenderer;
pub struct ComrakRenderer {
pub theme: String,
}
pub struct ComrakPreviewRenderer {
pub theme: String,
}
impl MarkdownRenderer for ComrakRenderer {
fn render(&self, content: &str) -> String {
let adapter = SyntectAdapterBuilder::new().theme(&self.theme).build();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
let mut plugins = Plugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adapter);
markdown_to_html_with_plugins(content, &options, &plugins)
}
}
impl MarkdownRenderer for ComrakPreviewRenderer {
fn render(&self, content: &str) -> String {
let adapter = SyntectAdapterBuilder::new().theme(&self.theme).build();
let options = Options::default();
let mut plugins = Plugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adapter);
markdown_to_html_with_plugins(content, &options, &plugins)
}
}
@@ -0,0 +1,25 @@
use std::fs;
use std::path::Path;
pub fn collect_md_files(dir: &Path, result: &mut Vec<String>) -> std::io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
collect_md_files(&path, result)?;
} else if path.extension().is_some_and(|e| e == "md") {
if let Some(s) = path.to_str() {
result.push(s.to_string());
}
}
}
Ok(())
}
pub fn read_file(path: &str) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| e.to_string())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod comrak_renderer;
pub mod file_repository;
pub mod notify_watcher;
@@ -0,0 +1,66 @@
use notify::EventKind;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::RecvTimeoutError;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
#[derive(Serialize, Clone)]
struct FileChangedPayload {
path: String,
}
pub fn run_debounce_loop(
rx: std::sync::mpsc::Receiver<notify::Result<notify::Event>>,
app: AppHandle,
) {
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
loop {
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Ok(event)) => {
if matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
for p in event.paths {
if is_relevant_path(&p) {
pending.insert(p, Instant::now());
}
}
}
}
Ok(Err(_)) => {}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
let now = Instant::now();
let ready: Vec<PathBuf> = pending
.iter()
.filter(|(_, t)| now.duration_since(**t) >= Duration::from_millis(80))
.map(|(p, _)| p.clone())
.collect();
for p in ready {
pending.remove(&p);
let _ = app.emit(
"file-changed",
FileChangedPayload {
path: p.to_string_lossy().to_string(),
},
);
}
}
}
fn is_relevant_path(path: &Path) -> bool {
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
return false;
}
path.extension().is_some_and(|e| e == "md")
}
+2
View File
@@ -1,4 +1,6 @@
mod core;
mod domain;
mod infrastructure;
mod watcher;
use std::sync::Mutex;
+6 -61
View File
@@ -1,29 +1,12 @@
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::RecvTimeoutError;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, State};
use crate::infrastructure::notify_watcher::run_debounce_loop;
pub struct WatcherState(pub Mutex<Option<RecommendedWatcher>>);
#[derive(Serialize, Clone)]
struct FileChangedPayload {
path: String,
}
fn is_relevant_path(path: &Path) -> bool {
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
return false;
}
path.extension().is_some_and(|e| e == "md")
}
#[tauri::command]
pub fn start_watch(
app: AppHandle,
@@ -54,46 +37,8 @@ pub fn start_watch(
.watch(&watch_path, mode)
.map_err(|e| e.to_string())?;
// Debounce thread: collects events per path, emits after 80ms of silence
std::thread::spawn(move || {
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
loop {
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Ok(event)) => {
if matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
for p in event.paths {
if is_relevant_path(&p) {
pending.insert(p, Instant::now());
}
}
}
}
Ok(Err(_)) => {}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
let now = Instant::now();
let ready: Vec<PathBuf> = pending
.iter()
.filter(|(_, t)| now.duration_since(**t) >= Duration::from_millis(80))
.map(|(p, _)| p.clone())
.collect();
for p in ready {
pending.remove(&p);
let _ = app.emit(
"file-changed",
FileChangedPayload {
path: p.to_string_lossy().to_string(),
},
);
}
}
run_debounce_loop(rx, app);
});
let mut guard = state.0.lock().unwrap();
+8 -2
View File
@@ -40,7 +40,9 @@
<!-- contenu généré par JS -->
</div>
<button id="btn-customize-css" class="btn-customize-css">
<span class="btn-customize-css__icon"></span>
<svg class="btn-customize-css__icon" width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 8.5H15.51M10.5 7.5H10.51M7.5 11.5H7.51M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.6569 19.6569 15 18 15H17.4C17.0284 15 16.8426 15 16.6871 15.0246C15.8313 15.1602 15.1602 15.8313 15.0246 16.6871C15 16.8426 15 17.0284 15 17.4V18C15 19.6569 13.6569 21 12 21ZM16 8.5C16 8.77614 15.7761 9 15.5 9C15.2239 9 15 8.77614 15 8.5C15 8.22386 15.2239 8 15.5 8C15.7761 8 16 8.22386 16 8.5ZM11 7.5C11 7.77614 10.7761 8 10.5 8C10.2239 8 10 7.77614 10 7.5C10 7.22386 10.2239 7 10.5 7C10.7761 7 11 7.22386 11 7.5ZM8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Personnaliser le CSS
</button>
<button id="btn-back" class="btn-back">← Accueil</button>
@@ -50,7 +52,11 @@
<div id="css-modal-overlay" class="css-modal-overlay hidden">
<div class="css-modal">
<div class="css-modal__header">
<span class="css-modal__title">CSS personnalisé</span>
<span class="css-modal__title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle; margin-right: 6px; opacity: 0.8;">
<path d="M15.5 8.5H15.51M10.5 7.5H10.51M7.5 11.5H7.51M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.6569 19.6569 15 18 15H17.4C17.0284 15 16.8426 15 16.6871 15.0246C15.8313 15.1602 15.1602 15.8313 15.0246 16.6871C15 16.8426 15 17.0284 15 17.4V18C15 19.6569 13.6569 21 12 21ZM16 8.5C16 8.77614 15.7761 9 15.5 9C15.2239 9 15 8.77614 15 8.5C15 8.22386 15.2239 8 15.5 8C15.7761 8 16 8.22386 16 8.5ZM11 7.5C11 7.77614 10.7761 8 10.5 8C10.2239 8 10 7.77614 10 7.5C10 7.22386 10.2239 7 10.5 7C10.7761 7 11 7.22386 11 7.5ZM8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>CSS personnalisé
</span>
<button id="btn-css-close" class="css-modal__close"></button>
</div>
<div class="css-modal__tabs" id="css-tabs">
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 8.5H15.51M10.5 7.5H10.51M7.5 11.5H7.51M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.6569 19.6569 15 18 15H17.4C17.0284 15 16.8426 15 16.6871 15.0246C15.8313 15.1602 15.1602 15.8313 15.0246 16.6871C15 16.8426 15 17.0284 15 17.4V18C15 19.6569 13.6569 21 12 21ZM16 8.5C16 8.77614 15.7761 9 15.5 9C15.2239 9 15 8.77614 15 8.5C15 8.22386 15.2239 8 15.5 8C15.7761 8 16 8.22386 16 8.5ZM11 7.5C11 7.77614 10.7761 8 10.5 8C10.2239 8 10 7.77614 10 7.5C10 7.22386 10.2239 7 10.5 7C10.7761 7 11 7.22386 11 7.5ZM8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 993 B

+1 -1
View File
@@ -484,7 +484,7 @@ body {
border-color: rgba(255,255,255,0.35);
background: rgba(255,255,255,0.06);
}
.btn-customize-css__icon { font-size: 15px; opacity: 0.8; }
.btn-customize-css__icon { flex-shrink: 0; opacity: 0.8; }
/* ── CSS modal ── */
.css-modal-overlay {