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:
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/router": "^22.1.6",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"dompurify": "^3.4.15",
|
||||
"leaflet": "^1.9.4",
|
||||
"marked": "^18.0.13",
|
||||
"mermaid": "^12.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user