Consultation des specs dans l'app (menu Specs + rendu Markdown/Mermaid)
Nouvelle fonctionnalité : consulter les documents de spécification (exigences,
conception technique, plan de tâches) en lecture seule dans le frontend.
Backend :
- SpecsController (GET /api/specs, GET /api/specs/{cle}) protégé par l'auth
globale ; SpecDocumentService avec whitelist (pas de path traversal) et
lecture depuis le dossier specs/ embarqué, repli sur .kiro/specs en dev.
- DTOs SpecDocumentDto / SpecContenuDto ; service enregistré dans Program.cs.
Docker :
- Contexte de build backend = racine du dépôt (dockerfile: backend/Dockerfile)
pour embarquer .kiro/specs/*.md dans l'image (app autonome).
- .dockerignore racine ajouté (exclut frontend/node_modules, bin/obj, etc.).
Frontend :
- SpecService + SpecsComponent (lazy-loadé) : page dédiée avec menu latéral par
document ; rendu Markdown via marked (GFM) assaini par DOMPurify.
- Diagrammes Mermaid (ex. graph TB) rendus en SVG via mermaid.js importé
dynamiquement ; déclenchement après résolution du @ViewChild (setTimeout).
- Barre de navigation : simple lien « Specs » (le choix du document se fait dans
le menu latéral de la page, pas dans la barre).
- Routes /specs et /specs/:cle protégées par authGuard.
Specs : exigence 19 (consultation des documents) ajoutée à requirements.md ;
design.md complété (DTOs, endpoints, composant, rendu Mermaid, note Docker).
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# .dockerignore RACINE — utilisé par le build backend dont le contexte est la racine
|
||||||
|
# du dépôt (afin d'embarquer .kiro/specs). On exclut tout ce qui n'est pas nécessaire
|
||||||
|
# au build de l'API pour limiter la taille du contexte envoyé au démon Docker.
|
||||||
|
|
||||||
|
# --- Frontend (non requis pour l'image backend) ---
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/.angular
|
||||||
|
|
||||||
|
# --- Artefacts de build .NET (reconstruits dans l'image) ---
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
|
||||||
|
# --- Projet de tests (non nécessaire au déploiement de l'API) ---
|
||||||
|
backend/SchotzIadKmTracker.Tests
|
||||||
|
|
||||||
|
# --- Bases SQLite locales (la base vit sur le volume /data) ---
|
||||||
|
**/*.db
|
||||||
|
**/*.db-shm
|
||||||
|
**/*.db-wal
|
||||||
|
|
||||||
|
# --- VCS / IDE / outils ---
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.user
|
||||||
|
|
||||||
|
# --- Fichiers Docker ---
|
||||||
|
**/Dockerfile
|
||||||
|
**/.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# --- Divers ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IMPORTANT : NE PAS ignorer .kiro/specs (embarqué dans l'image backend).
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
| 21 | Authentification | Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur |
|
| 21 | Authentification | Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur |
|
||||||
| 22 | Trajet aller/retour | Case à cocher A/R doublant la distance comptée (Distance_Effective) sans ligne supplémentaire ; badge A/R en liste et mention « (A/R) » sur la carte |
|
| 22 | Trajet aller/retour | Case à cocher A/R doublant la distance comptée (Distance_Effective) sans ligne supplémentaire ; badge A/R en liste et mention « (A/R) » sur la carte |
|
||||||
| 23 | Barème multi-tranches & recopie | Tranches paramétrables par puissance fiscale (dernière tranche illimitée), coefficient sur 3 décimales, bouton « Recopier le dernier barème connu » |
|
| 23 | Barème multi-tranches & recopie | Tranches paramétrables par puissance fiscale (dernière tranche illimitée), coefficient sur 3 décimales, bouton « Recopier le dernier barème connu » |
|
||||||
|
| 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification |
|
||||||
|
|
||||||
### Contraintes techniques
|
### Contraintes techniques
|
||||||
|
|
||||||
@@ -144,7 +145,8 @@ src/
|
|||||||
│ │ │ ├── adresse-cache.service.ts # Autocomplétion adresses
|
│ │ │ ├── adresse-cache.service.ts # Autocomplétion adresses
|
||||||
│ │ │ ├── adresse-nommee.service.ts # CRUD carnet d'adresses nommées
|
│ │ │ ├── adresse-nommee.service.ts # CRUD carnet d'adresses nommées
|
||||||
│ │ │ ├── profil.service.ts # CRUD profil
|
│ │ │ ├── profil.service.ts # CRUD profil
|
||||||
│ │ │ └── auth.service.ts # Inscription, connexion, session, reset mot de passe
|
│ │ │ ├── auth.service.ts # Inscription, connexion, session, reset mot de passe
|
||||||
|
│ │ │ └── spec.service.ts # Lecture des documents de spécification (specs)
|
||||||
│ │ ├── models/
|
│ │ ├── models/
|
||||||
│ │ │ ├── trajet.model.ts
|
│ │ │ ├── trajet.model.ts
|
||||||
│ │ │ ├── resume-mensuel.model.ts
|
│ │ │ ├── resume-mensuel.model.ts
|
||||||
@@ -201,6 +203,9 @@ src/
|
|||||||
│ │ │ ├── register.component.ts # Création de l'unique compte (étape 1 de l'initialisation)
|
│ │ │ ├── register.component.ts # Création de l'unique compte (étape 1 de l'initialisation)
|
||||||
│ │ │ ├── forgot-password.component.ts # Demande de réinitialisation par email
|
│ │ │ ├── forgot-password.component.ts # Demande de réinitialisation par email
|
||||||
│ │ │ └── reset-password.component.ts # Définition d'un nouveau mot de passe via jeton
|
│ │ │ └── reset-password.component.ts # Définition d'un nouveau mot de passe via jeton
|
||||||
|
│ │ ├── specs/
|
||||||
|
│ │ │ ├── specs.component.ts # Rendu Markdown des documents de spécification (lazy-loadé)
|
||||||
|
│ │ │ └── specs.component.html
|
||||||
│ ├── core/
|
│ ├── core/
|
||||||
│ │ └── guards/
|
│ │ └── guards/
|
||||||
│ │ ├── auth.guard.ts # Valide la session serveur (GET /api/auth/moi) avant les vues protégées
|
│ │ ├── auth.guard.ts # Valide la session serveur (GET /api/auth/moi) avant les vues protégées
|
||||||
@@ -333,6 +338,14 @@ src/
|
|||||||
|
|
||||||
> **Décision — stockage du JWT dans `localStorage`** : retenu pour sa simplicité, malgré le risque XSS, acceptable pour une application personnelle mono-utilisateur. Le cookie HttpOnly a été rejeté pour éviter la complexité de protection CSRF.
|
> **Décision — stockage du JWT dans `localStorage`** : retenu pour sa simplicité, malgré le risque XSS, acceptable pour une application personnelle mono-utilisateur. Le cookie HttpOnly a été rejeté pour éviter la complexité de protection CSRF.
|
||||||
|
|
||||||
|
**`SpecsComponent`** (documents de spécification — Exigence 19)
|
||||||
|
- La barre de navigation principale expose un simple lien « Specs » vers `/specs` (redirigé vers le premier document) ; le choix du document se fait dans le **menu latéral** de la page, pas dans la barre.
|
||||||
|
- Route paramétrée `/specs/:cle` ; **lazy-loadée** (`loadComponent`) pour sortir la librairie Markdown du bundle initial
|
||||||
|
- Colonne gauche : menu listant les documents (`GET /api/specs`), le document indisponible étant signalé ; colonne droite : contenu rendu
|
||||||
|
- Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7
|
||||||
|
- **Diagrammes Mermaid** : un renderer `marked` transforme les blocs ` ```mermaid ` en `<pre class="mermaid">` ; après injection du HTML (déclenchée via `setTimeout` pour laisser Angular résoudre le `@ViewChild`), **Mermaid.js** (importé dynamiquement) rend ces blocs en SVG (`securityLevel: 'loose'` pour accepter les libellés HTML type `<br/>`). En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible.
|
||||||
|
- Accès derrière `authGuard` (mêmes règles que les autres vues protégées)
|
||||||
|
|
||||||
**`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`)
|
**`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`)
|
||||||
- Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`)
|
- Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`)
|
||||||
- Si `EstConfigure` est `false`, redirige vers la route d'onboarding (`/onboarding`) et bloque l'accès à la saisie / consultation des trajets tant que le profil n'est pas configuré
|
- Si `EstConfigure` est `false`, redirige vers la route d'onboarding (`/onboarding`) et bloque l'accès à la saisie / consultation des trajets tant que le profil n'est pas configuré
|
||||||
@@ -1167,6 +1180,22 @@ public record ResetPasswordDto(
|
|||||||
string Token, // jeton en clair reçu par email
|
string Token, // jeton en clair reçu par email
|
||||||
string MotDePasse // nouveau mot de passe, >= 8 caractères
|
string MotDePasse // nouveau mot de passe, >= 8 caractères
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Documents de spécification (Exigence 19) ---
|
||||||
|
|
||||||
|
// Métadonnées d'un document (élément du sous-menu)
|
||||||
|
public record SpecDocumentDto(
|
||||||
|
string Cle, // whitelist : "requirements" | "design" | "tasks"
|
||||||
|
string Titre, // libellé lisible (ex. « Exigences »)
|
||||||
|
bool Disponible // fichier présent et lisible
|
||||||
|
);
|
||||||
|
|
||||||
|
// Contenu Markdown d'un document
|
||||||
|
public record SpecContenuDto(
|
||||||
|
string Cle,
|
||||||
|
string Titre,
|
||||||
|
string Markdown // contenu brut, rendu en HTML côté frontend
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1274,6 +1303,15 @@ public record ResetPasswordDto(
|
|||||||
| `PUT` | `/api/montants-regles` | Enregistrer/mettre à jour un montant réglé | `MontantRegleDto` | `200 OK` + `MontantRegleDto` |
|
| `PUT` | `/api/montants-regles` | Enregistrer/mettre à jour un montant réglé | `MontantRegleDto` | `200 OK` + `MontantRegleDto` |
|
||||||
| `GET` | `/api/regularisation/{annee}?vehiculeId={id}` | Synthèse annuelle de régularisation : `RegularisationAnnuelleDto` avec les 12 `LigneSyntheseMoisDto`, le coefficient et la base de la tranche annuelle, le total réel (barème plein) et la régularisation (réel − payé) | — | `200 OK` + `RegularisationAnnuelleDto` |
|
| `GET` | `/api/regularisation/{annee}?vehiculeId={id}` | Synthèse annuelle de régularisation : `RegularisationAnnuelleDto` avec les 12 `LigneSyntheseMoisDto`, le coefficient et la base de la tranche annuelle, le total réel (barème plein) et la régularisation (réel − payé) | — | `200 OK` + `RegularisationAnnuelleDto` |
|
||||||
|
|
||||||
|
#### Documents de spécification (Exigence 19)
|
||||||
|
|
||||||
|
> Endpoints **protégés** (authentification requise). L'accès est restreint à une **whitelist** de clés (`requirements`, `design`, `tasks`) : aucun chemin de fichier n'est construit à partir d'une entrée libre (pas de path traversal).
|
||||||
|
|
||||||
|
| Méthode | Route | Description | Réponse succès |
|
||||||
|
|---------|-------|-------------|----------------|
|
||||||
|
| `GET` | `/api/specs` | Lister les documents et leur disponibilité (sous-menu) | `200 OK` + `SpecDocumentDto[]` |
|
||||||
|
| `GET` | `/api/specs/{cle}` | Contenu Markdown d'un document (clé whitelistée) | `200 OK` + `SpecContenuDto` ; `404` si clé inconnue / fichier absent |
|
||||||
|
|
||||||
#### Codes d'erreur standardisés
|
#### Codes d'erreur standardisés
|
||||||
|
|
||||||
| Code HTTP | Cas |
|
| Code HTTP | Cas |
|
||||||
@@ -1665,6 +1703,8 @@ volumes:
|
|||||||
|
|
||||||
Les **migrations EF Core sont appliquées automatiquement au démarrage du backend** (`await dbContext.Database.MigrateAsync()` dans `Program.cs` avant `app.Run()`). Ainsi, au premier lancement, la base `/data/trajets.db` est créée sur le volume monté ; aux lancements suivants, elle est mise à jour avec les migrations en attente sans intervention manuelle.
|
Les **migrations EF Core sont appliquées automatiquement au démarrage du backend** (`await dbContext.Database.MigrateAsync()` dans `Program.cs` avant `app.Run()`). Ainsi, au premier lancement, la base `/data/trajets.db` est créée sur le volume monté ; aux lancements suivants, elle est mise à jour avec les migrations en attente sans intervention manuelle.
|
||||||
|
|
||||||
|
> **Documents de spécification embarqués** : le `Dockerfile` backend a pour contexte la racine du dépôt et copie `.kiro/specs/schotziad-km-tracker/{requirements,design,tasks}.md` dans `./specs/` de l'image. Le `SpecDocumentService` lit en priorité ce dossier embarqué (`ContentRootPath/specs`), avec repli sur `.kiro/specs/...` en développement. L'application est ainsi autonome, sans montage du dépôt à l'exécution.
|
||||||
|
|
||||||
> **Migrations notables** : `VehiculeDenomination` (dénomination du véhicule), `TrajetGeometrieJson` (géométrie d'itinéraire portée par le trajet), `TrancheBaremeKmMaxNullable` / bornes exclusives / coefficient numérique (barème multi-tranches à dernière tranche illimitée), `AjoutAuthentification` (comptes + jetons de réinitialisation), `TrajetAllerRetour` (indicateur aller/retour).
|
> **Migrations notables** : `VehiculeDenomination` (dénomination du véhicule), `TrajetGeometrieJson` (géométrie d'itinéraire portée par le trajet), `TrancheBaremeKmMaxNullable` / bornes exclusives / coefficient numérique (barème multi-tranches à dernière tranche illimitée), `AjoutAuthentification` (comptes + jetons de réinitialisation), `TrajetAllerRetour` (indicateur aller/retour).
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ L'application est composée d'un frontend Angular/TypeScript et d'un backend C#.
|
|||||||
- **Jeton_Reinitialisation** : Un jeton à usage unique et à durée de vie limitée, envoyé par e-mail, permettant de définir un nouveau Mot_De_Passe sans connaître l'ancien.
|
- **Jeton_Reinitialisation** : Un jeton à usage unique et à durée de vie limitée, envoyé par e-mail, permettant de définir un nouveau Mot_De_Passe sans connaître l'ancien.
|
||||||
- **Aller_Retour** : Un indicateur booléen d'un Trajet signifiant que le déplacement est effectué dans les deux sens. Lorsqu'il est vrai, la Distance comptée du Trajet (Distance_Effective) est le double de la distance simple mesurée entre le départ et l'arrivée.
|
- **Aller_Retour** : Un indicateur booléen d'un Trajet signifiant que le déplacement est effectué dans les deux sens. Lorsqu'il est vrai, la Distance comptée du Trajet (Distance_Effective) est le double de la distance simple mesurée entre le départ et l'arrivée.
|
||||||
- **Distance_Effective** : La distance comptée d'un Trajet dans tous les agrégats (liste, totaux, carte, PDF, barème) : égale à la distance simple, ou à son double lorsque le Trajet est un Aller_Retour.
|
- **Distance_Effective** : La distance comptée d'un Trajet dans tous les agrégats (liste, totaux, carte, PDF, barème) : égale à la distance simple, ou à son double lorsque le Trajet est un Aller_Retour.
|
||||||
|
- **Document_Specification** : Un document de conception du projet consultable en lecture seule dans l'Application : les Exigences (requirements), la Conception technique (design) et le Plan de tâches (tasks), rendus depuis leur source Markdown.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -432,3 +433,20 @@ L'application est composée d'un frontend Angular/TypeScript et d'un backend C#.
|
|||||||
5. WHEN l'utilisateur consulte l'infobulle d'un Trajet Aller_Retour sur la Carte, THE Frontend SHALL faire suivre la distance affichée de la mention « (A/R) ».
|
5. WHEN l'utilisateur consulte l'infobulle d'un Trajet Aller_Retour sur la Carte, THE Frontend SHALL faire suivre la distance affichée de la mention « (A/R) ».
|
||||||
6. THE Backend SHALL persister l'indicateur Aller_Retour de chaque Trajet et le conserver lors des modifications ne portant pas sur ce champ.
|
6. THE Backend SHALL persister l'indicateur Aller_Retour de chaque Trajet et le conserver lors des modifications ne portant pas sur ce champ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement 19: Consultation des documents de spécification
|
||||||
|
|
||||||
|
**User Story:** En tant qu'utilisateur, je veux consulter les documents de spécification du projet (exigences, conception technique, plan de tâches) directement dans l'application, afin de disposer d'une référence lisible sans quitter l'outil.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Frontend SHALL afficher, dans la barre de navigation principale, un lien « Specs » menant à une page dédiée ; cette page présente un menu latéral listant chaque Document_Specification disponible : « Exigences », « Conception technique » et « Plan de tâches ».
|
||||||
|
2. WHEN l'utilisateur sélectionne un Document_Specification dans le menu latéral, THE Frontend SHALL afficher le contenu du document rendu de façon lisible (titres, listes, tableaux, blocs de code) à partir de sa source Markdown, ET SHALL rendre les blocs de diagramme Mermaid (ex. `graph TB`) sous forme de schémas graphiques.
|
||||||
|
3. THE Backend SHALL exposer les Documents_Specification en lecture seule via une API, en restreignant les documents accessibles à une liste fermée (exigences, conception, tâches) afin d'empêcher tout accès à un fichier arbitraire.
|
||||||
|
4. THE Backend SHALL exiger un utilisateur authentifié pour accéder aux Documents_Specification (mêmes règles d'accès que les autres données de l'Application) ; en l'absence de jeton valide, THE Backend SHALL retourner une erreur HTTP 401.
|
||||||
|
5. IF l'utilisateur demande un Document_Specification inconnu ou absent, THEN THE Backend SHALL retourner une erreur HTTP 404 et THE Frontend SHALL afficher un message indiquant que le document est introuvable.
|
||||||
|
6. WHERE le fichier d'un Document_Specification n'est pas disponible (ex. plan de tâches incomplet ou absent), THE Frontend SHALL signaler ce document comme indisponible dans le menu latéral tout en affichant les autres documents.
|
||||||
|
7. THE Frontend SHALL assainir le contenu HTML issu du rendu Markdown avant affichage, afin d'éviter l'injection de contenu actif.
|
||||||
|
8. IF un diagramme Mermaid ne peut pas être rendu (syntaxe non reconnue), THEN THE Frontend SHALL conserver l'affichage du bloc de code brut correspondant sans empêcher le rendu des autres diagrammes ni du reste du document.
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -1,10 +1,13 @@
|
|||||||
# ---- Étape build (SDK) ----
|
# ---- Étape build (SDK) ----
|
||||||
|
# NOTE : le contexte de build est la RACINE du dépôt (voir docker-compose.yml),
|
||||||
|
# afin de pouvoir embarquer les documents de spécification (.kiro/specs). Les chemins
|
||||||
|
# sont donc relatifs à la racine (préfixe backend/).
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY *.sln ./
|
COPY backend/*.sln ./
|
||||||
COPY SchotzIadKmTracker.Api/*.csproj SchotzIadKmTracker.Api/
|
COPY backend/SchotzIadKmTracker.Api/*.csproj SchotzIadKmTracker.Api/
|
||||||
RUN dotnet restore SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj
|
RUN dotnet restore SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj
|
||||||
COPY . .
|
COPY backend/ .
|
||||||
RUN dotnet publish SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj -c Release -o /app/publish
|
RUN dotnet publish SchotzIadKmTracker.Api/SchotzIadKmTracker.Api.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
# ---- Étape runtime (ASP.NET) ----
|
# ---- Étape runtime (ASP.NET) ----
|
||||||
@@ -18,6 +21,13 @@ RUN apt-get update \
|
|||||||
|
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Documents de spécification embarqués (lus par SpecsController) : l'application est
|
||||||
|
# ainsi autonome, sans dépendre d'un montage du dépôt à l'exécution.
|
||||||
|
COPY .kiro/specs/schotziad-km-tracker/requirements.md \
|
||||||
|
.kiro/specs/schotziad-km-tracker/design.md \
|
||||||
|
.kiro/specs/schotziad-km-tracker/tasks.md \
|
||||||
|
./specs/
|
||||||
|
|
||||||
# Répertoire du volume pour la base SQLite persistée
|
# Répertoire du volume pour la base SQLite persistée
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||||
|
using SchotzIadKmTracker.Api.Services;
|
||||||
|
|
||||||
|
namespace SchotzIadKmTracker.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expose en lecture seule les documents de spécification du projet (exigences,
|
||||||
|
/// conception technique, plan de tâches) pour affichage dans le frontend. Comme tous
|
||||||
|
/// les controllers hors AuthController, ces endpoints exigent un utilisateur
|
||||||
|
/// authentifié (autorisation globale) : les specs ne sont pas publiques.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/specs")]
|
||||||
|
public class SpecsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISpecDocumentService _specDocumentService;
|
||||||
|
|
||||||
|
public SpecsController(ISpecDocumentService specDocumentService)
|
||||||
|
{
|
||||||
|
_specDocumentService = specDocumentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liste les documents de spécification connus et leur disponibilité (pour le sous-menu).</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SpecDocumentDto>), StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<IReadOnlyList<SpecDocumentDto>> Lister()
|
||||||
|
{
|
||||||
|
return Ok(_specDocumentService.Lister());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retourne le contenu Markdown d'un document identifié par sa clé (whitelist :
|
||||||
|
/// "requirements", "design", "tasks"). 404 si la clé est inconnue ou le fichier absent.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{cle}")]
|
||||||
|
[ProducesResponseType(typeof(SpecContenuDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SpecContenuDto>> Obtenir(string cle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contenu = await _specDocumentService.ObtenirAsync(cle, cancellationToken);
|
||||||
|
if (contenu is null)
|
||||||
|
{
|
||||||
|
return Problem(
|
||||||
|
detail: $"Le document de spécification '{cle}' est introuvable.",
|
||||||
|
statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
return Ok(contenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace SchotzIadKmTracker.Api.Models.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Métadonnées d'un document de spécification exposé au frontend.</summary>
|
||||||
|
/// <param name="Cle">Clé stable et sûre (whitelist) : "requirements", "design" ou "tasks".</param>
|
||||||
|
/// <param name="Titre">Libellé lisible pour le sous-menu (ex. « Exigences »).</param>
|
||||||
|
/// <param name="Disponible">Vrai si le fichier Markdown correspondant est présent et lisible.</param>
|
||||||
|
public record SpecDocumentDto(
|
||||||
|
string Cle,
|
||||||
|
string Titre,
|
||||||
|
bool Disponible
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Contenu Markdown d'un document de spécification.</summary>
|
||||||
|
/// <param name="Cle">Clé du document ("requirements", "design", "tasks").</param>
|
||||||
|
/// <param name="Titre">Libellé lisible du document.</param>
|
||||||
|
/// <param name="Markdown">Contenu brut Markdown du document, rendu côté frontend.</param>
|
||||||
|
public record SpecContenuDto(
|
||||||
|
string Cle,
|
||||||
|
string Titre,
|
||||||
|
string Markdown
|
||||||
|
);
|
||||||
@@ -79,6 +79,7 @@ builder.Services.AddScoped<IDistanceCalculator, DistanceCalculatorService>();
|
|||||||
// --- Cache des adresses géocodées ---
|
// --- Cache des adresses géocodées ---
|
||||||
builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
|
builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
|
||||||
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
|
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
|
||||||
|
builder.Services.AddScoped<ISpecDocumentService, SpecDocumentService>();
|
||||||
|
|
||||||
// --- Services de base ---
|
// --- Services de base ---
|
||||||
// Autorisation GLOBALE par défaut : toutes les routes MVC exigent un utilisateur authentifié.
|
// Autorisation GLOBALE par défaut : toutes les routes MVC exigent un utilisateur authentifié.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||||
|
|
||||||
|
namespace SchotzIadKmTracker.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fournit l'accès en lecture seule aux documents de spécification du projet
|
||||||
|
/// (requirements, design, tasks) pour affichage dans le frontend.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISpecDocumentService
|
||||||
|
{
|
||||||
|
/// <summary>Liste les documents de spécification connus et leur disponibilité.</summary>
|
||||||
|
IReadOnlyList<SpecDocumentDto> Lister();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retourne le contenu Markdown du document identifié par <paramref name="cle"/>,
|
||||||
|
/// ou <c>null</c> si la clé est inconnue (hors whitelist) ou le fichier absent.
|
||||||
|
/// </summary>
|
||||||
|
Task<SpecContenuDto?> ObtenirAsync(string cle, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||||
|
|
||||||
|
namespace SchotzIadKmTracker.Api.Services;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class SpecDocumentService : ISpecDocumentService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whitelist des documents exposés : clé sûre -> (nom de fichier, libellé lisible).
|
||||||
|
/// La clé provient du client ; seules ces valeurs sont acceptées, ce qui interdit
|
||||||
|
/// tout parcours de chemin (path traversal) : aucun chemin n'est construit à partir
|
||||||
|
/// d'une entrée utilisateur libre.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly IReadOnlyList<(string Cle, string Fichier, string Titre)> Documents = new[]
|
||||||
|
{
|
||||||
|
("requirements", "requirements.md", "Exigences"),
|
||||||
|
("design", "design.md", "Conception technique"),
|
||||||
|
("tasks", "tasks.md", "Plan de tâches")
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly ILogger<SpecDocumentService> _logger;
|
||||||
|
|
||||||
|
public SpecDocumentService(IWebHostEnvironment environment, ILogger<SpecDocumentService> logger)
|
||||||
|
{
|
||||||
|
_environment = environment;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<SpecDocumentDto> Lister()
|
||||||
|
{
|
||||||
|
return Documents
|
||||||
|
.Select(d => new SpecDocumentDto(d.Cle, d.Titre, File.Exists(ResoudreChemin(d.Fichier))))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SpecContenuDto?> ObtenirAsync(string cle, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var doc = Documents.FirstOrDefault(d => d.Cle == cle);
|
||||||
|
if (doc.Cle is null)
|
||||||
|
{
|
||||||
|
// Clé hors whitelist : document inconnu.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chemin = ResoudreChemin(doc.Fichier);
|
||||||
|
if (!File.Exists(chemin))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Document de spécification introuvable : {Chemin}", chemin);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var markdown = await File.ReadAllTextAsync(chemin, cancellationToken);
|
||||||
|
return new SpecContenuDto(doc.Cle, doc.Titre, markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résout le chemin absolu d'un fichier de spécification. En priorité le dossier
|
||||||
|
/// <c>specs/</c> embarqué à la racine de contenu de l'application (copié dans l'image
|
||||||
|
/// Docker au build) ; à défaut, le dossier <c>.kiro/specs/<feature>/</c> du dépôt
|
||||||
|
/// (utile en développement, où l'API tourne depuis l'arborescence du projet).
|
||||||
|
/// </summary>
|
||||||
|
private string ResoudreChemin(string fichier)
|
||||||
|
{
|
||||||
|
// 1) Dossier embarqué à côté de l'application publiée (Docker).
|
||||||
|
var embarque = Path.Combine(_environment.ContentRootPath, "specs", fichier);
|
||||||
|
if (File.Exists(embarque))
|
||||||
|
{
|
||||||
|
return embarque;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Repli développement : remonter jusqu'au dépôt et lire .kiro/specs/<feature>/.
|
||||||
|
var racineDepot = TrouverRacineDepot(_environment.ContentRootPath);
|
||||||
|
if (racineDepot is not null)
|
||||||
|
{
|
||||||
|
var devPath = Path.Combine(
|
||||||
|
racineDepot, ".kiro", "specs", "schotziad-km-tracker", fichier);
|
||||||
|
if (File.Exists(devPath))
|
||||||
|
{
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chemin embarqué par défaut (inexistant) : File.Exists renverra false en amont.
|
||||||
|
return embarque;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remonte l'arborescence depuis <paramref name="depart"/> jusqu'à trouver un dossier
|
||||||
|
/// contenant <c>.kiro</c> (racine du dépôt), ou <c>null</c> si non trouvé.
|
||||||
|
/// </summary>
|
||||||
|
private static string? TrouverRacineDepot(string depart)
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(depart);
|
||||||
|
while (dir is not null)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.Combine(dir.FullName, ".kiro")))
|
||||||
|
{
|
||||||
|
return dir.FullName;
|
||||||
|
}
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
# Contexte = racine du dépôt pour pouvoir embarquer .kiro/specs dans l'image.
|
||||||
dockerfile: Dockerfile
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_URLS=http://+:8080
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
- ConnectionStrings__Default=Data Source=/data/trajets.db
|
- ConnectionStrings__Default=Data Source=/data/trajets.db
|
||||||
|
|||||||
Generated
+1149
-2
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@
|
|||||||
"@angular/platform-browser-dynamic": "^22.1.6",
|
"@angular/platform-browser-dynamic": "^22.1.6",
|
||||||
"@angular/router": "^22.1.6",
|
"@angular/router": "^22.1.6",
|
||||||
"@types/leaflet": "^1.9.22",
|
"@types/leaflet": "^1.9.22",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"marked": "^18.0.13",
|
||||||
|
"mermaid": "^12.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
<a class="nav-bar__lien" routerLink="/resume-annuel" routerLinkActive="nav-bar__lien--actif">
|
<a class="nav-bar__lien" routerLink="/resume-annuel" routerLinkActive="nav-bar__lien--actif">
|
||||||
Résumé annuel
|
Résumé annuel
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="nav-bar__lien" routerLink="/specs" routerLinkActive="nav-bar__lien--actif">
|
||||||
|
Specs
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export const routes: Routes = [
|
|||||||
{ path: 'profil', component: ProfilComponent, canActivate: [authGuard] },
|
{ path: 'profil', component: ProfilComponent, canActivate: [authGuard] },
|
||||||
{ path: 'vehicules', component: VehiculesComponent, canActivate: [authGuard] },
|
{ path: 'vehicules', component: VehiculesComponent, canActivate: [authGuard] },
|
||||||
|
|
||||||
|
// Documents de spécification (lecture seule) — protégés par authGuard.
|
||||||
|
{ path: 'specs', redirectTo: 'specs/requirements', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'specs/:cle',
|
||||||
|
// Lazy-load : la librairie Markdown (marked/DOMPurify) n'est chargée qu'à l'ouverture des specs.
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/specs/specs.component').then((m) => m.SpecsComponent),
|
||||||
|
canActivate: [authGuard]
|
||||||
|
},
|
||||||
|
|
||||||
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
|
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
|
||||||
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
|
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||||
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
|
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from './profil.model';
|
|||||||
export * from './bareme.model';
|
export * from './bareme.model';
|
||||||
export * from './adresse.model';
|
export * from './adresse.model';
|
||||||
export * from './auth.model';
|
export * from './auth.model';
|
||||||
|
export * from './spec.model';
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Modèles des documents de spécification (miroir des DTOs backend SpecDtos.cs).
|
||||||
|
|
||||||
|
/** Métadonnées d'un document de spécification (élément du sous-menu). */
|
||||||
|
export interface SpecDocument {
|
||||||
|
/** Clé stable et sûre : 'requirements' | 'design' | 'tasks'. */
|
||||||
|
cle: string;
|
||||||
|
/** Libellé lisible pour le sous-menu (ex. « Exigences »). */
|
||||||
|
titre: string;
|
||||||
|
/** Vrai si le fichier Markdown est présent et lisible côté backend. */
|
||||||
|
disponible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contenu Markdown d'un document de spécification. */
|
||||||
|
export interface SpecContenu {
|
||||||
|
cle: string;
|
||||||
|
titre: string;
|
||||||
|
/** Contenu brut Markdown, rendu en HTML côté composant. */
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export * from './adresse-nommee.service';
|
|||||||
export * from './profil.service';
|
export * from './profil.service';
|
||||||
export * from './bareme.service';
|
export * from './bareme.service';
|
||||||
export * from './remboursement.service';
|
export * from './remboursement.service';
|
||||||
|
export * from './spec.service';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { SpecContenu, SpecDocument } from '../models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service HTTP d'accès aux documents de spécification du projet (exigences,
|
||||||
|
* conception technique, plan de tâches) pour affichage en lecture seule.
|
||||||
|
* Les endpoints sont protégés par l'authentification (comme les autres appels).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SpecService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.apiUrl}/specs`;
|
||||||
|
|
||||||
|
/** Liste les documents de spécification connus et leur disponibilité (GET /api/specs). */
|
||||||
|
lister(): Observable<SpecDocument[]> {
|
||||||
|
return this.http.get<SpecDocument[]>(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupère le contenu Markdown d'un document par sa clé (GET /api/specs/{cle}). */
|
||||||
|
obtenir(cle: string): Observable<SpecContenu> {
|
||||||
|
return this.http.get<SpecContenu>(`${this.baseUrl}/${cle}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<section class="specs" aria-label="Documents de spécification">
|
||||||
|
<!-- Sous-menu : un lien par document disponible. -->
|
||||||
|
<aside class="specs__menu" aria-label="Liste des documents">
|
||||||
|
<h2 class="specs__menu-titre">Specs</h2>
|
||||||
|
<nav class="specs__liens">
|
||||||
|
@for (doc of documents(); track doc.cle) {
|
||||||
|
<a
|
||||||
|
class="specs__lien"
|
||||||
|
[routerLink]="['/specs', doc.cle]"
|
||||||
|
routerLinkActive="specs__lien--actif"
|
||||||
|
[class.specs__lien--indisponible]="!doc.disponible"
|
||||||
|
>
|
||||||
|
{{ doc.titre }}
|
||||||
|
@if (!doc.disponible) {
|
||||||
|
<span class="specs__badge" title="Document non disponible">indisponible</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Contenu du document rendu depuis le Markdown. -->
|
||||||
|
<article class="specs__contenu">
|
||||||
|
@if (chargement()) {
|
||||||
|
<p class="specs__info">Chargement du document…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (erreur()) {
|
||||||
|
<p class="specs__erreur" role="alert">{{ erreur() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!chargement() && !erreur() && contenuHtml()) {
|
||||||
|
<h1 class="specs__titre">{{ titreCourant() }}</h1>
|
||||||
|
<!-- Contenu assaini par DOMPurify avant injection. -->
|
||||||
|
<div #markdownHost class="specs__markdown markdown-body" [innerHTML]="contenuHtml()"></div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
.specs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&__menu {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__menu-titre {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__liens {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__lien {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #1f2937;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--actif {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--indisponible {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__contenu {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titre {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info,
|
||||||
|
&__erreur {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__erreur {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rendu lisible du Markdown converti (titres, texte, tableaux, code, citations). */
|
||||||
|
.markdown-body {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1f2937;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.4em 0 0.6em;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.7rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; }
|
||||||
|
h2 { font-size: 1.4rem; border-bottom: 1px solid #eef2f7; padding-bottom: 0.2em; }
|
||||||
|
h3 { font-size: 1.15rem; }
|
||||||
|
|
||||||
|
p { margin: 0.6em 0; }
|
||||||
|
|
||||||
|
a { color: #2563eb; }
|
||||||
|
|
||||||
|
ul, ol { padding-left: 1.5rem; margin: 0.5em 0; }
|
||||||
|
li { margin: 0.2em 0; }
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0.8em 0;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
border-left: 4px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { background: #f1f5f9; font-weight: 600; }
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img { max-width: 100%; }
|
||||||
|
|
||||||
|
/* Diagrammes Mermaid rendus (SVG) : centrés, sans le fond sombre des blocs de code. */
|
||||||
|
pre.mermaid {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
svg { max-width: 100%; height: auto; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
import { SpecDocument } from '../../core/models';
|
||||||
|
import { SpecService } from '../../core/services';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SpecsComponent` — consultation en lecture seule des documents de spécification
|
||||||
|
* (exigences, conception technique, plan de tâches) rendus depuis le Markdown.
|
||||||
|
*
|
||||||
|
* Le menu (colonne gauche) liste les documents disponibles ; le document courant est
|
||||||
|
* déterminé par le paramètre de route `:cle`. Le Markdown est converti en HTML par
|
||||||
|
* `marked`, puis assaini par `DOMPurify` avant affichage (le contenu provient du
|
||||||
|
* backend mais on assainit par principe défensif).
|
||||||
|
*
|
||||||
|
* Les blocs de code ` ```mermaid ` (ex. `graph TB`, `flowchart LR`) sont rendus en
|
||||||
|
* diagrammes SVG via **Mermaid.js**, importé dynamiquement au premier rendu afin de
|
||||||
|
* ne pas alourdir le chargement initial de la page.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-specs',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, RouterLinkActive],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './specs.component.html',
|
||||||
|
styleUrl: './specs.component.scss'
|
||||||
|
})
|
||||||
|
export class SpecsComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly specService = inject(SpecService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly cdr = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
|
/** Conteneur du HTML rendu, utilisé pour lancer le rendu Mermaid après injection. */
|
||||||
|
@ViewChild('markdownHost') private markdownHost?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
/** Documents disponibles pour le menu latéral. */
|
||||||
|
readonly documents = signal<SpecDocument[]>([]);
|
||||||
|
|
||||||
|
/** Clé du document actuellement affiché. */
|
||||||
|
readonly cleCourante = signal<string | null>(null);
|
||||||
|
|
||||||
|
/** Titre du document courant (pour l'en-tête). */
|
||||||
|
readonly titreCourant = signal<string>('');
|
||||||
|
|
||||||
|
/** HTML rendu (assaini) du document courant. */
|
||||||
|
readonly contenuHtml = signal<SafeHtml | null>(null);
|
||||||
|
|
||||||
|
/** Chargement en cours du contenu. */
|
||||||
|
readonly chargement = signal(false);
|
||||||
|
|
||||||
|
/** Message d'erreur de chargement (contenu ou liste). */
|
||||||
|
readonly erreur = signal<string | null>(null);
|
||||||
|
|
||||||
|
private listeSub?: Subscription;
|
||||||
|
private contenuSub?: Subscription;
|
||||||
|
private paramSub?: Subscription;
|
||||||
|
|
||||||
|
/** Renderer marked partagé : les blocs `mermaid` deviennent `<pre class="mermaid">`. */
|
||||||
|
private readonly markedInstance = this.creerMarked();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Charge la liste du menu (indépendante du document courant).
|
||||||
|
this.listeSub = this.specService.lister().subscribe({
|
||||||
|
next: (docs) => {
|
||||||
|
this.documents.set(docs);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.erreur.set('Impossible de charger la liste des documents.');
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Réagit au paramètre de route `:cle` pour charger le document demandé.
|
||||||
|
this.paramSub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const cle = params.get('cle');
|
||||||
|
if (cle) {
|
||||||
|
this.chargerDocument(cle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.listeSub?.unsubscribe();
|
||||||
|
this.contenuSub?.unsubscribe();
|
||||||
|
this.paramSub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge et rend le document Markdown identifié par `cle`. */
|
||||||
|
private chargerDocument(cle: string): void {
|
||||||
|
this.contenuSub?.unsubscribe();
|
||||||
|
this.cleCourante.set(cle);
|
||||||
|
this.chargement.set(true);
|
||||||
|
this.erreur.set(null);
|
||||||
|
this.contenuHtml.set(null);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
|
||||||
|
this.contenuSub = this.specService.obtenir(cle).subscribe({
|
||||||
|
next: (doc) => {
|
||||||
|
this.titreCourant.set(doc.titre);
|
||||||
|
// marked (GFM) → HTML (les blocs mermaid → <pre class="mermaid">),
|
||||||
|
// puis assainissement DOMPurify avant injection.
|
||||||
|
const html = this.markedInstance.parse(doc.markdown, { async: false }) as string;
|
||||||
|
const propre = DOMPurify.sanitize(html, {
|
||||||
|
// Conserve la classe (indispensable pour cibler .mermaid) ; le SVG généré
|
||||||
|
// par Mermaid est inséré APRÈS sanitisation, donc pas besoin de l'autoriser ici.
|
||||||
|
ADD_ATTR: ['class']
|
||||||
|
});
|
||||||
|
this.contenuHtml.set(this.sanitizer.bypassSecurityTrustHtml(propre));
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
// Rendu des diagrammes Mermaid une fois le HTML injecté dans le DOM et le
|
||||||
|
// @ViewChild résolu (setTimeout = macrotask, après le cycle de détection).
|
||||||
|
setTimeout(() => void this.rendreMermaid(), 0);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.chargement.set(false);
|
||||||
|
this.erreur.set('Ce document est introuvable ou n\'a pas pu être chargé.');
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend en SVG les blocs `<pre class="mermaid">` présents dans le contenu injecté.
|
||||||
|
* Mermaid est importé dynamiquement (chunk séparé) et n'est chargé qu'ici.
|
||||||
|
*/
|
||||||
|
private async rendreMermaid(): Promise<void> {
|
||||||
|
const hote = this.markdownHost?.nativeElement;
|
||||||
|
if (!hote) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blocs = hote.querySelectorAll<HTMLElement>('pre.mermaid');
|
||||||
|
if (blocs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mermaid = (await import('mermaid')).default;
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });
|
||||||
|
// `run` remplace le contenu des éléments ciblés par le SVG rendu.
|
||||||
|
await mermaid.run({ nodes: Array.from(blocs) });
|
||||||
|
} catch {
|
||||||
|
// En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible (repli).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit une instance `marked` dont le renderer de bloc de code émet
|
||||||
|
* `<pre class="mermaid">…</pre>` pour le langage `mermaid`, et le rendu standard
|
||||||
|
* (avec échappement) pour les autres langages.
|
||||||
|
*/
|
||||||
|
private creerMarked(): typeof marked {
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
code(this: unknown, { text, lang }: { text: string; lang?: string }): string {
|
||||||
|
if ((lang ?? '').trim().toLowerCase() === 'mermaid') {
|
||||||
|
// Le texte Mermaid est inséré tel quel dans <pre class="mermaid"> ;
|
||||||
|
// il est traité comme définition de diagramme, jamais comme HTML.
|
||||||
|
return `<pre class="mermaid">${SpecsComponent.echapper(text)}</pre>`;
|
||||||
|
}
|
||||||
|
const langue = (lang ?? '').trim();
|
||||||
|
const classe = langue ? ` class="language-${SpecsComponent.echapper(langue)}"` : '';
|
||||||
|
return `<pre><code${classe}>${SpecsComponent.echapper(text)}</code></pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return marked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Échappe les caractères HTML sensibles. */
|
||||||
|
private static echapper(valeur: string): string {
|
||||||
|
return valeur
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user