feat(outils): sélection open source, sommaire d'ancres et garde-fous (B6-M4)

28 outils vérifiés en 4 rubriques (open_source.selection), affichés en premier
sous un sommaire d'ancres ; grille 2 colonnes dès 1024 px. Le test contrôle
la sélection (champs, https, liste noire) et les doublons d'URL.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
This commit is contained in:
Jules Neny
2026-10-08 17:07:37 +02:00
co-authored by Claude Sonnet
parent dfbac7dd12
commit 43ae065e26
3 changed files with 578 additions and 7 deletions
+131 -6
View File
@@ -95,8 +95,38 @@
<template v-else-if="tab === 'open-source'">
<p class="outils-intention">{{ outils.open_source.intention }}</p>
<section class="outils-section" aria-labelledby="sec-recommandes">
<h2 id="sec-recommandes" class="outils-section__title">Recommandés</h2>
<!-- Sommaire d'ancres : tout est déployé d'office, on saute au bloc voulu -->
<nav class="os-sommaire" aria-label="Sommaire de l'onglet Open source">
<ul class="os-sommaire__liste">
<li v-for="a in sommaire" :key="a.id">
<a :href="'#' + a.id" class="os-sommaire__lien" @click="allerAncre($event, a.id)">{{ a.libelle }}</a>
</li>
</ul>
</nav>
<!-- Sélection AEP : outils utiles à un architecte indépendant (B6-M3/M4) -->
<template v-if="outils.open_source.selection">
<p class="outils-section__desc">{{ outils.open_source.selection.intention }}</p>
<section
v-for="r in outils.open_source.selection.rubriques"
:key="r.id"
class="outils-section outils-section--ancre"
:aria-labelledby="'sel-' + r.id"
>
<h2 :id="'sel-' + r.id" class="outils-section__title" tabindex="-1">{{ r.titre }}</h2>
<p v-if="r.note" class="outils-section__desc">{{ r.note }}</p>
<ul class="sel-grid">
<li v-for="l in r.liens" :key="l.url" class="sel-item">
<a :href="l.url" target="_blank" rel="noopener noreferrer" class="sel-item__nom">{{ l.nom }}</a>
<p class="sel-item__desc">{{ l.desc }}</p>
<p class="sel-item__meta">{{ metaSelection(l) }}</p>
</li>
</ul>
</section>
</template>
<section class="outils-section outils-section--ancre" aria-labelledby="sec-recommandes">
<h2 id="sec-recommandes" class="outils-section__title" tabindex="-1">Nos recommandations</h2>
<div class="outil-cards-grid">
<OutilCard
v-for="outil in outils.open_source.recommandes"
@@ -110,8 +140,8 @@
</div>
</section>
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
<h2 id="sec-fmhy" class="outils-section__title">{{ outils.open_source.fmhy.titre }}</h2>
<section class="outils-section outils-section--fmhy outils-section--ancre" aria-labelledby="sec-fmhy">
<h2 id="sec-fmhy" class="outils-section__title" tabindex="-1">{{ outils.open_source.fmhy.titre }}</h2>
<p class="outils-section__desc">{{ outils.open_source.fmhy.texte_intro }}</p>
<div v-for="g in outils.open_source.rubriques" :key="g.id" class="fmhy-usage">
@@ -155,10 +185,10 @@
</div>
</section>
<section v-if="outils.open_source.logiciels_pro" class="outils-section outils-section--placeholder" aria-labelledby="sec-logiciels">
<section v-if="outils.open_source.logiciels_pro" class="outils-section outils-section--placeholder outils-section--ancre" aria-labelledby="sec-logiciels">
<div class="placeholder-block">
<span class="placeholder-block__badge">{{ outils.open_source.logiciels_pro.badge }}</span>
<h2 id="sec-logiciels" class="placeholder-block__title">{{ outils.open_source.logiciels_pro.titre }}</h2>
<h2 id="sec-logiciels" class="placeholder-block__title" tabindex="-1">{{ outils.open_source.logiciels_pro.titre }}</h2>
<p class="placeholder-block__desc">{{ outils.open_source.logiciels_pro.description }}</p>
</div>
</section>
@@ -220,6 +250,31 @@ function onTabKeydown(e: KeyboardEvent) {
nextTick(() => tablistRef.value?.querySelector<HTMLElement>(`#outils-tab-${TABS[j].id}`)?.focus())
}
// Open source : sommaire d'ancres = rubriques de la sélection + les trois autres blocs.
const sommaire = computed(() => {
const os = outils.value?.open_source
if (!os) return []
return [
...(os.selection?.rubriques ?? []).map((r: any) => ({ id: `sel-${r.id}`, libelle: r.titre })),
{ id: 'sec-recommandes', libelle: 'Nos recommandations' },
{ id: 'sec-fmhy', libelle: 'Annuaire fmhy' },
...(os.logiciels_pro ? [{ id: 'sec-logiciels', libelle: 'Logiciels pro' }] : []),
]
})
const metaSelection = (l: any) => [l.licence, (l.plateformes ?? []).join(', ')].filter(Boolean).join(' · ')
// Saut vers une ancre : défilement doux sauf préférence « moins d'animations », focus sur le titre
// (clavier et lecteurs d'écran arrivent au bon endroit). L'URL ne bouge pas (?tab= intact).
function allerAncre(e: Event, id: string) {
e.preventDefault()
const el = document.getElementById(id)
if (!el) return
const reduit = window.matchMedia('(prefers-reduced-motion: reduce)').matches
el.scrollIntoView({ behavior: reduit ? 'auto' : 'smooth', block: 'start' })
el.focus({ preventScroll: true })
}
useSeoMeta({
title: () => `Outils — AEP${tab.value !== TAB_DEFAUT ? ' — ' + TABS.find((t) => t.id === tab.value)!.libelle : ''}`,
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres pour partir de soi.',
@@ -397,6 +452,76 @@ useSeoMeta({
gap: var(--space-md);
}
/* ── Open source : sommaire d'ancres et sélection ───────────── */
.outils-section--ancre { scroll-margin-top: var(--space-lg); }
.outils-section--ancre :is(h2):focus { outline: none; }
.outils-section--ancre :is(h2):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius-sm); }
.os-sommaire__liste {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-sm);
}
.os-sommaire__lien {
display: inline-block;
padding: var(--space-xs) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-alt);
color: var(--text);
font-size: 0.85rem;
font-weight: var(--fw-medium);
text-decoration: none;
}
.os-sommaire__lien:hover { border-color: var(--nav-primary-solid); }
.os-sommaire__lien:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.sel-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr;
gap: var(--space-md);
}
@media (min-width: 1024px) {
.sel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.sel-item {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.sel-item__nom {
align-self: flex-start;
font-size: 0.95rem;
font-weight: var(--fw-semibold);
color: var(--nav-primary-solid);
text-underline-offset: 2px;
overflow-wrap: anywhere;
}
.sel-item__nom:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius-sm); }
.sel-item__desc {
margin: 0;
font-size: 0.85rem;
line-height: 1.55;
color: var(--text);
}
.sel-item__meta {
margin: auto 0 0;
padding-top: 0.2rem;
font-size: 0.78rem;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
/* ── Section FMHY ───────────────────────────────────────────── */
.outils-section--fmhy {
background: var(--bg-alt);
+407 -1
View File
@@ -42,7 +42,413 @@
]
},
"open_source": {
"intention": "Des outils libres recommandés, puis un annuaire du web libre classé par usage. Choisis un usage et ouvre le lien.",
"intention": "Des outils libres choisis pour un architecte indépendant, puis nos recommandations maison et un annuaire du web libre classé par usage. Choisis un bloc dans le sommaire ou fais défiler.",
"selection": {
"intention": "Une sélection d'outils libres utiles au quotidien d'un architecte indépendant : dictée, bureautique, dessin et modélisation, cartes et données publiques. Chacun a été ouvert et vérifié le 8 octobre 2026, avec sa licence et ses plateformes.",
"rubriques": [
{
"id": "dictee-voix",
"titre": "Dictée et voix",
"note": "Ces outils font tourner Whisper sur ton ordinateur : rien ne part dans le cloud, ce qui compte quand tu dictes un CR de chantier ou un échange client.",
"liens": [
{
"nom": "Handy",
"url": "https://handy.computer/",
"desc": "Raccourci clavier, tu parles, le texte s'insère au curseur dans n'importe quelle application : dicter un mail ou une note de chantier sans quitter le logiciel ouvert, avec transcription locale.",
"source": "aep",
"licence": "MIT",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v0.9.8 du 2026-10-03)"
},
{
"nom": "Buzz",
"url": "https://buzzcaptions.com/",
"desc": "Transcrit un enregistrement de réunion ou de visite (fichier audio ou vidéo) en texte, SRT ou TXT, hors ligne : base brute pour un compte rendu.",
"source": "aep",
"licence": "MIT",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v1.4.5 du 2026-08-23)"
},
{
"nom": "Vibe",
"url": "https://github.com/thewh1teagle/vibe",
"desc": "Transcription locale de fichiers audio et vidéo, interface simple : pratique pour transformer un vocal de visite en texte à retravailler.",
"source": "aep",
"licence": "MIT",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v3.2.2 du 2026-09-05)"
}
]
},
{
"id": "quotidien",
"titre": "Le quotidien d'une agence",
"note": "De quoi tenir un cabinet d'architecte sans abonnement : bureautique, courrier, notes, PDF, visio, devis et factures.",
"liens": [
{
"nom": "LibreOffice",
"url": "https://www.libreoffice.org/",
"desc": "Rédiger CCTP, notes de calcul, tableurs de métrés et devis au format Word/Excel/PDF sans licence Microsoft.",
"source": "aep",
"licence": "MPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-08 (v26.8.1)"
},
{
"nom": "Thunderbird",
"url": "https://www.thunderbird.net/fr/",
"desc": "Client mail et agenda pour centraliser plusieurs adresses (pro, projets, associatives) hors webmail, avec filtres et signatures.",
"source": "aep",
"licence": "MPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux",
"Android"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026 (v157.0.1 sur le site le 2026-10-08)"
},
{
"nom": "Joplin",
"url": "https://joplinapp.org/",
"desc": "Notes en markdown synchronisées sur téléphone et ordinateur (chiffrées de bout en bout) : carnet de chantier, listes de réserves, recherche rapide.",
"source": "aep",
"licence": "AGPL-3.0",
"plateformes": [
"Windows",
"macOS",
"Linux",
"Android",
"iOS"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v3.7.21 du 2026-09-25)"
},
{
"nom": "Stirling-PDF",
"url": "https://github.com/Stirling-Tools/Stirling-PDF",
"desc": "Fusionner, découper, compresser, signer ou passer en OCR un PDF (dossier de DP, pièces d'un marché) sans envoyer les plans chez un service tiers quand on l'installe en local.",
"source": "aep",
"licence": "MIT (cœur ; fonctions entreprise sous licence propriétaire, non nécessaires)",
"plateformes": [
"Web (auto-hébergé)",
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v3.1.0 du 2026-10-05)"
},
{
"nom": "PDF Arranger",
"url": "https://github.com/pdfarranger/pdfarranger",
"desc": "Réordonner, pivoter, extraire ou fusionner les pages d'un PDF à la souris : composer un dossier de consultation propre en deux minutes.",
"source": "aep",
"licence": "GPL-3.0",
"plateformes": [
"Linux",
"Windows"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v1.14.0 du 2026-05-23, dépôt actif le 2026-10-01)"
},
{
"nom": "Xournal++",
"url": "https://xournalpp.github.io/",
"desc": "Annoter un plan ou un PDF au stylet sur tablette ou écran tactile : markup de visite, retours sur un plan d'exécution.",
"source": "aep",
"licence": "GPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v1.3.8 du 2026-09-26)"
},
{
"nom": "Jitsi Meet",
"url": "https://jitsi.org/jitsi-meet/",
"desc": "Visioconférence sans compte, avec partage d'écran, pour une réunion de chantier à distance ou un rendez-vous client ; instance publique gratuite ou auto-hébergeable.",
"source": "aep",
"licence": "Apache-2.0",
"plateformes": [
"Web",
"Android",
"iOS"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (version stable du 2026-09-14)"
},
{
"nom": "CryptPad",
"url": "https://cryptpad.org/",
"desc": "Documents, tableurs et tableaux partagés en ligne, chiffrés : co-éditer un CR ou un planning avec un client sans lui demander de créer un compte.",
"source": "aep",
"licence": "AGPL-3.0",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v2026.9.0 du 2026-10-07)"
},
{
"nom": "Dolibarr",
"url": "https://www.dolibarr.org/",
"desc": "ERP léger auto-hébergeable : devis, factures, suivi des paiements pour une agence ou un freelance qui veut sortir du tableur.",
"source": "aep",
"licence": "GPL-3.0",
"plateformes": [
"Web (auto-hébergé)"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v24.0.1 du 2026-09-07)"
}
]
},
{
"id": "dessiner-modeliser",
"titre": "Dessiner, modéliser, construire",
"note": "La chaîne libre de la CAO au BIM, de l'illustration à la maquette. FreeCAD et Bonsai sont sérieux mais demandent plusieurs semaines de prise en main.",
"liens": [
{
"nom": "FreeCAD",
"url": "https://www.freecad.org/",
"desc": "Modélisation paramétrique 3D avec atelier BIM, coupes et mise en plan : modéliser un projet et sortir des plans sans licence.",
"source": "aep",
"licence": "LGPL-2.1",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (v1.1.4 du 2026-09-28)"
},
{
"nom": "Bonsai (ex BlenderBIM)",
"url": "https://bonsaibim.org/",
"desc": "Créer et lire des modèles IFC (OpenBIM) dans Blender : produire un modèle BIM échangeable avec un bureau d'études sans passer par Revit ou ArchiCAD.",
"source": "aep",
"licence": "LGPL-3.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (build alpha du 2026-10-07, dépôt IfcOpenShell)"
},
{
"nom": "Blender",
"url": "https://www.blender.org/",
"desc": "Modélisation, rendu photoréaliste et animation pour illustrer un concours ou une esquisse ; socle de Bonsai.",
"source": "aep",
"licence": "GPL-3.0-or-later",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026 (v5.2 en téléchargement le 2026-10-08)"
},
{
"nom": "LibreCAD",
"url": "https://librecad.org/",
"desc": "Dessin 2D en DXF : reprendre un relevé, redessiner un plan de masse ou préparer un fichier pour la découpe, sans licence AutoCAD (pas de DWG natif).",
"source": "aep",
"licence": "GPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-05 (v2.2.1.5 du 2026-05-02, dépôt actif le 2026-10-07)"
},
{
"nom": "Inkscape",
"url": "https://inkscape.org/fr/",
"desc": "Dessin vectoriel : mettre au propre un schéma de principe, un plan-masse ou une planche de concours, export SVG et PDF.",
"source": "aep",
"licence": "GPL",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (commits du 2026-10-08)"
},
{
"nom": "GIMP",
"url": "https://www.gimp.org/",
"desc": "Retouche d'image : traiter des photos de visite, détourer, préparer des photomontages et des planches.",
"source": "aep",
"licence": "GPL-3.0-or-later",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (actualités du 2026-10-01)"
},
{
"nom": "WikiHouse Skylark",
"url": "https://github.com/wikihouseproject/Skylark",
"desc": "Système constructif open source en blocs isolants, plans de fabrication CNC publiés : référence pour étudier ou proposer une ossature bois partagée ; à faire valider par un ingénieur structure.",
"source": "aep",
"licence": "CC BY-SA 4.0",
"plateformes": [
"Web (dépôt de fichiers CAO)"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-10 (commit du 2026-10-08)"
}
]
},
{
"id": "cartes-territoire",
"titre": "Cartes, cadastre et territoire",
"note": "Les données publiques utiles avant une étude de faisabilité : parcelle, urbanisme, risques, histoire du site.",
"liens": [
{
"nom": "QGIS",
"url": "https://qgis.org/",
"desc": "Système d'information géographique complet : croiser cadastre, PLU, relief et servitudes sur un même plan-masse, export DXF.",
"source": "aep",
"licence": "GPL-2.0",
"plateformes": [
"Windows",
"macOS",
"Linux"
],
"verifie_le": "2026-10-08",
"derniere_activite": "2026-09 (v3.44.15 du 2026-09-25)"
},
{
"nom": "Géoportail (cartes.gouv.fr)",
"url": "https://cartes.gouv.fr/explorer-les-cartes/",
"desc": "Fonds IGN, cadastre, orthophotos et relief : repérer une parcelle, mesurer, imprimer un extrait ; l'ancien géoportail.gouv.fr redirige ici.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Cadastre – données ouvertes",
"url": "https://cadastre.data.gouv.fr/",
"desc": "Plan cadastral vectoriel et fichiers par commune en téléchargement libre, à charger dans QGIS pour un plan de situation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Géoportail de l'Urbanisme",
"url": "https://www.geoportail-urbanisme.gouv.fr/",
"desc": "PLU, PLUi, cartes communales et servitudes opposables : la source officielle pour vérifier la règle d'une parcelle avant de rédiger une étude.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Géorisques",
"url": "https://www.georisques.gouv.fr/",
"desc": "Inondation, argiles, radon, sismicité, sites pollués pour une adresse : le contrôle de base avant d'acheter ou de chiffrer un projet.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Remonter le temps (IGN)",
"url": "https://remonterletemps.ign.fr/",
"desc": "Cartes et photos aériennes anciennes d'un lieu : lire l'histoire d'un site, repérer d'anciennes constructions ou d'anciens usages.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "API Carto (IGN)",
"url": "https://apicarto.ign.fr/api/doc/",
"desc": "Interroger parcelles, zonage et servitudes par API : base technique pour automatiser un plan de situation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Demandes de valeurs foncières",
"url": "https://app.dvf.etalab.gouv.fr/",
"desc": "Prix de vente des biens par commune ou parcelle : étayer une fourchette d'estimation ou un argumentaire de réhabilitation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
},
{
"nom": "Cartofriches (Cerema)",
"url": "https://cartofriches.cerema.fr/cartofriches/",
"desc": "Recensement national des friches : repérer des sites à reconvertir pour une approche de réhabilitation ou de renaturation.",
"source": "aep",
"licence": "Service public, gratuit",
"plateformes": [
"Web"
],
"verifie_le": "2026-10-08",
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
}
]
}
]
},
"recommandes": [
{
"id": "dictee-universelle-groq",
+40
View File
@@ -117,6 +117,46 @@ verifie(
verifie(Array.isArray(outils.open_source?.rubriques) && outils.open_source.rubriques.every((r) => r.liens?.length > 0), 'open_source.rubriques : une rubrique vide doit disparaître')
verifie(!!outils.contribution?.texte && !!outils.contribution?.lien && !!outils.contribution?.cta, 'contribution incomplète')
// ── Open source : sélection (B6-M4) ───────────────────────────
// Chaque lien de la sélection est vérifié par une personne (licence, date) ; la liste noire
// piraterie s'applique déjà à toute chaîne d'outils.json (voir plus haut), sélection comprise.
const sel = outils.open_source?.selection
verifie(!!sel && typeof sel.intention === 'string' && sel.intention, 'open_source.selection : intention manquante')
verifie(Array.isArray(sel?.rubriques) && sel.rubriques.length >= 1, 'open_source.selection : au moins une rubrique attendue')
const idsRub = new Set()
for (const r of sel?.rubriques ?? []) {
const rub = `sélection « ${r.id ?? r.titre ?? '?'} »`
verifie(typeof r.id === 'string' && r.id && !idsRub.has(r.id), `${rub} : id manquant ou en double`)
idsRub.add(r.id)
verifie(typeof r.titre === 'string' && r.titre, `${rub} : titre manquant`)
verifie(Array.isArray(r.liens) && r.liens.length > 0, `${rub} : rubrique vide`)
for (const l of r.liens ?? []) {
const nom = `${rub} › ${l.nom ?? '?'}`
verifie(typeof l.nom === 'string' && l.nom, `${nom} : nom manquant`)
verifie(typeof l.url === 'string' && l.url.startsWith('https://'), `${nom} : url https manquante (${l.url})`)
verifie(typeof l.desc === 'string' && l.desc, `${nom} : desc manquante`)
verifie(typeof l.licence === 'string' && l.licence, `${nom} : licence manquante`)
verifie(DATE.test(l.verifie_le ?? ''), `${nom} : verifie_le « ${l.verifie_le} » n'est pas AAAA-MM-JJ`)
const bas = (l.url ?? '').toLowerCase()
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
verifie(!hit, `${nom} : domaine de la liste noire « ${hit} »`)
}
}
// Aucun doublon d'URL dans tout open_source (sélection + recommandés + rubriques fmhy), à la barre finale et à la casse près.
const norm = (u) => u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '')
const vues = new Map()
const urlsOS = [
...(sel?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`sélection › ${l.nom}`, l.url])),
...(outils.open_source?.recommandes ?? []).filter((o) => o.url).map((o) => [`recommandé › ${o.titre}`, o.url]),
...(outils.open_source?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`fmhy › ${l.nom}`, l.url])),
]
for (const [qui, url] of urlsOS) {
const k = norm(url)
verifie(!vues.has(k), `open_source : URL en double « ${url} » (${vues.get(k)} et ${qui})`)
vues.set(k, qui)
}
if (echecs.length) {
for (const e of echecs) console.log(`FAIL ${e}`)
console.log(`\n${n - echecs.length}/${n} vérifications OK — ${echecs.length} échec(s)`)