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:
2026-09-16 08:10:41 +02:00
parent 47e1416a16
commit 9d2b676c84
21 changed files with 1929 additions and 8 deletions
+4
View File
@@ -24,6 +24,10 @@
<a class="nav-bar__lien" routerLink="/resume-annuel" routerLinkActive="nav-bar__lien--actif">
Résumé annuel
</a>
<a class="nav-bar__lien" routerLink="/specs" routerLinkActive="nav-bar__lien--actif">
Specs
</a>
</nav>
<main>
+10
View File
@@ -48,6 +48,16 @@ export const routes: Routes = [
{ path: 'profil', component: ProfilComponent, 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
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
+1
View File
@@ -8,3 +8,4 @@ export * from './profil.model';
export * from './bareme.model';
export * from './adresse.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;
}
+1
View File
@@ -8,3 +8,4 @@ export * from './adresse-nommee.service';
export * from './profil.service';
export * from './bareme.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}