feat: coloration syntaxique via CSS classes (themeable)

Remplace les styles inline syntect par des classes CSS. Le CSS est
généré côté Rust (base16-ocean.dark) et injecté au démarrage via
get_syntax_highlight_css. Les thèmes peuvent surcharger .syntax-highlighting
et les classes de tokens. Supprime le lien CDN highlight.js inutilisé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:36:08 +02:00
parent 75c85e7e0a
commit df899bd1a8
8 changed files with 107 additions and 26 deletions
+10 -4
View File
@@ -98,15 +98,21 @@ body {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
} }
/* Bloc de code (entre triple backticks) */ /* Bloc de code sans coloration syntaxique */
.content pre { .content pre:not(.syntax-highlighting) {
background: #0f172a; background: #0f172a;
border: 1px solid #1e293b; border: 1px solid #1e293b;
border-radius: 10px; border-radius: 10px;
} }
/* Texte à l'intérieur d'un bloc de code */ /* Bloc de code avec coloration syntaxique */
.content pre code { .content .syntax-highlighting {
border: 1px solid #1e293b;
border-radius: 10px;
}
/* Texte dans un bloc sans coloration */
.content pre:not(.syntax-highlighting) code {
background: none; background: none;
color: #e2e8f0; color: #e2e8f0;
} }
+3 -2
View File
@@ -9,8 +9,9 @@ body { background: #f1f5f9; }
.content p { color: #1e293b; } .content p { color: #1e293b; }
.content ul li, .content ol li { color: #1e293b; } .content ul li, .content ol li { color: #1e293b; }
.content code { background: #f1f5f9; color: #4f46e5; border: 1px solid #e2e8f0; } .content code { background: #f1f5f9; color: #4f46e5; border: 1px solid #e2e8f0; }
.content pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 10px; } .content pre:not(.syntax-highlighting) { background: #0f172a; border: 1px solid #1e293b; border-radius: 10px; }
.content pre code { background: none; color: #e2e8f0; } .content .syntax-highlighting { border: 1px solid #1e293b; border-radius: 10px; }
.content pre:not(.syntax-highlighting) code { background: none; color: #e2e8f0; }
.content blockquote { border-color: #818cf8; background: #f5f3ff; color: #4338ca; } .content blockquote { border-color: #818cf8; background: #f5f3ff; color: #4338ca; }
.content table { color: #374151; } .content table { color: #374151; }
.content table th { border-bottom-color: #4f46e5; color: #334155; } .content table th { border-bottom-color: #4f46e5; color: #334155; }
+23 -8
View File
@@ -1,22 +1,25 @@
use crate::application::{file_service, render_service}; use crate::application::{file_service, render_service};
use crate::infrastructure::comrak_renderer::{ComrakPreviewRenderer, ComrakRenderer}; use crate::infrastructure::comrak_renderer::{
syntax_css_for_theme, ComrakPreviewRenderer, ComrakRenderer,
};
#[tauri::command] #[tauri::command]
pub fn render_markdown(content: String) -> String { pub fn render_markdown(content: String) -> String {
let renderer = ComrakPreviewRenderer { let renderer = ComrakPreviewRenderer;
theme: "base16-ocean.dark".to_string(),
};
render_service::render_string(&renderer, &content) render_service::render_string(&renderer, &content)
} }
#[tauri::command] #[tauri::command]
pub fn convert_file(path: String) -> Result<String, String> { pub fn convert_file(path: String) -> Result<String, String> {
let renderer = ComrakRenderer { let renderer = ComrakRenderer;
theme: "InspiredGitHub".to_string(),
};
render_service::render_file(&renderer, &path) render_service::render_file(&renderer, &path)
} }
#[tauri::command]
pub fn get_syntax_highlight_css() -> String {
syntax_css_for_theme("base16-ocean.dark")
}
#[tauri::command] #[tauri::command]
pub fn list_md_files(dir: String) -> Result<Vec<String>, String> { pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
file_service::list_markdown_files(&dir) file_service::list_markdown_files(&dir)
@@ -41,7 +44,7 @@ mod tests {
#[test] #[test]
fn render_markdown_code_block() { fn render_markdown_code_block() {
let html = render_markdown("```rust\nfn main() {}\n```\n".to_string()); let html = render_markdown("```rust\nfn main() {}\n```\n".to_string());
assert!(html.contains("<code") || html.contains("<pre>")); assert!(html.contains("syntax-highlighting"));
} }
#[test] #[test]
@@ -80,6 +83,18 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn get_syntax_highlight_css_returns_non_empty() {
let css = get_syntax_highlight_css();
assert!(!css.is_empty());
}
#[test]
fn get_syntax_highlight_css_contains_wrapper_class() {
let css = get_syntax_highlight_css();
assert!(css.contains(".syntax-highlighting {"));
}
#[test] #[test]
fn list_md_files_sorted() { fn list_md_files_sorted() {
let dir = tmpdir("pena_list_sorted"); let dir = tmpdir("pena_list_sorted");
@@ -1,19 +1,16 @@
use comrak::plugins::syntect::SyntectAdapterBuilder; use comrak::plugins::syntect::SyntectAdapterBuilder;
use comrak::{markdown_to_html_with_plugins, Options, Plugins}; use comrak::{markdown_to_html_with_plugins, Options, Plugins};
use syntect::highlighting::ThemeSet;
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
use crate::domain::MarkdownRenderer; use crate::domain::MarkdownRenderer;
pub struct ComrakRenderer { pub struct ComrakRenderer;
pub theme: String, pub struct ComrakPreviewRenderer;
}
pub struct ComrakPreviewRenderer {
pub theme: String,
}
impl MarkdownRenderer for ComrakRenderer { impl MarkdownRenderer for ComrakRenderer {
fn render(&self, content: &str) -> String { fn render(&self, content: &str) -> String {
let adapter = SyntectAdapterBuilder::new().theme(&self.theme).build(); let adapter = SyntectAdapterBuilder::new().css().build();
let mut options = Options::default(); let mut options = Options::default();
options.extension.table = true; options.extension.table = true;
options.extension.strikethrough = true; options.extension.strikethrough = true;
@@ -27,10 +24,60 @@ impl MarkdownRenderer for ComrakRenderer {
impl MarkdownRenderer for ComrakPreviewRenderer { impl MarkdownRenderer for ComrakPreviewRenderer {
fn render(&self, content: &str) -> String { fn render(&self, content: &str) -> String {
let adapter = SyntectAdapterBuilder::new().theme(&self.theme).build(); let adapter = SyntectAdapterBuilder::new().css().build();
let options = Options::default(); let options = Options::default();
let mut plugins = Plugins::default(); let mut plugins = Plugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adapter); plugins.render.codefence_syntax_highlighter = Some(&adapter);
markdown_to_html_with_plugins(content, &options, &plugins) markdown_to_html_with_plugins(content, &options, &plugins)
} }
} }
pub fn syntax_css_for_theme(syntect_theme_name: &str) -> String {
let ts = ThemeSet::load_defaults();
let theme = match ts.themes.get(syntect_theme_name) {
Some(t) => t,
None => return String::new(),
};
match css_for_theme_with_class_style(theme, ClassStyle::Spaced) {
Ok(css) => css.replace(".code {", ".syntax-highlighting {"),
Err(_) => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_outputs_syntax_highlighting_class() {
let r = ComrakRenderer;
let html = r.render("```rust\nfn main() {}\n```\n");
assert!(html.contains("syntax-highlighting"));
}
#[test]
fn render_outputs_classed_spans() {
let r = ComrakRenderer;
let html = r.render("```rust\nfn main() {}\n```\n");
assert!(html.contains("<span class="));
}
#[test]
fn syntax_css_known_theme_contains_wrapper() {
let css = syntax_css_for_theme("base16-ocean.dark");
assert!(!css.is_empty());
assert!(css.contains(".syntax-highlighting {"));
}
#[test]
fn syntax_css_known_theme_contains_token_rules() {
let css = syntax_css_for_theme("base16-ocean.dark");
assert!(css.contains("color:") || css.contains("color :"));
}
#[test]
fn syntax_css_unknown_theme_returns_empty() {
let css = syntax_css_for_theme("nonexistent-theme-xyz");
assert!(css.is_empty());
}
}
+1
View File
@@ -15,6 +15,7 @@ pub fn run() {
commands::render::render_markdown, commands::render::render_markdown,
commands::render::convert_file, commands::render::convert_file,
commands::render::list_md_files, commands::render::list_md_files,
commands::render::get_syntax_highlight_css,
commands::watch::start_watch, commands::watch::start_watch,
commands::watch::stop_watch, commands::watch::stop_watch,
commands::theme::list_themes, commands::theme::list_themes,
-1
View File
@@ -6,7 +6,6 @@
<title>Pena — Markdown Viewer</title> <title>Pena — Markdown Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Roboto+Mono&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Roboto+Mono&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
+11
View File
@@ -1,8 +1,18 @@
import { showHome, openPath } from './router.js'; import { showHome, openPath } from './router.js';
import { initCssModal } from './ui/css-modal.js'; import { initCssModal } from './ui/css-modal.js';
const { invoke } = window.__TAURI__.core;
const win = window.__TAURI__.window.getCurrentWindow(); const win = window.__TAURI__.window.getCurrentWindow();
async function injectSyntaxCss() {
const css = await invoke('get_syntax_highlight_css');
if (!css) return;
const style = document.createElement('style');
style.id = 'pena-syntax-style';
style.textContent = css;
document.head.appendChild(style);
}
document.getElementById('titlebar').addEventListener('mousedown', (e) => { document.getElementById('titlebar').addEventListener('mousedown', (e) => {
if (e.target.closest('.titlebar-controls')) return; if (e.target.closest('.titlebar-controls')) return;
if (e.buttons === 1) win.startDragging(); if (e.buttons === 1) win.startDragging();
@@ -32,5 +42,6 @@ document.getElementById('btn-open-dir').addEventListener('click', async () => {
document.getElementById('btn-back').addEventListener('click', showHome); document.getElementById('btn-back').addEventListener('click', showHome);
await injectSyntaxCss();
await initCssModal(); await initCssModal();
showHome(); showHome();
+2 -1
View File
@@ -451,7 +451,8 @@ body {
border-radius: 4px; border-radius: 4px;
line-height: 1.4; line-height: 1.4;
} }
.content pre { background: #f4f4f7; padding: 20px 24px; border-radius: 6px; overflow-x: auto; margin: 16px 0; } .content pre { padding: 20px 24px; border-radius: 6px; overflow-x: auto; margin: 16px 0; }
.content pre:not(.syntax-highlighting) { background: #f4f4f7; }
.content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; } .content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; }
.content blockquote { .content blockquote {