13 Commits

Author SHA1 Message Date
Gato 2841881a01 feat: script de packaging Flatpak
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:02:32 +02:00
Gato 8b0f128313 (feat) copy code content
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
2026-06-22 21:33:38 +02:00
Gato 2d9a1e64f9 fix: inclure le CSS syntect statiquement dans style.css
L'injection dynamique via invoke() échouait silencieusement.
Le CSS base16-ocean.dark est maintenant scopé (.syntax-highlighting .keyword
etc.) et inclus directement dans style.css — aucun appel runtime requis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:49:26 +02:00
Gato ebb648bb2d fix: isoler le style du code inline pour ne pas affecter les blocs pre
.content code dans les thèmes ciblait aussi le <code> dans <pre>,
écrasant les couleurs syntect par héritage et ajoutant une bordure
indésirable. Changé en .content :not(pre) > code + reset explicite
dans style.css (border: none, color: inherit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:44:00 +02:00
Gato df899bd1a8 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>
2026-06-19 15:36:08 +02:00
Gato 75c85e7e0a fix: corriger les classes CSS mortes de la sidebar dans les thèmes
Les thèmes référençaient d'anciennes classes (sidebar__menu__*__link)
jamais générées par le JS. Remplacement par les vraies classes nav-tree
(nav-root-link, nav-folder-link, nav-file-link) et ajout du fond
sidebar et des états actifs configurables par thème.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:20:55 +02:00
Gato e34528c886 feat: thèmes rapides gérés par le backend via fichiers CSS
Les thèmes ne sont plus codés en dur dans le JS. Chaque thème est
un fichier CSS dans src-tauri/resources/themes/, embarqué à la
compilation via include_str!. Le backend expose list_themes et
get_theme_css comme commandes Tauri ; le frontend charge et met en
cache le CSS à la demande.

Ajout du thème "Défaut" (basé sur Shell Indigo) avec commentaires
sur chaque règle et rendu complet des tableaux (en-têtes en
majuscules, bordure extérieure, séparateurs verticaux, lignes
alternées) aligné sur MarkdownRender-nodejs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:04:11 +02:00
Gato 8b39c09484 feat: ajout des badges thèmes rapides dans le modal CSS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:39:24 +02:00
Gato b8ba15d832 feat: navigation entre documents via les liens Markdown
Intercepte les clics sur les liens dans le contenu rendu : les liens
internes (.md) chargent le document dans l'app, les liens HTTP s'ouvrent
dans le navigateur système. Restructure aussi la sidebar avec un footer
fixe pour les boutons Personnaliser/Accueil.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:35:50 +02:00
Gato d47e93b3c3 docs: règle architecture Clean Architecture pour Pena-tauri 2026-06-19 14:27:25 +02:00
Gato cdafffaa70 refactor: migration architecture clean (Rust + JS)
Côté JS : décompose main.js en modules ES6 (state, services, ui, router)
pour séparer les responsabilités et faciliter la maintenance.
Côté Rust : ajoute 7 tests sur is_relevant_path (notify_watcher)
pour couvrir les cas cachés/non-md ; couverture totale 77 % > seuil 60 %.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:23:30 +02:00
Gato 6ae7a434c4 refactor: migration Clean Architecture — module commands avec render et watch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:13:30 +02:00
Gato 9eae97f1d8 feat: module application avec render_service et file_service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:09:08 +02:00
45 changed files with 5265 additions and 554 deletions
+52
View File
@@ -0,0 +1,52 @@
# Règles — Architecture (Pena-tauri)
## Principe
L'architecture est **Clean Architecture**. Toute nouvelle fonctionnalité doit respecter ce découpage sans exception.
## Structure Rust (`src-tauri/src/`)
```
domain/ — entités et traits purs, zéro dépendance externe
application/ — services métier, dépend uniquement de domain/
infrastructure/ — implémentations concrètes des traits de domain/
commands/ — points d'entrée Tauri (#[tauri::command]), orchestrent application/
```
## Structure JS (`src/`)
```
services/ — appels Tauri (invoke), logique de données
state/ — état global de l'application
ui/ — composants d'affichage, sans logique métier
```
## Règles de dépendance
| Couche | Peut dépendre de | Ne peut PAS dépendre de |
|---|---|---|
| `domain` | rien | `application`, `infrastructure`, `commands` |
| `application` | `domain` | `infrastructure`, `commands` |
| `infrastructure` | `domain` | `application`, `commands` |
| `commands` | `application`, `domain` | `infrastructure` directement |
| `ui/` (JS) | `services/`, `state/` | rien d'externe hors Tauri |
| `services/` (JS) | invoke Tauri | `ui/` |
## Ce qui est interdit
- Instancier une implémentation concrète (`ComrakRenderer`, `FileRepository`, etc.) depuis `application/` ou `commands/` — passer par injection de dépendance ou par le point d'assemblage (`lib.rs`).
- Mettre de la logique métier dans `commands/` ou `ui/`.
- Mettre des appels Tauri (`invoke`) dans `ui/` — les déléguer à `services/`.
- Créer un module hors de ces quatre couches Rust sans justification explicite.
## Point d'assemblage
`lib.rs` est le seul endroit où les implémentations concrètes sont instanciées et câblées aux services.
## Nouveau code
Avant d'ajouter un fichier, se poser la question : **dans quelle couche appartient cette responsabilité ?**
- Règle métier / contrat → `domain/`
- Orchestration d'un cas d'usage → `application/`
- Accès I/O, filesystem, bibliothèque tierce → `infrastructure/`
- Commande exposée à Tauri → `commands/`
+7
View File
@@ -1,2 +1,9 @@
src-tauri/target/ src-tauri/target/
src-tauri/gen/ src-tauri/gen/
# Artefacts de build Flatpak
flatpak/.build/
flatpak/.repo/
flatpak/.flatpak-builder/
flatpak/pena.deb
flatpak/*.flatpak
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
#
# Construit un paquet Flatpak de Pena à partir du .deb généré par Tauri.
#
# Étapes :
# 1. `cargo tauri build --bundles deb` -> produit le .deb autonome
# 2. Copie du .deb à côté du manifeste sous le nom `pena.deb`
# 3. `flatpak-builder` construit le bundle dans la sandbox GNOME
# 4. Installation locale (--user) et/ou export en fichier .flatpak
#
# Usage :
# ./build-flatpak.sh # build + installation locale (--user)
# ./build-flatpak.sh --bundle # build + export d'un fichier Pena.flatpak
# ./build-flatpak.sh --no-deb # réutilise le .deb déjà présent (build rapide)
#
set -euo pipefail
APP_ID="com.pena.app"
RUNTIME_VERSION="48"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # .../Pena-tauri
MANIFEST="$SCRIPT_DIR/${APP_ID}.yml"
BUILD_DIR="$SCRIPT_DIR/.build" # arbre de build flatpak-builder
REPO_DIR="$SCRIPT_DIR/.repo" # dépôt ostree temporaire
DEB_TARGET="$SCRIPT_DIR/pena.deb"
DO_BUNDLE=0
BUILD_DEB=1
for arg in "$@"; do
case "$arg" in
--bundle) DO_BUNDLE=1 ;;
--no-deb) BUILD_DEB=0 ;;
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "Argument inconnu : $arg" >&2; exit 1 ;;
esac
done
# --- Vérification des outils -------------------------------------------------
need() { command -v "$1" >/dev/null 2>&1 || { echo "Outil manquant : $1" >&2; exit 1; }; }
need flatpak
if ! command -v flatpak-builder >/dev/null 2>&1; then
echo "flatpak-builder est requis. Installez-le :" >&2
echo " flatpak install -y flathub org.flatpak.Builder" >&2
echo " (puis lancez ce script via : flatpak run org.flatpak.Builder ...)" >&2
echo " ou sur Fedora : sudo dnf install flatpak-builder" >&2
exit 1
fi
# --- Runtime / SDK GNOME -----------------------------------------------------
echo ">> Vérification du runtime GNOME ${RUNTIME_VERSION}"
if ! flatpak info "org.gnome.Platform//${RUNTIME_VERSION}" >/dev/null 2>&1; then
flatpak install -y --user flathub \
"org.gnome.Platform//${RUNTIME_VERSION}" \
"org.gnome.Sdk//${RUNTIME_VERSION}"
fi
# --- 1. Build du .deb via Tauri ---------------------------------------------
if [[ "$BUILD_DEB" -eq 1 ]]; then
echo ">> Build du paquet .deb (cargo tauri build --bundles deb)…"
( cd "$PROJECT_DIR" && cargo tauri build --bundles deb )
fi
DEB_SRC="$(find "$PROJECT_DIR/src-tauri/target/release/bundle/deb" -name '*.deb' 2>/dev/null | sort | tail -n1 || true)"
if [[ -z "$DEB_SRC" ]]; then
echo "Aucun .deb trouvé. Lancez le script sans --no-deb." >&2
exit 1
fi
echo ">> Utilisation du .deb : $DEB_SRC"
cp -f "$DEB_SRC" "$DEB_TARGET"
# --- 2. Build Flatpak --------------------------------------------------------
echo ">> Construction du Flatpak…"
rm -rf "$BUILD_DIR"
FB_ARGS=(--force-clean --user --repo="$REPO_DIR")
if [[ "$DO_BUNDLE" -eq 0 ]]; then
FB_ARGS+=(--install)
fi
flatpak-builder "${FB_ARGS[@]}" "$BUILD_DIR" "$MANIFEST"
# --- 3. Export d'un fichier .flatpak (optionnel) -----------------------------
if [[ "$DO_BUNDLE" -eq 1 ]]; then
OUT="$SCRIPT_DIR/Pena.flatpak"
echo ">> Export du bundle : $OUT"
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" "$RUNTIME_VERSION" 2>/dev/null \
|| flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID"
echo ">> Installer avec : flatpak install --user $OUT"
else
echo ">> Installé. Lancer avec : flatpak run $APP_ID"
fi
echo ">> Terminé."
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.pena.app</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>Pena</name>
<summary>Lecteur de fichiers Markdown</summary>
<description>
<p>
Pena est une application de bureau pour lire des fichiers Markdown.
La conversion Markdown vers HTML est réalisée côté Rust (comrak), avec
coloration syntaxique des blocs de code et plusieurs thèmes de lecture.
</p>
</description>
<launchable type="desktop-id">com.pena.app.desktop</launchable>
<categories>
<category>Utility</category>
<category>TextEditor</category>
</categories>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.1.0" date="2026-06-23"/>
</releases>
</component>
+48
View File
@@ -0,0 +1,48 @@
id: com.pena.app
runtime: org.gnome.Platform
runtime-version: '48'
sdk: org.gnome.Sdk
command: pena
finish-args:
# Affichage
- --socket=wayland
- --socket=fallback-x11
- --device=dri
- --share=ipc
# Lecture des fichiers Markdown ouverts par l'utilisateur
- --filesystem=home
modules:
- name: pena
buildsystem: simple
sources:
- type: file
path: pena.deb
- type: file
path: com.pena.app.metainfo.xml
build-commands:
- |
set -eux
# Extraction du .deb produit par `cargo tauri build`
ar -x pena.deb
tar -xf data.tar.gz
# Binaire principal -> /app/bin/pena (correspond à `command:`)
bin="$(find usr/bin -maxdepth 1 -type f | head -n1)"
install -Dm755 "$bin" /app/bin/pena
# Fichier .desktop : renommé en <app-id>.desktop (exigence Flatpak)
desktop="$(find usr/share/applications -name '*.desktop' | head -n1)"
install -Dm644 "$desktop" /app/share/applications/com.pena.app.desktop
sed -i \
-e 's/^Exec=.*/Exec=pena/' \
-e 's/^Icon=.*/Icon=com.pena.app/' \
/app/share/applications/com.pena.app.desktop
# Icône : on prend la plus grande disponible
icon="$(find usr/share/icons -name '*.png' | sort | tail -n1)"
install -Dm644 "$icon" /app/share/icons/hicolor/128x128/apps/com.pena.app.png
# Métadonnées AppStream
install -Dm644 com.pena.app.metainfo.xml /app/share/metainfo/com.pena.app.metainfo.xml
+1 -1
View File
@@ -17,5 +17,5 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
comrak = { version = "0.28", features = ["syntect"] } comrak = { version = "0.28", features = ["syntect"] }
syntect = "5" syntect = { version = "5", features = ["yaml-load"] }
notify = "7" notify = "7"
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
.content { background: #1e1e2e; color: #cdd6f4; }
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 { color: #cdd6f4; }
.content p, .content ul li, .content ol li { color: #cdd6f4; }
.content a { color: #89b4fa; }
.content a:visited { color: #cba6f7; }
.content code { background: #313244; color: #cba6f7; }
.content pre { background: #313244; }
.content blockquote { border-color: #6c7086; color: #a6adc8; }
.content table td, .content table th { color: #cdd6f4; }
+255
View File
@@ -0,0 +1,255 @@
/* ─────────────────────────────────────────────
Thème par défaut — basé sur Shell Indigo
Modifiez les valeurs pour personnaliser
l'apparence de Pena.
───────────────────────────────────────────── */
/* ── Page ──────────────────────────────────── */
/* Couleur de fond de la fenêtre (visible autour du contenu) */
body {
background: #f1f5f9;
}
/* Couleur de surbrillance lors d'une sélection de texte */
::selection {
background: #c7d2fe;
}
/* ── Zone de contenu ────────────────────────── */
/* Fond et couleur de texte principale de la zone de lecture */
.content {
background: #ffffff;
color: #1e293b;
}
/* ── Liens ──────────────────────────────────── */
/* Lien normal (non visité) */
.content a {
color: #4f46e5;
border-bottom: 1px solid #c7d2fe;
}
/* Lien au survol de la souris */
.content a:hover {
color: #3730a3;
border-bottom-color: #4f46e5;
}
/* Lien déjà visité */
.content a:visited {
color: #6d28d9;
}
/* Lien déjà visité au survol */
.content a:visited:hover {
color: #3730a3;
}
/* ── Titres ─────────────────────────────────── */
/* Couleur commune à tous les niveaux de titre (h1 à h6) */
.content h1,
.content h2,
.content h3,
.content h4,
.content h5,
.content h6 {
color: #0f172a;
}
/* ── Texte courant ──────────────────────────── */
/* Paragraphes */
.content p {
color: #1e293b;
}
/* Éléments de liste (à puces et numérotées) */
.content ul li,
.content ol li {
color: #1e293b;
}
/* Texte en gras */
.content strong {
color: #0f172a;
}
/* Ligne de séparation horizontale (---) */
.content hr {
border-color: #e2e8f0;
}
/* ── Code ───────────────────────────────────── */
/* Code inline (entre backticks simples) */
.content :not(pre) > code {
background: #f1f5f9;
color: #4f46e5;
border: 1px solid #e2e8f0;
}
/* Bloc de code sans coloration syntaxique */
.content pre:not(.syntax-highlighting) {
background: #0f172a;
border: 1px solid #1e293b;
}
/* Bloc de code avec coloration syntaxique */
.content .syntax-highlighting {
border: 1px solid #1e293b;
}
/* Texte dans un bloc sans coloration */
.content pre:not(.syntax-highlighting) code {
background: none;
color: #e2e8f0;
}
/* ── Citation ───────────────────────────────── */
/* Bloc de citation (> texte) */
.content blockquote {
border-color: #818cf8;
background: #f5f3ff;
color: #4338ca;
}
/* ── Tableaux ───────────────────────────────── */
/* Conteneur : bordure extérieure et coins arrondis */
.content table {
font-size: 0.875rem;
border: 1px solid #cbd5e1;
border-radius: 8px;
overflow: hidden;
color: #374151;
}
/* Ligne d'en-tête : fond gris clair */
.content thead {
background: #e2e8f0;
}
/* Cellule d'en-tête : texte en majuscules espacées, séparateur vertical */
.content table th {
padding: 0.6rem 1rem;
font-size: 0.77rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #334155;
border-bottom: 2px solid #4f46e5;
border-right: 1px solid #cbd5e1;
text-align: left;
}
/* Pas de bordure droite sur la dernière colonne d'en-tête */
.content table th:last-child {
border-right: none;
}
/* Cellule de données : padding, séparateurs horizontaux et verticaux */
.content table td {
padding: 0.6rem 1rem;
border-bottom: 1px solid #e2e8f0;
border-right: 1px solid #e2e8f0;
vertical-align: top;
}
/* Pas de bordure droite sur la dernière colonne */
.content table td:last-child {
border-right: none;
}
/* Pas de bordure basse sur la dernière ligne (évite le double bord avec le conteneur) */
.content tbody tr:last-child td {
border-bottom: none;
}
/* Lignes paires : fond légèrement différent pour faciliter la lecture */
.content tbody tr:nth-child(even) {
background: #f8fafc;
}
/* Ligne au survol : mise en évidence */
.content tbody tr:hover {
background: #eff6ff;
}
/* ── Barre latérale ─────────────────────────── */
/* Fond de la barre latérale */
.sidebar {
background: #1a1b2e;
}
/* Lien racine (premier niveau) au survol */
.nav-root-link:hover {
color: #818cf8;
}
/* Lien racine (premier niveau) actif (page ouverte) */
.nav-root-link.active {
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
}
/* Lien de dossier au survol */
.nav-folder-link:hover {
color: #818cf8;
}
/* Lien de dossier actif */
.nav-folder-link.active {
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
}
/* Lien de fichier (sous-niveaux) au survol */
.nav-file-link:hover {
color: #818cf8;
}
/* Lien de fichier actif (page ouverte) */
.nav-file-link.active {
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
}
/* ── Barre de titre ─────────────────────────── */
/* Fond et bordure inférieure de la barre de titre */
#titlebar {
background: #1a1b2e;
/* border-bottom: 1px solid #1e293b; */
}
/* Texte du titre de la fenêtre */
#titlebar-title {
color: rgba(255, 255, 255, 0.5);
}
/* Boutons de contrôle (réduire, agrandir, fermer) au repos */
.titlebar-controls button {
color: rgba(255, 255, 255, 0.45);
}
/* Boutons de contrôle au survol */
.titlebar-controls button:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
}
+3
View File
@@ -0,0 +1,3 @@
.content a:hover { color: #10b981; }
.content table th { border-bottom-color: #10b981; }
.content table td { border-bottom-color: rgba(16, 185, 129, 0.25); }
@@ -0,0 +1,2 @@
.content { font-size: 18px; }
.content p { line-height: 1.8; }
+6
View File
@@ -0,0 +1,6 @@
.content { background: #f4ecd8; }
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 { color: #5c4a1e; }
.content p, .content ul li, .content ol li { color: #5c4a1e; }
.content a { color: #7a5c2e; }
.content code { background: #e8d5b0; color: #5c4a1e; }
.content pre { background: #e8d5b0; }
@@ -0,0 +1,30 @@
body { background: #f1f5f9; }
::selection { background: #c7d2fe; }
.content { background: #ffffff; color: #1e293b; }
.content a { color: #4f46e5; border-bottom: 1px solid #c7d2fe; }
.content a:hover { color: #3730a3; border-bottom-color: #4f46e5; }
.content a:visited { color: #6d28d9; }
.content a:visited:hover { color: #3730a3; }
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 { color: #0f172a; }
.content p { color: #1e293b; }
.content ul li, .content ol li { color: #1e293b; }
.content :not(pre) > code { background: #f1f5f9; color: #4f46e5; border: 1px solid #e2e8f0; }
.content pre:not(.syntax-highlighting) { background: #0f172a; border: 1px solid #1e293b; border-radius: 10px; }
.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 table { color: #374151; }
.content table th { border-bottom-color: #4f46e5; color: #334155; }
.content table td { border-bottom-color: rgba(79, 70, 229, 0.25); }
.content strong { color: #0f172a; }
.content hr { border-color: #e2e8f0; }
.nav-root-link:hover { color: #818cf8; }
.nav-root-link.active { color: #818cf8; background: rgba(129, 140, 248, 0.08); }
.nav-folder-link:hover { color: #818cf8; }
.nav-folder-link.active { color: #818cf8; background: rgba(129, 140, 248, 0.08); }
.nav-file-link:hover { color: #818cf8; }
.nav-file-link.active { color: #818cf8; background: rgba(129, 140, 248, 0.08); }
#titlebar { background: #0f172a; border-bottom: 1px solid #1e293b; }
#titlebar-title { color: rgba(255,255,255,0.5); }
.titlebar-controls button { color: rgba(255,255,255,0.45); }
.titlebar-controls button:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.9); }
+65
View File
@@ -0,0 +1,65 @@
use crate::infrastructure::file_repository;
use std::path::Path;
pub fn list_markdown_files(dir: &str) -> Result<Vec<String>, String> {
let path = Path::new(dir);
if !path.is_dir() {
return Err(format!("{dir} n'est pas un dossier"));
}
let mut files = Vec::new();
file_repository::collect_md_files(path, &mut files).map_err(|e| e.to_string())?;
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmpdir(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn list_markdown_files_sorted() {
let dir = tmpdir("pena_fs_sorted");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("z.md"), "").unwrap();
fs::write(dir.join("a.md"), "").unwrap();
fs::write(dir.join("m.md"), "").unwrap();
let result = list_markdown_files(dir.to_str().unwrap()).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 3);
assert!(result[0] < result[1] && result[1] < result[2]);
assert!(result[0].ends_with("a.md"));
assert!(result[2].ends_with("z.md"));
}
#[test]
fn list_markdown_files_ignores_non_md() {
let dir = tmpdir("pena_fs_non_md");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("doc.md"), "").unwrap();
fs::write(dir.join("notes.txt"), "").unwrap();
let result = list_markdown_files(dir.to_str().unwrap()).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("doc.md"));
}
#[test]
fn list_markdown_files_not_a_dir() {
let result = list_markdown_files("/nonexistent/pena_fs_dir");
assert!(result.is_err());
}
#[test]
fn list_markdown_files_empty_dir() {
let dir = tmpdir("pena_fs_empty");
fs::create_dir_all(&dir).unwrap();
let result = list_markdown_files(dir.to_str().unwrap()).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 0);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod file_service;
pub mod render_service;
pub mod theme_service;
@@ -0,0 +1,58 @@
use crate::domain::MarkdownRenderer;
use crate::infrastructure::file_repository;
pub fn render_string(renderer: &dyn MarkdownRenderer, content: &str) -> String {
renderer.render(content)
}
pub fn render_file(renderer: &dyn MarkdownRenderer, path: &str) -> Result<String, String> {
let content = file_repository::read_file(path)?;
Ok(renderer.render(&content))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct MockRenderer;
impl MarkdownRenderer for MockRenderer {
fn render(&self, content: &str) -> String {
format!("<mock>{content}</mock>")
}
}
#[test]
fn render_string_delegates_to_renderer() {
let renderer = MockRenderer;
let result = render_string(&renderer, "hello");
assert_eq!(result, "<mock>hello</mock>");
}
#[test]
fn render_string_empty_content() {
let renderer = MockRenderer;
let result = render_string(&renderer, "");
assert_eq!(result, "<mock></mock>");
}
#[test]
fn render_file_success() {
let dir = std::env::temp_dir().join("pena_rs_render_file");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.md");
fs::write(&file, "world").unwrap();
let renderer = MockRenderer;
let result = render_file(&renderer, file.to_str().unwrap());
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.unwrap(), "<mock>world</mock>");
}
#[test]
fn render_file_not_found() {
let renderer = MockRenderer;
let result = render_file(&renderer, "/nonexistent/pena_rs_file.md");
assert!(result.is_err());
}
}
@@ -0,0 +1,59 @@
use crate::domain::theme::{Theme, ThemeRepository};
pub fn list_themes(repo: &dyn ThemeRepository) -> Vec<Theme> {
repo.list()
}
pub fn get_theme_css(repo: &dyn ThemeRepository, id: &str) -> Option<String> {
repo.get_css(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::theme::{Theme, ThemeRepository};
struct MockRepo {
themes: Vec<(&'static str, &'static str, &'static str)>,
}
impl ThemeRepository for MockRepo {
fn list(&self) -> Vec<Theme> {
self.themes.iter().map(|(id, label, _)| Theme { id: id.to_string(), label: label.to_string() }).collect()
}
fn get_css(&self, id: &str) -> Option<String> {
self.themes.iter().find(|(i, _, _)| *i == id).map(|(_, _, css)| css.to_string())
}
}
fn mock_repo() -> MockRepo {
MockRepo {
themes: vec![
("dark", "Mode sombre", ".content{background:#000}"),
("light", "Clair", ".content{background:#fff}"),
],
}
}
#[test]
fn list_themes_delegates_to_repo() {
let repo = mock_repo();
let themes = list_themes(&repo);
assert_eq!(themes.len(), 2);
assert_eq!(themes[0].id, "dark");
assert_eq!(themes[1].label, "Clair");
}
#[test]
fn get_theme_css_returns_css_for_known_id() {
let repo = mock_repo();
let css = get_theme_css(&repo, "dark");
assert_eq!(css, Some(".content{background:#000}".to_string()));
}
#[test]
fn get_theme_css_returns_none_for_unknown_id() {
let repo = mock_repo();
assert!(get_theme_css(&repo, "unknown").is_none());
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod render;
pub mod theme;
pub mod watch;
+116
View File
@@ -0,0 +1,116 @@
use crate::application::{file_service, render_service};
use crate::infrastructure::comrak_renderer::{
syntax_css_for_theme, ComrakPreviewRenderer, ComrakRenderer,
};
#[tauri::command]
pub fn render_markdown(content: String) -> String {
let renderer = ComrakPreviewRenderer;
render_service::render_string(&renderer, &content)
}
#[tauri::command]
pub fn convert_file(path: String) -> Result<String, String> {
let renderer = ComrakRenderer;
render_service::render_file(&renderer, &path)
}
#[tauri::command]
pub fn get_syntax_highlight_css() -> String {
syntax_css_for_theme("base16-ocean.dark")
}
#[tauri::command]
pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
file_service::list_markdown_files(&dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmpdir(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn render_markdown_heading() {
let html = render_markdown("# Hello\n".to_string());
assert!(html.contains("<h1>"));
assert!(html.contains("Hello"));
}
#[test]
fn render_markdown_code_block() {
let html = render_markdown("```rust\nfn main() {}\n```\n".to_string());
assert!(html.contains("syntax-highlighting"));
}
#[test]
fn render_markdown_empty() {
let html = render_markdown(String::new());
assert!(!html.contains("<h1>"));
}
#[test]
fn convert_file_success() {
let dir = tmpdir("pena_convert_success");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.md");
fs::write(&file, "# Title\n\nParagraph.").unwrap();
let result = convert_file(file.to_string_lossy().to_string());
fs::remove_dir_all(&dir).unwrap();
assert!(result.is_ok());
assert!(result.unwrap().contains("<h1>"));
}
#[test]
fn convert_file_with_table() {
let dir = tmpdir("pena_convert_table");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("table.md");
fs::write(&file, "| A | B |\n|---|---|\n| 1 | 2 |").unwrap();
let result = convert_file(file.to_string_lossy().to_string());
fs::remove_dir_all(&dir).unwrap();
assert!(result.is_ok());
assert!(result.unwrap().contains("<table>"));
}
#[test]
fn convert_file_not_found() {
let result = convert_file("/nonexistent/path/does/not/exist.md".to_string());
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]
fn list_md_files_sorted() {
let dir = tmpdir("pena_list_sorted");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("z.md"), "").unwrap();
fs::write(dir.join("a.md"), "").unwrap();
fs::write(dir.join("m.md"), "").unwrap();
let result = list_md_files(dir.to_string_lossy().to_string()).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 3);
assert!(result[0] < result[1] && result[1] < result[2]);
}
#[test]
fn list_md_files_not_a_dir() {
let result = list_md_files("/nonexistent/does/not/exist".to_string());
assert!(result.is_err());
}
}
+54
View File
@@ -0,0 +1,54 @@
use crate::application::theme_service;
use crate::infrastructure::theme_repository::StaticThemeRepository;
use serde::Serialize;
#[derive(Serialize)]
pub struct ThemeDto {
pub id: String,
pub label: String,
}
#[tauri::command]
pub fn list_themes() -> Vec<ThemeDto> {
let repo = StaticThemeRepository;
theme_service::list_themes(&repo)
.into_iter()
.map(|t| ThemeDto { id: t.id, label: t.label })
.collect()
}
#[tauri::command]
pub fn get_theme_css(id: String) -> Option<String> {
let repo = StaticThemeRepository;
theme_service::get_theme_css(&repo, &id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_themes_returns_non_empty() {
let themes = list_themes();
assert!(!themes.is_empty());
}
#[test]
fn list_themes_ids_are_non_empty() {
for theme in list_themes() {
assert!(!theme.id.is_empty());
assert!(!theme.label.is_empty());
}
}
#[test]
fn get_theme_css_known() {
assert!(get_theme_css("dark".into()).is_some());
assert!(get_theme_css("shell-indigo".into()).is_some());
}
#[test]
fn get_theme_css_unknown_returns_none() {
assert!(get_theme_css("does-not-exist".into()).is_none());
}
}
-176
View File
@@ -1,176 +0,0 @@
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 {
ComrakPreviewRenderer {
theme: "base16-ocean.dark".to_string(),
}
.render(&content)
}
pub fn convert_file(path: String) -> Result<String, String> {
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<()> {
file_repository::collect_md_files(dir, result)
}
pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
let path = Path::new(&dir);
if !path.is_dir() {
return Err(format!("{dir} n'est pas un dossier"));
}
let mut files = Vec::new();
collect_md_files(path, &mut files).map_err(|e| e.to_string())?;
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn tmpdir(name: &str) -> PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn render_markdown_heading() {
let html = render_markdown("# Hello\n".to_string());
assert!(html.contains("<h1>"));
assert!(html.contains("Hello"));
}
#[test]
fn render_markdown_code_block() {
let html = render_markdown("```rust\nfn main() {}\n```\n".to_string());
assert!(html.contains("<code") || html.contains("<pre>"));
}
#[test]
fn render_markdown_empty() {
let html = render_markdown(String::new());
assert!(!html.contains("<h1>"));
}
#[test]
fn convert_file_success() {
let dir = tmpdir("pena_convert_success");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.md");
fs::write(&file, "# Title\n\nParagraph.").unwrap();
let result = convert_file(file.to_string_lossy().to_string());
fs::remove_dir_all(&dir).unwrap();
assert!(result.is_ok());
assert!(result.unwrap().contains("<h1>"));
}
#[test]
fn convert_file_with_table() {
let dir = tmpdir("pena_convert_table");
fs::create_dir_all(&dir).unwrap();
let file = dir.join("table.md");
fs::write(&file, "| A | B |\n|---|---|\n| 1 | 2 |").unwrap();
let result = convert_file(file.to_string_lossy().to_string());
fs::remove_dir_all(&dir).unwrap();
assert!(result.is_ok());
assert!(result.unwrap().contains("<table>"));
}
#[test]
fn convert_file_not_found() {
let result = convert_file("/nonexistent/path/does/not/exist.md".to_string());
assert!(result.is_err());
}
#[test]
fn collect_md_flat() {
let dir = tmpdir("pena_collect_flat");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.md"), "").unwrap();
fs::write(dir.join("b.md"), "").unwrap();
fs::write(dir.join("c.txt"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
result.sort();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 2);
assert!(result[0].ends_with("a.md"));
assert!(result[1].ends_with("b.md"));
}
#[test]
fn collect_md_recursive() {
let dir = tmpdir("pena_collect_recursive");
let sub = dir.join("docs");
fs::create_dir_all(&sub).unwrap();
fs::write(dir.join("Home.md"), "").unwrap();
fs::write(sub.join("Page.md"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn collect_md_ignores_hidden_dir() {
let dir = tmpdir("pena_collect_hidden_dir");
let hidden = dir.join(".hidden");
fs::create_dir_all(&hidden).unwrap();
fs::write(dir.join("visible.md"), "").unwrap();
fs::write(hidden.join("secret.md"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("visible.md"));
}
#[test]
fn collect_md_ignores_hidden_file() {
let dir = tmpdir("pena_collect_hidden_file");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("visible.md"), "").unwrap();
fs::write(dir.join(".hidden.md"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("visible.md"));
}
#[test]
fn collect_md_nonexistent_dir() {
let mut result = Vec::new();
let err = collect_md_files(Path::new("/nonexistent/path/for/pena"), &mut result);
assert!(err.is_err());
}
#[test]
fn list_md_files_sorted() {
let dir = tmpdir("pena_list_sorted");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("z.md"), "").unwrap();
fs::write(dir.join("a.md"), "").unwrap();
fs::write(dir.join("m.md"), "").unwrap();
let result = list_md_files(dir.to_string_lossy().to_string()).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 3);
assert!(result[0] < result[1] && result[1] < result[2]);
}
#[test]
fn list_md_files_not_a_dir() {
let result = list_md_files("/nonexistent/does/not/exist".to_string());
assert!(result.is_err());
}
}
+1
View File
@@ -1,4 +1,5 @@
mod markdown; mod markdown;
pub mod theme;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use markdown::{MarkdownRenderer, RenderOptions}; pub use markdown::{MarkdownRenderer, RenderOptions};
+39
View File
@@ -0,0 +1,39 @@
pub struct Theme {
pub id: String,
pub label: String,
}
pub trait ThemeRepository: Send + Sync {
fn list(&self) -> Vec<Theme>;
fn get_css(&self, id: &str) -> Option<String>;
}
#[cfg(test)]
mod tests {
use super::*;
struct StubRepo;
impl ThemeRepository for StubRepo {
fn list(&self) -> Vec<Theme> {
vec![Theme { id: "a".into(), label: "A".into() }]
}
fn get_css(&self, id: &str) -> Option<String> {
if id == "a" { Some(".x{}".into()) } else { None }
}
}
#[test]
fn theme_fields_accessible() {
let t = Theme { id: "dark".into(), label: "Mode sombre".into() };
assert_eq!(t.id, "dark");
assert_eq!(t.label, "Mode sombre");
}
#[test]
fn repository_trait_object_works() {
let repo: Box<dyn ThemeRepository> = Box::new(StubRepo);
assert_eq!(repo.list().len(), 1);
assert!(repo.get_css("a").is_some());
assert!(repo.get_css("unknown").is_none());
}
}
@@ -1,19 +1,31 @@
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 syntect::parsing::SyntaxDefinition;
use crate::domain::MarkdownRenderer; use crate::domain::MarkdownRenderer;
pub struct ComrakRenderer { const TYPESCRIPT_SYNTAX: &str =
pub theme: String, include_str!("../../resources/syntaxes/TypeScript.sublime-syntax");
fn build_syntax_set() -> syntect::parsing::SyntaxSet {
let mut builder = syntect::parsing::SyntaxSet::load_defaults_nonewlines().into_builder();
if let Ok(def) = SyntaxDefinition::load_from_str(TYPESCRIPT_SYNTAX, true, None) {
builder.add(def);
}
builder.build()
} }
pub struct ComrakPreviewRenderer { pub struct ComrakRenderer;
pub theme: String, pub struct ComrakPreviewRenderer;
}
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()
.syntax_set(build_syntax_set())
.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 +39,70 @@ 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()
.syntax_set(build_syntax_set())
.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 typescript_syntax_is_highlighted() {
let r = ComrakRenderer;
let html = r.render("```typescript\ninterface Foo { id: string; }\n```\n");
assert!(html.contains("<span class="), "TypeScript should produce highlighted spans");
}
#[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());
}
}
@@ -23,3 +23,76 @@ pub fn collect_md_files(dir: &Path, result: &mut Vec<String>) -> std::io::Result
pub fn read_file(path: &str) -> Result<String, String> { pub fn read_file(path: &str) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| e.to_string()) fs::read_to_string(path).map_err(|e| e.to_string())
} }
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn tmpdir(name: &str) -> PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn collect_md_flat() {
let dir = tmpdir("pena_collect_flat");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.md"), "").unwrap();
fs::write(dir.join("b.md"), "").unwrap();
fs::write(dir.join("c.txt"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
result.sort();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 2);
assert!(result[0].ends_with("a.md"));
assert!(result[1].ends_with("b.md"));
}
#[test]
fn collect_md_recursive() {
let dir = tmpdir("pena_collect_recursive");
let sub = dir.join("docs");
fs::create_dir_all(&sub).unwrap();
fs::write(dir.join("Home.md"), "").unwrap();
fs::write(sub.join("Page.md"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn collect_md_ignores_hidden_dir() {
let dir = tmpdir("pena_collect_hidden_dir");
let hidden = dir.join(".hidden");
fs::create_dir_all(&hidden).unwrap();
fs::write(dir.join("visible.md"), "").unwrap();
fs::write(hidden.join("secret.md"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("visible.md"));
}
#[test]
fn collect_md_ignores_hidden_file() {
let dir = tmpdir("pena_collect_hidden_file");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("visible.md"), "").unwrap();
fs::write(dir.join(".hidden.md"), "").unwrap();
let mut result = Vec::new();
collect_md_files(&dir, &mut result).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("visible.md"));
}
#[test]
fn collect_md_nonexistent_dir() {
let mut result = Vec::new();
let err = collect_md_files(Path::new("/nonexistent/path/for/pena"), &mut result);
assert!(err.is_err());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod comrak_renderer; pub mod comrak_renderer;
pub mod file_repository; pub mod file_repository;
pub mod notify_watcher; pub mod notify_watcher;
pub mod theme_repository;
@@ -64,3 +64,44 @@ fn is_relevant_path(path: &Path) -> bool {
} }
path.extension().is_some_and(|e| e == "md") path.extension().is_some_and(|e| e == "md")
} }
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn relevant_path_md_file() {
assert!(is_relevant_path(Path::new("/docs/notes.md")));
}
#[test]
fn relevant_path_nested_md() {
assert!(is_relevant_path(Path::new("/wiki/section/page.md")));
}
#[test]
fn irrelevant_path_txt_extension() {
assert!(!is_relevant_path(Path::new("/docs/readme.txt")));
}
#[test]
fn irrelevant_path_no_extension() {
assert!(!is_relevant_path(Path::new("/docs/makefile")));
}
#[test]
fn irrelevant_path_hidden_file() {
assert!(!is_relevant_path(Path::new("/docs/.hidden.md")));
}
#[test]
fn irrelevant_path_hidden_dir() {
assert!(!is_relevant_path(Path::new("/docs/.git/file.md")));
}
#[test]
fn irrelevant_path_hidden_dir_at_root() {
assert!(!is_relevant_path(Path::new(".hidden/file.md")));
}
}
@@ -0,0 +1,68 @@
use crate::domain::theme::{Theme, ThemeRepository};
const DEFAULT_CSS: &str = include_str!("../../resources/themes/default.css");
const DARK_CSS: &str = include_str!("../../resources/themes/dark.css");
const SEPIA_CSS: &str = include_str!("../../resources/themes/sepia.css");
const LARGE_TEXT_CSS: &str = include_str!("../../resources/themes/large-text.css");
const EMERALD_CSS: &str = include_str!("../../resources/themes/emerald.css");
const SHELL_INDIGO_CSS: &str = include_str!("../../resources/themes/shell-indigo.css");
pub struct StaticThemeRepository;
impl ThemeRepository for StaticThemeRepository {
fn list(&self) -> Vec<Theme> {
vec![
Theme { id: "default".into(), label: "Défaut".into() },
Theme { id: "dark".into(), label: "Mode sombre".into() },
Theme { id: "sepia".into(), label: "Sépia".into() },
Theme { id: "large-text".into(), label: "Grand texte".into() },
Theme { id: "emerald".into(), label: "Accent émeraude".into() },
Theme { id: "shell-indigo".into(), label: "Shell Indigo".into() },
]
}
fn get_css(&self, id: &str) -> Option<String> {
match id {
"default" => Some(DEFAULT_CSS.to_string()),
"dark" => Some(DARK_CSS.to_string()),
"sepia" => Some(SEPIA_CSS.to_string()),
"large-text" => Some(LARGE_TEXT_CSS.to_string()),
"emerald" => Some(EMERALD_CSS.to_string()),
"shell-indigo" => Some(SHELL_INDIGO_CSS.to_string()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::theme::ThemeRepository;
#[test]
fn list_returns_all_themes() {
let repo = StaticThemeRepository;
let themes = repo.list();
assert_eq!(themes.len(), 6);
let ids: Vec<&str> = themes.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"default"));
assert!(ids.contains(&"dark"));
assert!(ids.contains(&"shell-indigo"));
}
#[test]
fn get_css_known_themes() {
let repo = StaticThemeRepository;
for id in ["default", "dark", "sepia", "large-text", "emerald", "shell-indigo"] {
let css = repo.get_css(id);
assert!(css.is_some(), "CSS manquant pour le thème {id}");
assert!(!css.unwrap().is_empty());
}
}
#[test]
fn get_css_unknown_returns_none() {
let repo = StaticThemeRepository;
assert!(repo.get_css("nonexistent").is_none());
}
}
+11 -23
View File
@@ -1,37 +1,25 @@
mod core; mod application;
mod commands;
mod domain; mod domain;
mod infrastructure; mod infrastructure;
mod watcher;
use std::sync::Mutex; use std::sync::Mutex;
#[tauri::command]
fn render_markdown(content: String) -> String {
core::render_markdown(content)
}
#[tauri::command]
fn convert_file(path: String) -> Result<String, String> {
core::convert_file(path)
}
#[tauri::command]
fn list_md_files(dir: String) -> Result<Vec<String>, String> {
core::list_md_files(dir)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(watcher::WatcherState(Mutex::new(None))) .manage(commands::watch::WatcherState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
render_markdown, commands::render::render_markdown,
convert_file, commands::render::convert_file,
list_md_files, commands::render::list_md_files,
watcher::start_watch, commands::render::get_syntax_highlight_css,
watcher::stop_watch, commands::watch::start_watch,
commands::watch::stop_watch,
commands::theme::list_themes,
commands::theme::get_theme_css,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Erreur lors du démarrage de l'application Tauri"); .expect("Erreur lors du démarrage de l'application Tauri");

Before

Width:  |  Height:  |  Size: 993 B

After

Width:  |  Height:  |  Size: 993 B

+16 -5
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>
@@ -20,6 +19,8 @@
</div> </div>
</div> </div>
<div id="app-content">
<div id="view-home"> <div id="view-home">
<h1 class="home-title">Pena</h1> <h1 class="home-title">Pena</h1>
<div class="home-buttons"> <div class="home-buttons">
@@ -33,12 +34,11 @@
</div> </div>
<div id="view-reader" class="hidden"> <div id="view-reader" class="hidden">
<aside id="sidebar" class="sidebar"> <div class="sidebar">
<aside id="sidebar" class="sidebar__scroll">
<!-- contenu généré par JS --> <!-- contenu généré par JS -->
</aside> </aside>
<div id="content" class="content"> <div class="sidebar__footer">
<!-- contenu généré par JS -->
</div>
<button id="btn-customize-css" class="btn-customize-css"> <button id="btn-customize-css" class="btn-customize-css">
<svg class="btn-customize-css__icon" width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <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"/>
@@ -47,6 +47,13 @@
</button> </button>
<button id="btn-back" class="btn-back">← Accueil</button> <button id="btn-back" class="btn-back">← Accueil</button>
</div> </div>
</div>
<div id="content" class="content">
<!-- contenu généré par JS -->
</div>
</div>
</div><!-- #app-content -->
<!-- Modale CSS --> <!-- Modale CSS -->
<div id="css-modal-overlay" class="css-modal-overlay hidden"> <div id="css-modal-overlay" class="css-modal-overlay hidden">
@@ -66,6 +73,10 @@
<button class="css-modal__tab" data-tab="avance">Développement</button> <button class="css-modal__tab" data-tab="avance">Développement</button>
</div> </div>
<div class="css-modal__body"> <div class="css-modal__body">
<div class="css-modal__quick-themes">
<p class="css-modal__quick-themes-label">Thèmes rapides</p>
<div id="quick-themes-list" class="css-modal__quick-themes-list"></div>
</div>
<p class="css-modal__description">Personnalisez l'apparence de vos pages en modifiant le CSS ci-dessous. Les styles s'appliquent à l'onglet actif.</p> <p class="css-modal__description">Personnalisez l'apparence de vos pages en modifiant le CSS ci-dessous. Les styles s'appliquent à l'onglet actif.</p>
<textarea id="css-editor" class="css-modal__editor" placeholder="/* Entrez votre CSS ici */&#10;/* Exemple : .content { font-size: 18px; } */"></textarea> <textarea id="css-editor" class="css-modal__editor" placeholder="/* Entrez votre CSS ici */&#10;/* Exemple : .content { font-size: 18px; } */"></textarea>
</div> </div>
+5 -313
View File
@@ -1,7 +1,6 @@
let currentPath = null; import { showHome, openPath } from './router.js';
let currentMode = null; // 'file' | 'dir' import { initCssModal } from './ui/css-modal.js';
const titlebarTitle = document.getElementById('titlebar-title');
const win = window.__TAURI__.window.getCurrentWindow(); const win = window.__TAURI__.window.getCurrentWindow();
document.getElementById('titlebar').addEventListener('mousedown', (e) => { document.getElementById('titlebar').addEventListener('mousedown', (e) => {
@@ -16,239 +15,6 @@ document.getElementById('titlebar-maximize').addEventListener('click', async ()
}); });
document.getElementById('titlebar-close').addEventListener('click', () => win.close()); document.getElementById('titlebar-close').addEventListener('click', () => win.close());
const viewHome = document.getElementById('view-home');
const viewReader = document.getElementById('view-reader');
const sidebar = document.querySelector('#sidebar');
const content = document.querySelector('#content');
const RECENTS_KEY = 'pena_recents';
const RECENTS_MAX = 8;
function loadRecents() {
try { return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); }
catch { return []; }
}
function saveRecent(path, mode) {
const name = path.replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
const parent = path.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
const recents = loadRecents().filter(r => r.path !== path);
recents.unshift({ path, mode, name, parent });
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, RECENTS_MAX)));
renderRecents();
}
function renderRecents() {
const recents = loadRecents();
const container = document.getElementById('home-recents');
const list = document.getElementById('home-recents-list');
if (recents.length === 0) {
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
list.innerHTML = recents.map(r => {
const icon = r.mode === 'dir' ? '📁' : '📄';
const escaped = escapeHtml(r.path);
return `<li>
<button class="home-recents__item" data-path="${escaped}" data-mode="${r.mode}">
<span class="home-recents__item__icon">${icon}</span>
<span class="home-recents__item__info">
<span class="home-recents__item__name">${escapeHtml(r.name)}</span>
<span class="home-recents__item__path">${escapeHtml(r.parent)}</span>
</span>
</button>
</li>`;
}).join('');
list.querySelectorAll('.home-recents__item').forEach(btn => {
btn.addEventListener('click', () => openPath(btn.dataset.path, btn.dataset.mode));
});
}
function showHome() {
viewReader.classList.add('hidden');
viewHome.classList.remove('hidden');
}
function showReader() {
viewHome.classList.add('hidden');
viewReader.classList.remove('hidden');
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildTree(relPaths, sep) {
const tree = { _files: [], _dirs: {} };
relPaths.forEach((rel) => {
const parts = rel.split(sep);
let node = tree;
for (let i = 0; i < parts.length - 1; i++) {
const dir = parts[i];
if (!node._dirs[dir]) node._dirs[dir] = { _files: [], _dirs: {} };
node = node._dirs[dir];
}
node._files.push(rel);
});
return tree;
}
function renderTree(node, prefix, sep, currentRel, depth) {
let html = '';
const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : '';
node._files.slice().sort().forEach((rel) => {
const label = rel.split(sep).pop().replace(/\.md$/i, '').replace(/-/g, ' ');
const abs = prefix + rel;
const cls = rel === currentRel ? ' active' : '';
if (depth === 0) {
html += `\n<li><a href="#" class="nav-root-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
} else {
html += `\n<li${depthClass}><a href="#" class="nav-file-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
}
});
Object.keys(node._dirs).sort().forEach((dirName) => {
const child = node._dirs[dirName];
const isOpen = currentRel && currentRel.split(sep).includes(dirName);
const openAttr = isOpen ? ' open' : '';
const homeFile = child._files.find((f) => /^home\.md$/i.test(f.split(sep).pop()));
let folderLabel;
if (homeFile) {
const abs = prefix + homeFile;
const cls = homeFile === currentRel ? ' active' : '';
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(dirName)}</a>`;
} else {
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
}
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
const childNode = {
_files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files,
_dirs: child._dirs,
};
html += `\n<li${depthClass}><details class="nav-folder"${openAttr}>`;
html += `\n <summary>${arrow}${folderLabel}</summary>`;
html += `\n <ul class="nav-tree">`;
html += renderTree(childNode, prefix, sep, currentRel, depth + 1);
html += `\n </ul>\n</details></li>`;
});
return html;
}
function buildSidebar(baseDir, files, currentFile) {
const sep = baseDir.includes('\\') ? '\\' : '/';
const dirName = baseDir.split(sep).pop();
const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
const toRel = abs => abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
const currentRel = currentFile ? toRel(currentFile) : null;
const relPaths = files.map(toRel);
const tree = buildTree(relPaths, sep);
let html = `<div class="sidebar__title-block">
<h1 class="sidebar__title-block__title">${escapeHtml(dirName)}</h1>
</div>
<ul class="sidebar__menu nav-tree">`;
html += renderTree(tree, prefix, sep, currentRel, 0);
html += `\n </ul>`;
return html;
}
function renderSidebar(baseDir, files, currentFile) {
sidebar.innerHTML = buildSidebar(baseDir, files, currentFile);
sidebar.querySelectorAll('a[data-path]').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
loadPage(a.dataset.path);
});
});
}
async function loadPage(filePath) {
try {
const html = await window.__TAURI__.core.invoke('convert_file', { path: filePath });
content.innerHTML = html;
const basename = filePath.split('/').pop().split('\\').pop();
const title = basename.replace(/\.md$/i, '');
await win.setTitle(title);
titlebarTitle.textContent = title;
if (currentMode === 'dir' || currentMode === 'file') {
sidebar.querySelectorAll('a').forEach(a => {
a.classList.toggle('active', a.dataset.path === filePath);
});
const active = sidebar.querySelector('a[data-path].active');
if (active) {
let el = active.parentElement;
while (el && el !== sidebar) {
if (el.tagName === 'DETAILS') el.open = true;
el = el.parentElement;
}
}
}
} catch (err) {
content.innerHTML = `<p class="error">Impossible de charger le fichier : ${err}</p>`;
}
}
async function openPath(path, mode) {
currentPath = path;
currentMode = mode;
saveRecent(path, mode);
if (mode === 'file') {
showReader();
const sep = path.includes('\\') ? '\\' : '/';
const parentDir = path.split(sep).slice(0, -1).join(sep);
try {
const files = await window.__TAURI__.core.invoke('list_md_files', { dir: parentDir });
sidebar.classList.remove('hidden');
content.style.marginLeft = '';
renderSidebar(parentDir, files, path);
} catch {
sidebar.innerHTML = '';
sidebar.classList.add('hidden');
content.style.marginLeft = '0';
}
await loadPage(path);
return;
}
// mode === 'dir'
sidebar.classList.remove('hidden');
content.style.marginLeft = '';
try {
const files = await window.__TAURI__.core.invoke('list_md_files', { dir: path });
showReader();
const sep = path.includes('\\') ? '\\' : '/';
const home = files.find(f => {
const name = f.split(sep).pop();
return name === 'Home.md' || name === 'home.md';
});
const firstFile = home ?? files[0];
renderSidebar(path, files, firstFile);
await loadPage(firstFile);
} catch (err) {
showReader();
content.innerHTML = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
}
}
renderRecents();
document.getElementById('btn-open-file').addEventListener('click', async () => { document.getElementById('btn-open-file').addEventListener('click', async () => {
const selected = await window.__TAURI__.dialog.open({ const selected = await window.__TAURI__.dialog.open({
multiple: false, multiple: false,
@@ -264,81 +30,7 @@ document.getElementById('btn-open-dir').addEventListener('click', async () => {
await openPath(selected, 'dir'); await openPath(selected, 'dir');
}); });
document.getElementById('btn-back').addEventListener('click', () => { document.getElementById('btn-back').addEventListener('click', showHome);
currentPath = null;
currentMode = null; await initCssModal();
sidebar.innerHTML = '';
win.setTitle('Pena — Markdown Viewer');
titlebarTitle.textContent = 'Pena — Markdown Viewer';
showHome(); showHome();
});
// ── CSS personnalisé ──
const CSS_TABS = ['general', 'police', 'contenu', 'avance'];
let activeTab = CSS_TABS[0];
function cssKey(tab) { return `pena_css_${tab}`; }
function loadTabCss(tab) { return localStorage.getItem(cssKey(tab)) ?? ''; }
function saveTabCss(tab, css) {
if (css.trim()) localStorage.setItem(cssKey(tab), css);
else localStorage.removeItem(cssKey(tab));
}
function buildCombinedCss() {
return CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n');
}
function applyCustomCss() {
let styleEl = document.getElementById('pena-custom-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'pena-custom-style';
document.head.appendChild(styleEl);
}
styleEl.textContent = buildCombinedCss();
}
applyCustomCss();
const cssModalOverlay = document.getElementById('css-modal-overlay');
const cssEditor = document.getElementById('css-editor');
function openCssModal() {
cssEditor.value = loadTabCss(activeTab);
cssModalOverlay.classList.remove('hidden');
}
function closeCssModal() {
cssModalOverlay.classList.add('hidden');
}
document.getElementById('btn-customize-css').addEventListener('click', openCssModal);
document.getElementById('btn-css-close').addEventListener('click', closeCssModal);
document.getElementById('btn-css-cancel').addEventListener('click', closeCssModal);
cssModalOverlay.addEventListener('click', (e) => {
if (e.target === cssModalOverlay) closeCssModal();
});
document.getElementById('css-tabs').addEventListener('click', (e) => {
const tab = e.target.closest('.css-modal__tab');
if (!tab) return;
saveTabCss(activeTab, cssEditor.value);
activeTab = tab.dataset.tab;
document.querySelectorAll('.css-modal__tab').forEach(t => t.classList.toggle('active', t === tab));
cssEditor.value = loadTabCss(activeTab);
});
document.getElementById('btn-css-apply').addEventListener('click', () => {
saveTabCss(activeTab, cssEditor.value);
applyCustomCss();
closeCssModal();
});
document.getElementById('btn-css-reset').addEventListener('click', () => {
CSS_TABS.forEach(t => localStorage.removeItem(cssKey(t)));
cssEditor.value = '';
applyCustomCss();
});
+74
View File
@@ -0,0 +1,74 @@
import { appState } from './state/app-state.js';
import { listMdFiles } from './services/files.js';
import { renderSidebar } from './ui/sidebar.js';
import { loadPage } from './ui/reader.js';
import { saveRecent, renderRecents } from './ui/home.js';
const viewHome = document.getElementById('view-home');
const viewReader = document.getElementById('view-reader');
const sidebarEl = document.querySelector('#sidebar');
const content = document.querySelector('#content');
const win = window.__TAURI__.window.getCurrentWindow();
const titlebarTitle = document.getElementById('titlebar-title');
export function showHome() {
appState.currentPath = null;
appState.currentMode = null;
sidebarEl.innerHTML = '';
win.setTitle('Pena — Markdown Viewer');
titlebarTitle.textContent = 'Pena — Markdown Viewer';
viewReader.classList.add('hidden');
viewHome.classList.remove('hidden');
renderRecents(openPath);
}
export function showReader() {
viewHome.classList.add('hidden');
viewReader.classList.remove('hidden');
}
export async function openPath(path, mode) {
appState.currentPath = path;
appState.currentMode = mode;
saveRecent(path, mode);
if (mode === 'file') {
showReader();
const sep = path.includes('\\') ? '\\' : '/';
const parentDir = path.split(sep).slice(0, -1).join(sep);
try {
const files = await listMdFiles(parentDir);
sidebarEl.classList.remove('hidden');
content.style.marginLeft = '';
renderSidebar(parentDir, files, path, filePath => loadPage(filePath, appState.currentMode, sidebarEl));
} catch {
sidebarEl.innerHTML = '';
sidebarEl.classList.add('hidden');
content.style.marginLeft = '0';
}
await loadPage(path, appState.currentMode, sidebarEl);
return;
}
// mode === 'dir'
sidebarEl.classList.remove('hidden');
content.style.marginLeft = '';
try {
const files = await listMdFiles(path);
showReader();
const sep = path.includes('\\') ? '\\' : '/';
const home = files.find(f => {
const name = f.split(sep).pop();
return name === 'Home.md' || name === 'home.md';
});
const firstFile = home ?? files[0];
renderSidebar(path, files, firstFile, filePath => loadPage(filePath, appState.currentMode, sidebarEl));
await loadPage(firstFile, appState.currentMode, sidebarEl);
} catch (err) {
showReader();
content.innerHTML = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function listMdFiles(dir) {
return window.__TAURI__.core.invoke('list_md_files', { dir });
}
+7
View File
@@ -0,0 +1,7 @@
export function renderMarkdown(content) {
return window.__TAURI__.core.invoke('render_markdown', { content });
}
export function convertFile(path) {
return window.__TAURI__.core.invoke('convert_file', { path });
}
+9
View File
@@ -0,0 +1,9 @@
const { invoke } = window.__TAURI__.core;
export function listThemes() {
return invoke('list_themes');
}
export function getThemeCss(id) {
return invoke('get_theme_css', { id });
}
+11
View File
@@ -0,0 +1,11 @@
export function startWatch(path) {
return window.__TAURI__.core.invoke('start_watch', { path });
}
export function stopWatch() {
return window.__TAURI__.core.invoke('stop_watch');
}
export function onFileChanged(callback) {
return window.__TAURI__.event.listen('file-changed', callback);
}
+1
View File
@@ -0,0 +1 @@
export const appState = { currentPath: null, currentMode: null };
+142 -22
View File
@@ -8,7 +8,16 @@ body {
color: #222; color: #222;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-x: hidden; overflow: hidden;
}
#app-content {
position: fixed;
top: 36px;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
} }
/* ── Titlebar ── */ /* ── Titlebar ── */
@@ -78,8 +87,7 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100%;
padding-top: 36px;
background: #1a1b2e; background: #1a1b2e;
} }
@@ -206,6 +214,23 @@ body {
z-index: 99; z-index: 99;
overflow: hidden; overflow: hidden;
} }
.sidebar__scroll {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar__footer {
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 4px 0 8px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.sidebar__title-block { .sidebar__title-block {
margin: 32px 24px 8px; margin: 32px 24px 8px;
flex-shrink: 0; flex-shrink: 0;
@@ -233,7 +258,7 @@ body {
.sidebar__menu { .sidebar__menu {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 8px 0 48px 0; padding: 8px 0;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
@@ -299,7 +324,11 @@ body {
transition: color 0.2s; transition: color 0.2s;
} }
.nav-root-link:hover { color: #ff5577; } .nav-root-link:hover { color: #ff5577; }
.nav-root-link.active { color: #ff5577; font-weight: 500; } .nav-root-link.active {
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
font-weight: 500;
}
.nav-folder { margin: 0; } .nav-folder { margin: 0; }
.nav-folder > summary { .nav-folder > summary {
@@ -367,7 +396,11 @@ body {
transition: color 0.2s; transition: color 0.2s;
} }
.nav-file-link:hover { color: #ff5577; } .nav-file-link:hover { color: #ff5577; }
.nav-file-link.active { color: #ff5577; font-weight: 500; } .nav-file-link.active {
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
font-weight: 500;
}
.nav-depth-1 > .nav-file-link { padding: 5px 24px 5px 52px; } .nav-depth-1 > .nav-file-link { padding: 5px 24px 5px 52px; }
.nav-depth-1 > .nav-folder > summary { padding-left: 24px; } .nav-depth-1 > .nav-folder > summary { padding-left: 24px; }
@@ -379,12 +412,12 @@ body {
/* ── Content ── */ /* ── Content ── */
.content { .content {
margin-left: 264px; margin-left: 264px;
padding-top: 84px; padding-top: 48px;
padding-right: 72px; padding-right: 72px;
padding-bottom: 96px; padding-bottom: 96px;
padding-left: 72px; padding-left: 72px;
max-width: 1080px; max-width: 1080px;
min-height: 100vh; min-height: 100%;
background: #fff; background: #fff;
} }
@@ -418,8 +451,69 @@ 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 code { background: none; padding: 0; font-weight: 400; font-size: 13px; } .content pre:not(.syntax-highlighting) { background: #f4f4f7; }
.content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; border: none; border-radius: 0; color: inherit; }
/* ── Bouton « copier » des blocs de code ── */
.content .code-block { position: relative; }
.content .code-block .copy-code-btn {
position: absolute;
top: 24px;
right: 12px;
padding: 4px 10px;
font-family: inherit;
font-size: 12px;
line-height: 1.2;
color: #c0c5ce;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease;
}
.content .code-block:hover .copy-code-btn,
.content .code-block .copy-code-btn:focus-visible { opacity: 1; }
.content .code-block .copy-code-btn:hover { background: rgba(255, 255, 255, 0.18); }
.content .code-block .copy-code-btn.copied { color: #a3be8c; border-color: rgba(163, 190, 140, 0.5); }
/* ── Coloration syntaxique (base16-ocean.dark) ── */
.syntax-highlighting {
color: #c0c5ce;
background-color: #2b303b;
}
.syntax-highlighting .variable.parameter.function { color: #c0c5ce; }
.syntax-highlighting .comment, .syntax-highlighting .punctuation.definition.comment { color: #65737e; }
.syntax-highlighting .punctuation.definition.string, .syntax-highlighting .punctuation.definition.variable, .syntax-highlighting .punctuation.definition.parameters, .syntax-highlighting .punctuation.definition.array { color: #c0c5ce; }
.syntax-highlighting .keyword.operator { color: #c0c5ce; }
.syntax-highlighting .keyword { color: #b48ead; }
.syntax-highlighting .variable, .syntax-highlighting .variable.other.dollar.only.js { color: #bf616a; }
.syntax-highlighting .entity.name.function, .syntax-highlighting .meta.require, .syntax-highlighting .support.function.any-method, .syntax-highlighting .variable.function { color: #8fa1b3; }
.syntax-highlighting .support.class, .syntax-highlighting .entity.name.class, .syntax-highlighting .entity.name.type.class { color: #ebcb8b; }
.syntax-highlighting .meta.class { color: #eff1f5; }
.syntax-highlighting .keyword.other.special-method { color: #8fa1b3; }
.syntax-highlighting .storage { color: #b48ead; }
.syntax-highlighting .support.function { color: #96b5b4; }
.syntax-highlighting .string, .syntax-highlighting .constant.other.symbol, .syntax-highlighting .entity.other.inherited-class { color: #a3be8c; }
.syntax-highlighting .constant.numeric, .syntax-highlighting .constant { color: #d08770; }
.syntax-highlighting .entity.name.tag { color: #bf616a; }
.syntax-highlighting .entity.other.attribute-name { color: #d08770; }
.syntax-highlighting .entity.other.attribute-name.id, .syntax-highlighting .punctuation.definition.entity { color: #8fa1b3; }
.syntax-highlighting .meta.selector { color: #b48ead; }
.syntax-highlighting .markup.heading .punctuation.definition.heading, .syntax-highlighting .entity.name.section { color: #8fa1b3; }
.syntax-highlighting .keyword.other.unit { color: #d08770; }
.syntax-highlighting .markup.bold, .syntax-highlighting .punctuation.definition.bold { color: #ebcb8b; font-weight: bold; }
.syntax-highlighting .markup.italic, .syntax-highlighting .punctuation.definition.italic { color: #b48ead; font-style: italic; }
.syntax-highlighting .markup.raw.inline { color: #a3be8c; }
.syntax-highlighting .string.other.link { color: #bf616a; }
.syntax-highlighting .meta.separator { color: #c0c5ce; background-color: #4f5b66; }
.syntax-highlighting .markup.inserted, .syntax-highlighting .markup.inserted.git_gutter { color: #a3be8c; }
.syntax-highlighting .markup.deleted, .syntax-highlighting .markup.deleted.git_gutter { color: #bf616a; }
.syntax-highlighting .markup.changed, .syntax-highlighting .markup.changed.git_gutter { color: #b48ead; }
.syntax-highlighting .constant.other.color, .syntax-highlighting .string.regexp, .syntax-highlighting .constant.character.escape { color: #96b5b4; }
.syntax-highlighting .punctuation.section.embedded, .syntax-highlighting .variable.interpolation { color: #ab7967; }
.syntax-highlighting .invalid.illegal { color: #2b303b; background-color: #bf616a; }
.content blockquote { .content blockquote {
border-left: 3px solid rgba(34,34,34,0.25); border-left: 3px solid rgba(34,34,34,0.25);
@@ -459,11 +553,8 @@ body {
/* ── Customize CSS button ── */ /* ── Customize CSS button ── */
.btn-customize-css { .btn-customize-css {
position: fixed; width: calc(100% - 24px);
bottom: 60px; margin: 4px 12px 0;
left: 12px;
width: 240px;
z-index: 100;
background: transparent; background: transparent;
color: rgba(255,255,255,0.7); color: rgba(255,255,255,0.7);
border: 1px solid rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.15);
@@ -624,13 +715,43 @@ body {
.css-modal__btn--ghost { background: transparent; color: #777; border: 1px solid #ddd; } .css-modal__btn--ghost { background: transparent; color: #777; border: 1px solid #ddd; }
.css-modal__btn--ghost:hover { background: #f0f0f4; color: #444; } .css-modal__btn--ghost:hover { background: #f0f0f4; color: #444; }
/* ── Quick themes ── */
.css-modal__quick-themes { flex-shrink: 0; }
.css-modal__quick-themes-label {
font-size: 11px;
font-weight: 600;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 8px;
}
.css-modal__quick-themes-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.css-modal__theme-badge {
padding: 6px 14px;
border: 1px solid #ddd;
border-radius: 999px;
background: #fff;
font-family: 'Poppins', sans-serif;
font-size: 13px;
font-weight: 400;
color: #555;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.css-modal__theme-badge:hover { border-color: #bbb; color: #222; background: #f8f8f8; }
.css-modal__theme-badge.active {
border-color: #ff5577;
color: #ff5577;
background: rgba(255, 85, 119, 0.06);
}
/* ── Back button ── */ /* ── Back button ── */
.btn-back { .btn-back {
position: fixed; width: 100%;
bottom: 24px;
left: 0;
width: 264px;
z-index: 98;
background: transparent; background: transparent;
color: rgba(255,255,255,0.45); color: rgba(255,255,255,0.45);
border: none; border: none;
@@ -641,7 +762,6 @@ body {
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: color 0.2s; transition: color 0.2s;
z-index: 100;
} }
.btn-back:hover { color: #ff5577; } .btn-back:hover { color: #ff5577; }
@@ -651,7 +771,7 @@ body {
left: -300px; left: -300px;
transition: left 0.2s cubic-bezier(0.09, 0.46, 0.45, 0.94); transition: left 0.2s cubic-bezier(0.09, 0.46, 0.45, 0.94);
} }
.content { margin-left: 0; padding: 68px 24px 64px; } .content { margin-left: 0; padding: 32px 24px 64px; }
} }
@media screen and (max-width: 540px) { @media screen and (max-width: 540px) {
.content { padding: 68px 16px 48px; } .content { padding: 68px 16px 48px; }
+121
View File
@@ -0,0 +1,121 @@
import { listThemes, getThemeCss } from '../services/themes.js';
const CSS_TABS = ['general', 'police', 'contenu', 'avance'];
let activeTab = CSS_TABS[0];
const QUICK_THEME_KEY = 'pena_quick_theme';
const themeCssCache = new Map();
function cssKey(tab) { return `pena_css_${tab}`; }
function loadTabCss(tab) { return localStorage.getItem(cssKey(tab)) ?? ''; }
function saveTabCss(tab, css) {
if (css.trim()) localStorage.setItem(cssKey(tab), css);
else localStorage.removeItem(cssKey(tab));
}
function applyCustomCss() {
let styleEl = document.getElementById('pena-custom-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'pena-custom-style';
document.head.appendChild(styleEl);
}
const tabCss = CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n');
const activeTheme = localStorage.getItem(QUICK_THEME_KEY);
const themeCss = activeTheme ? (themeCssCache.get(activeTheme) ?? '') : '';
styleEl.textContent = [tabCss, themeCss].filter(Boolean).join('\n');
}
function syncThemeBadges() {
const activeTheme = localStorage.getItem(QUICK_THEME_KEY);
document.querySelectorAll('.css-modal__theme-badge').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === activeTheme);
});
}
function buildThemeBadges(themes) {
const list = document.getElementById('quick-themes-list');
list.innerHTML = '';
for (const { id, label } of themes) {
const btn = document.createElement('button');
btn.className = 'css-modal__theme-badge';
btn.dataset.theme = id;
btn.textContent = label;
btn.addEventListener('click', async () => {
const current = localStorage.getItem(QUICK_THEME_KEY);
if (current === id) {
localStorage.removeItem(QUICK_THEME_KEY);
} else {
if (!themeCssCache.has(id)) {
const css = await getThemeCss(id);
if (css) themeCssCache.set(id, css);
}
localStorage.setItem(QUICK_THEME_KEY, id);
}
applyCustomCss();
syncThemeBadges();
});
list.appendChild(btn);
}
}
async function preloadActiveThemeCss() {
const activeTheme = localStorage.getItem(QUICK_THEME_KEY);
if (activeTheme && !themeCssCache.has(activeTheme)) {
const css = await getThemeCss(activeTheme);
if (css) themeCssCache.set(activeTheme, css);
}
}
export async function initCssModal() {
const themes = await listThemes();
buildThemeBadges(themes);
await preloadActiveThemeCss();
applyCustomCss();
syncThemeBadges();
const cssModalOverlay = document.getElementById('css-modal-overlay');
const cssEditor = document.getElementById('css-editor');
function openCssModal() {
cssEditor.value = loadTabCss(activeTab);
cssModalOverlay.classList.remove('hidden');
}
function closeCssModal() {
cssModalOverlay.classList.add('hidden');
}
document.getElementById('btn-customize-css').addEventListener('click', openCssModal);
document.getElementById('btn-css-close').addEventListener('click', closeCssModal);
document.getElementById('btn-css-cancel').addEventListener('click', closeCssModal);
cssModalOverlay.addEventListener('click', (e) => {
if (e.target === cssModalOverlay) closeCssModal();
});
document.getElementById('css-tabs').addEventListener('click', (e) => {
const tab = e.target.closest('.css-modal__tab');
if (!tab) return;
saveTabCss(activeTab, cssEditor.value);
activeTab = tab.dataset.tab;
document.querySelectorAll('.css-modal__tab').forEach(t => t.classList.toggle('active', t === tab));
cssEditor.value = loadTabCss(activeTab);
});
document.getElementById('btn-css-apply').addEventListener('click', () => {
saveTabCss(activeTab, cssEditor.value);
applyCustomCss();
closeCssModal();
});
document.getElementById('btn-css-reset').addEventListener('click', () => {
CSS_TABS.forEach(t => localStorage.removeItem(cssKey(t)));
localStorage.removeItem(QUICK_THEME_KEY);
cssEditor.value = '';
applyCustomCss();
syncThemeBadges();
});
}
+44
View File
@@ -0,0 +1,44 @@
import { escapeHtml } from '../utils.js';
const RECENTS_KEY = 'pena_recents';
const RECENTS_MAX = 8;
export function loadRecents() {
try { return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); }
catch { return []; }
}
export function saveRecent(path, mode) {
const name = path.replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
const parent = path.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
const recents = loadRecents().filter(r => r.path !== path);
recents.unshift({ path, mode, name, parent });
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, RECENTS_MAX)));
}
export function renderRecents(onOpen) {
const recents = loadRecents();
const container = document.getElementById('home-recents');
const list = document.getElementById('home-recents-list');
if (recents.length === 0) {
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
list.innerHTML = recents.map(r => {
const icon = r.mode === 'dir' ? '📁' : '📄';
const escaped = escapeHtml(r.path);
return `<li>
<button class="home-recents__item" data-path="${escaped}" data-mode="${r.mode}">
<span class="home-recents__item__icon">${icon}</span>
<span class="home-recents__item__info">
<span class="home-recents__item__name">${escapeHtml(r.name)}</span>
<span class="home-recents__item__path">${escapeHtml(r.parent)}</span>
</span>
</button>
</li>`;
}).join('');
list.querySelectorAll('.home-recents__item').forEach(btn => {
btn.addEventListener('click', () => onOpen(btn.dataset.path, btn.dataset.mode));
});
}
+98
View File
@@ -0,0 +1,98 @@
import { convertFile } from '../services/markdown.js';
const win = window.__TAURI__.window.getCurrentWindow();
const titlebarTitle = document.getElementById('titlebar-title');
const content = document.querySelector('#content');
function resolveRelativePath(currentFile, href) {
const sep = currentFile.includes('\\') ? '\\' : '/';
const dir = currentFile.split(sep).slice(0, -1).join(sep);
const parts = [...dir.split(sep), ...href.split('/')];
const resolved = [];
for (const part of parts) {
if (part === '..') resolved.pop();
else if (part !== '.') resolved.push(part);
}
return resolved.join(sep);
}
function wireLinks(filePath, mode, sidebarEl) {
content.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href');
if (!href || href.startsWith('#')) return;
if (/^https?:\/\//.test(href)) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
return;
}
a.addEventListener('click', async e => {
e.preventDefault();
const resolved = resolveRelativePath(filePath, href);
await loadPage(resolved, mode, sidebarEl);
});
});
}
function addCopyButtons() {
content.querySelectorAll('pre').forEach(pre => {
if (pre.parentElement?.classList.contains('code-block')) return;
const wrapper = document.createElement('div');
wrapper.className = 'code-block';
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'copy-code-btn';
btn.textContent = 'Copier';
btn.addEventListener('click', async () => {
const code = (pre.querySelector('code') ?? pre).textContent;
try {
await navigator.clipboard.writeText(code);
btn.textContent = 'Copié !';
btn.classList.add('copied');
} catch {
btn.textContent = 'Erreur';
}
setTimeout(() => {
btn.textContent = 'Copier';
btn.classList.remove('copied');
}, 1500);
});
wrapper.appendChild(btn);
});
}
export async function loadPage(filePath, mode, sidebarEl) {
try {
const html = await convertFile(filePath);
content.innerHTML = html;
addCopyButtons();
const basename = filePath.split('/').pop().split('\\').pop();
const title = basename.replace(/\.md$/i, '');
await win.setTitle(title);
titlebarTitle.textContent = title;
if (mode === 'dir' || mode === 'file') {
sidebarEl.querySelectorAll('a').forEach(a => {
a.classList.toggle('active', a.dataset.path === filePath);
});
const active = sidebarEl.querySelector('a[data-path].active');
if (active) {
let el = active.parentElement;
while (el && el !== sidebarEl) {
if (el.tagName === 'DETAILS') el.open = true;
el = el.parentElement;
}
}
}
wireLinks(filePath, mode, sidebarEl);
} catch (err) {
content.innerHTML = `<p class="error">Impossible de charger le fichier : ${err}</p>`;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { escapeHtml } from '../utils.js';
function buildTree(relPaths, sep) {
const tree = { _files: [], _dirs: {} };
relPaths.forEach((rel) => {
const parts = rel.split(sep);
let node = tree;
for (let i = 0; i < parts.length - 1; i++) {
const dir = parts[i];
if (!node._dirs[dir]) node._dirs[dir] = { _files: [], _dirs: {} };
node = node._dirs[dir];
}
node._files.push(rel);
});
return tree;
}
function renderTree(node, prefix, sep, currentRel, depth) {
let html = '';
const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : '';
node._files.slice().sort().forEach((rel) => {
const label = rel.split(sep).pop().replace(/\.md$/i, '').replace(/-/g, ' ');
const abs = prefix + rel;
const cls = rel === currentRel ? ' active' : '';
if (depth === 0) {
html += `\n<li><a href="#" class="nav-root-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
} else {
html += `\n<li${depthClass}><a href="#" class="nav-file-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
}
});
Object.keys(node._dirs).sort().forEach((dirName) => {
const child = node._dirs[dirName];
const isOpen = currentRel && currentRel.split(sep).includes(dirName);
const openAttr = isOpen ? ' open' : '';
const homeFile = child._files.find((f) => /^home\.md$/i.test(f.split(sep).pop()));
let folderLabel;
if (homeFile) {
const abs = prefix + homeFile;
const cls = homeFile === currentRel ? ' active' : '';
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(dirName)}</a>`;
} else {
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
}
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
const childNode = {
_files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files,
_dirs: child._dirs,
};
html += `\n<li${depthClass}><details class="nav-folder"${openAttr}>`;
html += `\n <summary>${arrow}${folderLabel}</summary>`;
html += `\n <ul class="nav-tree">`;
html += renderTree(childNode, prefix, sep, currentRel, depth + 1);
html += `\n </ul>\n</details></li>`;
});
return html;
}
function buildSidebar(baseDir, files, currentFile) {
const sep = baseDir.includes('\\') ? '\\' : '/';
const dirName = baseDir.split(sep).pop();
const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
const toRel = abs => abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
const currentRel = currentFile ? toRel(currentFile) : null;
const relPaths = files.map(toRel);
const tree = buildTree(relPaths, sep);
let html = `<div class="sidebar__title-block">
<h1 class="sidebar__title-block__title">${escapeHtml(dirName)}</h1>
</div>
<ul class="sidebar__menu nav-tree">`;
html += renderTree(tree, prefix, sep, currentRel, 0);
html += `\n </ul>`;
return html;
}
export function renderSidebar(baseDir, files, currentFile, onNavigate) {
const sidebarEl = document.querySelector('#sidebar');
sidebarEl.innerHTML = buildSidebar(baseDir, files, currentFile);
sidebarEl.querySelectorAll('a[data-path]').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
onNavigate(a.dataset.path);
});
});
}
+7
View File
@@ -0,0 +1,7 @@
export function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}