diff --git a/.docs/reference/anchor-lexicon.md b/.docs/reference/anchor-lexicon.md new file mode 100644 index 00000000..412fff41 --- /dev/null +++ b/.docs/reference/anchor-lexicon.md @@ -0,0 +1,153 @@ +# Lexique des ancres + +> Seuil d'alerte : > 60% exact match sur une même URL +> Ce fichier est lu directement par `scripts/audit-anchor-diversity.ts` + +### Objectif pour script +> targets: exactMatch=30 partial=30 semantic=40 + +--- + +## `/audit-symfony-gratuit` + +### Exact match + +- audit symfony gratuit +- audit gratuit symfony + +### Partial + +- notre audit symfony de 30 minutes +- l'audit symfony offert +- bénéficier d'un audit symfony sans frais +- profitez de l'audit symfony gratuit +- demander votre audit symfony gratuit + +### Variations sémantiques + +- diagnostic technique symfony +- bilan de votre application symfony +- état des lieux symfony +- analyse de votre projet symfony +- revue technique symfony +- examen de votre code symfony +- audit de code symfony +- contrôle qualité symfony +- inspection symfony gratuite +- évaluation de votre app symfony + +--- + +## `/developpement-php` + +### Exact match + +- développement php +- developpement php + +### Partial + +- notre offre de développement php +- confier votre développement php +- solutions de développement php sur mesure +- projet de développement php + +### Variations sémantiques + +- expertise php +- réalisation d'application php +- prestation php +- développeur php senior +- solution web php +- application php métier +- votre projet php +- ingénierie php +- code php maintenable +- architecture php robuste + +--- + +## `/agence-symfony-france` + +### Exact match + +- agence symfony france +- agence symfony en france + +### Partial + +- notre agence symfony basée en france +- faire appel à une agence symfony française +- agence spécialisée symfony en france + +### Variations sémantiques + +- experts symfony +- prestataire symfony +- spécialistes symfony +- équipe symfony dédiée +- cabinet symfony +- développeurs symfony expérimentés +- partenaire symfony +- studio symfony +- consultants symfony + +--- + +## `/contact` + +### Exact match + +- nous contacter +- contactez-nous +- contactez nous +- contact + +### Partial + +- prendre contact avec notre équipe +- contacter un expert symfony +- envoyer votre demande + +### Variations sémantiques + +- prendre rendez-vous +- discuter de votre projet +- parler de votre besoin +- échanger sur votre projet +- obtenir un devis +- demander une estimation +- poser votre question +- soumettre votre projet +- démarrer votre projet +- en savoir plus sur nos services + +--- + +## `/audit-code-php` + +### Exact match + +- audit de code php +- audit php +- audit code php + +### Partial + +- notre audit de code php +- réaliser un audit php +- demander un audit de code php +- audit php de votre application + +### Variations sémantiques + +- diagnostic php +- analyse de code php +- revue de code php +- bilan de votre code php +- état des lieux php +- contrôle qualité php +- inspection de code php +- évaluation technique php +- analyse qualité php +- revue d'architecture php \ No newline at end of file diff --git a/.gitignore b/.gitignore index be10fc61..141c8110 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ yarn-error.log* next-env.d.ts chrome/ public/images/blog/responsive/ + +#revision plan +/.docs/reference/anchor-revision-plan.md \ No newline at end of file diff --git a/content/blog/claude-assistant-architecture-symfony-legacy.mdx b/content/blog/claude-assistant-architecture-symfony-legacy.mdx index b38d2dd6..64738d5b 100644 --- a/content/blog/claude-assistant-architecture-symfony-legacy.mdx +++ b/content/blog/claude-assistant-architecture-symfony-legacy.mdx @@ -271,7 +271,7 @@ Le plus inattendu : le `CLAUDE.md` est devenu le document d'architecture de réf - [Symfony AI dans un projet legacy](/article/symfony-ai-projet-legacy-retour-experience), intégrer l'IA dans du code existant - [RAG avec Symfony AI et Doctrine](/article/rag-symfony-ai-doctrine-indexer-base-metier), indexer sa base métier pour l'IA -- [Modernisation applicative](/modernisation-applicative), notre parcours complet pour reprendre un legacy en main +- [Adaptation de vos outils aux standards actuels](/modernisation-applicative), notre parcours complet pour reprendre un legacy en main - [Migration Symfony vers l'architecture hexagonale](/article/migration-symfony-architecture-hexagonale-retour-mission), structurer le legacy - [Monter en compétence sur Claude Code](/article/monter-en-competence-claude-code), le guide complet : skills, hooks et serveurs MCP - [Serveurs MCP pour développeurs Symfony](/article/serveurs-mcp-claude-code-developpeurs-symfony), connecter Claude Code à sa base de données et ses outils diff --git a/content/blog/commandes-invocables-symfony-attributs-console.mdx b/content/blog/commandes-invocables-symfony-attributs-console.mdx index 88cb5269..533e0b9b 100644 --- a/content/blog/commandes-invocables-symfony-attributs-console.mdx +++ b/content/blog/commandes-invocables-symfony-attributs-console.mdx @@ -331,7 +331,7 @@ Trois critères pour décider : 2. **Commandes complexes avec beaucoup d'inputs** → migrez avec `#[MapInput]` pour assainir la signature 3. **Commandes avec `interact()` sophistiqué** (wizards multi-étapes) → gardez l'ancien style ou utilisez `#[Interact]` si la logique reste simple -Si vous êtes en train de [migrer votre projet vers Symfony 7.4](/article/guide-de-migration-dans-un-projet-symfony), c'est l'occasion idéale de convertir vos commandes une par une. Notre accompagnement en [migration Symfony](/migration-symfony) couvre ce type de modernisation progressive. [PHPStan](/article/phpstan-niveau-max-symfony-10-erreurs) vous aidera à vérifier que vous n'avez rien cassé. +Si vous êtes en train de [migrer votre projet vers Symfony 7.4](/article/guide-de-migration-dans-un-projet-symfony), c'est l'occasion idéale de convertir vos commandes une par une. Notre accompagnement en [migration technique vers Symfony](/migration-symfony) couvre ce type de modernisation progressive. [PHPStan](/article/phpstan-niveau-max-symfony-10-erreurs) vous aidera à vérifier que vous n'avez rien cassé. ## Un signe de la direction que prend Symfony diff --git a/content/blog/comment-former-vos-equipes-a-la-securite-informatique-en-toute-simplicite.mdx b/content/blog/comment-former-vos-equipes-a-la-securite-informatique-en-toute-simplicite.mdx index 86cb7ed0..9c9911e9 100644 --- a/content/blog/comment-former-vos-equipes-a-la-securite-informatique-en-toute-simplicite.mdx +++ b/content/blog/comment-former-vos-equipes-a-la-securite-informatique-en-toute-simplicite.mdx @@ -100,7 +100,7 @@ L'OWASP SAMM fournit un framework d'évaluation structuré autour de cinq pratiq Former des équipes à la sécurité informatique n'est pas un projet avec une date de fin. C'est une capacité organisationnelle qui se construit par couches successives : d'abord les fondamentaux (OWASP Top 10, hygiène de développement), puis les pratiques d'ingénierie (revue de code sécurisée, outillage SAST/DAST, gestion des dépendances), enfin la dimension architecturale (threat modeling, pipeline DevSecOps, programme de Security Champions, pilotage par la maturité). -L'erreur la plus fréquente est de traiter la sécurité comme un sujet à part, délégué à des spécialistes. Les organisations les plus résilientes sont celles où chaque développeur, chaque lead, chaque architecte intègre la sécurité comme une dimension intrinsèque de la qualité logicielle. Notre service d'[accompagnement et conseil](/accompagnement-et-conseil) inclut des programmes de sensibilisation et de formation adaptés au niveau de maturité de chaque équipe. +L'erreur la plus fréquente est de traiter la sécurité comme un sujet à part, délégué à des spécialistes. Les organisations les plus résilientes sont celles où chaque développeur, chaque lead, chaque architecte intègre la sécurité comme une dimension intrinsèque de la qualité logicielle. Notre service d'[appui stratégique](/accompagnement-et-conseil) inclut des programmes de sensibilisation et de formation adaptés au niveau de maturité de chaque équipe. ## Pour aller plus loin diff --git a/content/blog/comment-se-passe-un-audit-chez-efficience-it-quel-contenu-comment-procede-t-on-quels-sont-les-criteres-quel-procede.mdx b/content/blog/comment-se-passe-un-audit-chez-efficience-it-quel-contenu-comment-procede-t-on-quels-sont-les-criteres-quel-procede.mdx index 04713a39..efdfb2c7 100644 --- a/content/blog/comment-se-passe-un-audit-chez-efficience-it-quel-contenu-comment-procede-t-on-quels-sont-les-criteres-quel-procede.mdx +++ b/content/blog/comment-se-passe-un-audit-chez-efficience-it-quel-contenu-comment-procede-t-on-quels-sont-les-criteres-quel-procede.mdx @@ -102,7 +102,7 @@ Un audit technique régulier, comme un contrôle technique pour un véhicule, d ## Pour aller plus loin -- [Découvrez les raisons de nous confier la maintenance de vos applications web](/article/decouvrez-les-raisons-de-nous-confier-la-maintenance-de-vos-applications-web), pourquoi la maintenance applicative est indispensable après un audit +- [Pourquoi nous confier la maintenance de vos applications web](/article/decouvrez-les-raisons-de-nous-confier-la-maintenance-de-vos-applications-web), pourquoi la maintenance applicative est indispensable après un audit - [La dette technique : faut-il vraiment en avoir peur ?](/article/la-dette-technique-faut-il-vraiment-en-avoir-peur), comprendre et gérer la dette technique identifiée lors d'un audit - [PHPStan au niveau max sur un projet Symfony](/article/phpstan-niveau-max-symfony-10-erreurs), les erreurs les plus courantes révélées par l'analyse statique - [Guide de migration dans un projet Symfony](/article/guide-de-migration-dans-un-projet-symfony), transformer les recommandations d'audit en plan de migration concret diff --git a/content/blog/core-team-organisation-projet.mdx b/content/blog/core-team-organisation-projet.mdx index bee7a6c7..284a4707 100644 --- a/content/blog/core-team-organisation-projet.mdx +++ b/content/blog/core-team-organisation-projet.mdx @@ -72,7 +72,7 @@ Les outils collaboratifs deviennent le centre de gravité : le code, les issues, La Core Team doit pouvoir dire "non" ou changer de direction sans attendre un aval hiérarchique. Concrètement, cela passe par un système de vote entre membres ou l'autorité du Lead sur les sujets non consensuels. Cette décentralisation élimine les goulots d'étranglement bureaucratiques qui paralysent tant de projets. -C'est exactement le même principe qu'un bon [cahier des charges agile](/article/comment-rediger-un-cahier-de-charge-pour-un-projet-agile) : définir un cadre clair, puis laisser l'équipe autonome dans l'exécution. Notre service d'[accompagnement et conseil](/accompagnement-et-conseil) aide les organisations à structurer leurs équipes selon ce modèle. +C'est exactement le même principe qu'un bon [cahier des charges agile](/article/comment-rediger-un-cahier-de-charge-pour-un-projet-agile) : définir un cadre clair, puis laisser l'équipe autonome dans l'exécution. Notre service de [pilotage stratégique](/accompagnement-et-conseil) aide les organisations à structurer leurs équipes selon ce modèle. ### L'automatisation au service du flux diff --git a/content/blog/doctavis-et-efficience-it-une-course-contre-la-montre-pour-sortir-un-mvp.mdx b/content/blog/doctavis-et-efficience-it-une-course-contre-la-montre-pour-sortir-un-mvp.mdx index 3dd8c08a..03032739 100644 --- a/content/blog/doctavis-et-efficience-it-une-course-contre-la-montre-pour-sortir-un-mvp.mdx +++ b/content/blog/doctavis-et-efficience-it-une-course-contre-la-montre-pour-sortir-un-mvp.mdx @@ -111,7 +111,7 @@ Avez-vous une idée de projet ? Contactez Efficience IT qui vous accompagnera to - [Comment rédiger un cahier des charges pour un projet Agile](/article/comment-rediger-un-cahier-de-charge-pour-un-projet-agile), structurer votre projet pour maximiser ses chances de succès - [Pourquoi choisir Symfony pour vos projets](/article/pourquoi-choisir-symfony-pour-vos-projets), les raisons techniques qui en font le framework de référence pour les projets métier -- [Découvrez les raisons de nous confier la maintenance de vos applications web](/article/decouvrez-les-raisons-de-nous-confier-la-maintenance-de-vos-applications-web), assurer la pérennité du MVP après sa mise en production +- [Maintenance d'applications web : pourquoi nous choisir](/article/decouvrez-les-raisons-de-nous-confier-la-maintenance-de-vos-applications-web), assurer la pérennité du MVP après sa mise en production - [Symfony : Le framework PHP pour les projets web](https://symfony.com/), le framework utilisé pour développer le MVP Doctavis - [Lean Startup : Méthodologie MVP](http://theleanstartup.com/), l'approche Lean Startup qui inspire la construction de MVP - [API Platform : Framework pour créer des API](https://api-platform.com/), outil pour construire rapidement des API robustes avec Symfony diff --git a/content/blog/guide-de-migration-dans-un-projet-symfony.mdx b/content/blog/guide-de-migration-dans-un-projet-symfony.mdx index 31a2fa2f..4c86ed9c 100644 --- a/content/blog/guide-de-migration-dans-un-projet-symfony.mdx +++ b/content/blog/guide-de-migration-dans-un-projet-symfony.mdx @@ -104,7 +104,7 @@ Toute migration majeure mérite une analyse de risques formalisée. Trois axes s **Risque métier** : périodes critiques à éviter (Black Friday, clôture comptable), SLA à respecter, tolérance à l'indisponibilité, impact d'une régression sur le chiffre d'affaires. -Un projet avec une dette technique modérée, une bonne couverture de tests et une équipe expérimentée migrera en place avec la stratégie par phases. Un projet legacy critique avec peu de tests et des bundles abandonnés justifiera un Strangler Fig ou une réécriture partielle. Pour les applications PHP vieillissantes, notre parcours de [modernisation applicative](/modernisation-applicative) prend en charge la transition complète vers un socle Symfony moderne. Dans ce second cas, notre service de [migration Symfony](/migration-symfony) accompagne les équipes de l'audit initial jusqu'au déploiement de la version cible, en s'appuyant sur une méthodologie éprouvée. +Un projet avec une dette technique modérée, une bonne couverture de tests et une équipe expérimentée migrera en place avec la stratégie par phases. Un projet legacy critique avec peu de tests et des bundles abandonnés justifiera un Strangler Fig ou une réécriture partielle. Pour les applications PHP vieillissantes, notre parcours de [modernisation de vos outils](/modernisation-applicative) prend en charge la transition complète vers un socle Symfony moderne. Dans ce second cas, notre service de [migration Symfony](/migration-symfony) accompagne les équipes de l'audit initial jusqu'au déploiement de la version cible, en s'appuyant sur une méthodologie éprouvée. ### Conduire le changement dans l'organisation diff --git a/content/blog/les-6-etapes-pour-monter-en-competences-sur-symfony.mdx b/content/blog/les-6-etapes-pour-monter-en-competences-sur-symfony.mdx index 71db121d..5bdf4296 100644 --- a/content/blog/les-6-etapes-pour-monter-en-competences-sur-symfony.mdx +++ b/content/blog/les-6-etapes-pour-monter-en-competences-sur-symfony.mdx @@ -18,7 +18,7 @@ Pour structurer votre apprentissage, quelques ressources se distinguent par leur - [SymfonyCasts](https://symfonycasts.com/), dont les parcours vidéo progressifs permettent de comprendre le "pourquoi" derrière chaque mécanisme - Le blog officiel Symfony, indispensable pour suivre les évolutions du framework à chaque version -La lecture seule ne suffit pas. Dès les premières semaines, créez un projet personnel qui dépasse le simple CRUD : une application avec authentification, gestion de rôles et au moins une [commande console](/article/commandes-invocables-symfony-attributs-console). Pour les développeurs qui abordent le framework depuis zéro, [Symfony pour les moldus](/article/symfony-pour-les-moldus) offre une introduction accessible avant de plonger dans ces ressources. C'est dans la friction du réel que se forment les réflexes. +La lecture seule ne suffit pas. Dès les premières semaines, créez un projet personnel qui dépasse le simple CRUD : une application avec authentification, gestion de rôles et au moins une [commande console](/article/commandes-invocables-symfony-attributs-console). Pour les développeurs qui abordent le framework depuis zéro, [Comprendre Symfony facilement](/article/symfony-pour-les-moldus) offre une introduction accessible avant de plonger dans ces ressources. C'est dans la friction du réel que se forment les réflexes. ## Maîtriser le conteneur de services et l'injection de dépendances diff --git a/content/blog/les-certifications-symfony-twig-symfony-sylius.mdx b/content/blog/les-certifications-symfony-twig-symfony-sylius.mdx index f8b90226..7b9a33b0 100644 --- a/content/blog/les-certifications-symfony-twig-symfony-sylius.mdx +++ b/content/blog/les-certifications-symfony-twig-symfony-sylius.mdx @@ -125,4 +125,4 @@ Efficience IT met l'accent sur la formation continue, combinant connaissances th - [Les 6 étapes pour monter en compétences sur Symfony](/article/les-6-etapes-pour-monter-en-competences-sur-symfony), Un parcours structuré pour progresser sur le framework - [Pourquoi choisir Symfony pour vos projets](/article/pourquoi-choisir-symfony-pour-vos-projets), Les atouts de Symfony pour le développement d'applications métier -- [Symfony pour les moldus](/article/symfony-pour-les-moldus), Revenir aux fondamentaux du framework avant de passer la certification +- [Symfony pour débutants](/article/symfony-pour-les-moldus), Revenir aux fondamentaux du framework avant de passer la certification diff --git a/content/blog/migration-symfony-architecture-hexagonale-retour-mission.mdx b/content/blog/migration-symfony-architecture-hexagonale-retour-mission.mdx index cd6de5ec..5b1af5c7 100644 --- a/content/blog/migration-symfony-architecture-hexagonale-retour-mission.mdx +++ b/content/blog/migration-symfony-architecture-hexagonale-retour-mission.mdx @@ -11,7 +11,7 @@ proficiencyLevel: "Expert" Le client nous appelle un mardi. Son application Symfony gère 40 000 commandes par mois. Le code a six ans. Trois équipes bossent dessus en parallèle. Les déploiements prennent deux heures parce que personne n'ose toucher au code sans régression. La dette technique est devenue un risque business. -La demande : rendre l'application maintenable sans tout réécrire. Ce type de projet est au cœur de notre offre de [modernisation applicative](/modernisation-applicative) et de [migration Symfony](/migration-symfony). On a proposé une migration progressive vers une architecture hexagonale. Pas un big bang. Pas un projet de six mois en salle blanche. Une migration chirurgicale, feature par feature, en continuant à livrer. +La demande : rendre l'application maintenable sans tout réécrire. Ce type de projet est au cœur de notre offre de [modernisation de vos applications](/modernisation-applicative) et de [migration Symfony](/migration-symfony). On a proposé une migration progressive vers une architecture hexagonale. Pas un big bang. Pas un projet de six mois en salle blanche. Une migration chirurgicale, feature par feature, en continuant à livrer. Voici comment ça s'est passé. diff --git a/content/blog/monofony-le-guide-ultime-pour-les-debutants.mdx b/content/blog/monofony-le-guide-ultime-pour-les-debutants.mdx index e0cb4610..45fce81e 100644 --- a/content/blog/monofony-le-guide-ultime-pour-les-debutants.mdx +++ b/content/blog/monofony-le-guide-ultime-pour-les-debutants.mdx @@ -114,7 +114,7 @@ Monofony occupe une place précise dans l'écosystème PHP : entre Symfony pur, ## Pour aller plus loin -- [Symfony pour les moldus](/article/symfony-pour-les-moldus), comprendre les bases du framework Symfony +- [Symfony pour les débutants](/article/symfony-pour-les-moldus), comprendre les bases du framework Symfony - [Pourquoi choisir Symfony pour vos projets](/article/pourquoi-choisir-symfony-pour-vos-projets), les atouts de Symfony pour vos applications - [Les bundles les plus utilisés dans les projets Symfony](/article/les-bundles-les-plus-utilises-dans-les-projets-symfony), découvrir l'écosystème de bundles qui complète Monofony - [Dépôt GitHub de Monofony](https://github.com/Monofony/Monofony), code source et documentation du projet diff --git a/content/blog/symfony-pour-les-moldus.mdx b/content/blog/symfony-pour-les-moldus.mdx index cc799e29..70d3b22f 100644 --- a/content/blog/symfony-pour-les-moldus.mdx +++ b/content/blog/symfony-pour-les-moldus.mdx @@ -79,7 +79,7 @@ La SymfonyCon, conférence annuelle internationale, rassemble chaque année des Pour un simple site vitrine, Symfony n'est pas nécessairement le bon choix. Il prend tout son sens lorsque le projet implique une logique métier complexe, des intégrations multiples, des exigences de sécurité élevées ou une durée de vie longue. Applications métier (ERP, CRM), plateformes e-commerce, API alimentant des applications mobiles, intranets sécurisés : ce sont les terrains où Symfony excelle, comme le détaille l'article [pourquoi choisir Symfony pour vos projets](/article/pourquoi-choisir-symfony-pour-vos-projets). -Un framework comme Symfony reste un outil exigeant qui nécessite des développeurs compétents pour en exploiter le potentiel. Pour les équipes qui débutent ou souhaitent monter en compétences, notre [formation Symfony en entreprise](/formation-symfony-entreprise) offre un parcours adapté aux projets réels. Plus largement, notre service d'[accompagnement et conseil](/accompagnement-et-conseil) structure la montée en compétences de vos équipes techniques. S'entourer d'une équipe expérimentée, comme Efficience IT, fait toute la différence entre un projet qui tient ses promesses et un projet qui s'enlise. +Un framework comme Symfony reste un outil exigeant qui nécessite des développeurs compétents pour en exploiter le potentiel. Pour les équipes qui débutent ou souhaitent monter en compétences, notre [formation Symfony en entreprise](/formation-symfony-entreprise) offre un parcours adapté aux projets réels. Plus largement, notre service d'[expertise stratégique](/accompagnement-et-conseil) structure la montée en compétences de vos équipes techniques. S'entourer d'une équipe expérimentée, comme Efficience IT, fait toute la différence entre un projet qui tient ses promesses et un projet qui s'enlise. ## Pour aller plus loin diff --git a/package-lock.json b/package-lock.json index f36bcaea..8302eedf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -583,34 +583,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -3818,42 +3790,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4758,21 +4694,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4866,15 +4787,6 @@ "node": ">= 8" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -6070,15 +5982,6 @@ "dev": true, "license": "MIT" }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6445,18 +6348,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -11500,15 +11391,6 @@ "node": ">=10" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -15585,52 +15467,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -16104,15 +15940,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -16646,18 +16473,6 @@ "fd-slicer": "~1.1.0" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7082a24f..02aabffd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "eslint", "knip": "knip", "test": "jest", - "generate:image-variants": "npx -y tsx@4 scripts/generate-image-variants.ts" + "generate:image-variants": "npx -y tsx@4 scripts/generate-image-variants.ts", + "audit:anchor-diversity": "npx -y tsx scripts/audit-anchor-diversity.ts --all" }, "dependencies": { "@tailwindcss/typography": "^0.5.19", diff --git a/scripts/audit-anchor-diversity.ts b/scripts/audit-anchor-diversity.ts new file mode 100644 index 00000000..de5f0b5f --- /dev/null +++ b/scripts/audit-anchor-diversity.ts @@ -0,0 +1,562 @@ +#!/usr/bin/env tsx +/* npx tsx scripts/audit-anchor-diversity.ts (--all) */ + +import fs from "fs"; +import path from "path"; + +const SCAN_DIRS = ["src/app", "src/components", "content/blog"]; +const EXTENSIONS = [".tsx", ".jsx", ".mdx"]; +const DEFAULT_THRESHOLD = 60; +const ALERT_MARGIN = 7; + +interface AnchorOccurrence { + file: string; + line: number; + href: string; + anchorText: string; + category: "exact_match" | "partial" | "semantic"; +} + +interface PageReport { + targetUrl: string; + total: number; + exactMatch: { count: number; pct: number; anchors: string[] }; + partial: { count: number; pct: number; anchors: string[] }; + semantic: { count: number; pct: number; anchors: string[] }; + occurrences: AnchorOccurrence[]; + alert: boolean; +} + +interface LexiconEntry { + exactMatch: string[]; + partial: string[]; + semantic: string[]; + targets?: { exactMatch: number; partial: number; semantic: number }; +} + +const LEXICON_PATH = path.resolve(".docs/reference/anchor-lexicon.md"); + +let _globalTargets: { exactMatch: number; partial: number; semantic: number } | null = null; + +function loadLexicon(): Record { + if (!fs.existsSync(LEXICON_PATH)) { + console.warn(`⚠ Lexique introuvable : ${LEXICON_PATH}`); + return {}; + } + + const raw = fs.readFileSync(LEXICON_PATH, "utf-8"); + const lexicon: Record = {}; + + const globalTargetsMatch = raw.match(/^>\s*targets:\s*exactMatch=(\d+)\s+partial=(\d+)\s+semantic=(\d+)/im); + _globalTargets = globalTargetsMatch + ? { + exactMatch: parseInt(globalTargetsMatch[1]), + partial: parseInt(globalTargetsMatch[2]), + semantic: parseInt(globalTargetsMatch[3]), + } + : null; + + let currentUrl: string | null = null; + let currentCategory: "exactMatch" | "partial" | "semantic" | null = null; + + for (const line of raw.split("\n")) { + const urlMatch = line.match(/^##\s+(`[^`]+`|\S+)/); + if (urlMatch) { + currentUrl = urlMatch[1].replace(/`/g, "").trim(); + lexicon[currentUrl] = { exactMatch: [], partial: [], semantic: [] }; + currentCategory = null; + continue; + } + + if (!currentUrl) continue; + + const targetsMatch = line.match(/^>\s*targets:\s*exactMatch=(\d+)\s+partial=(\d+)\s+semantic=(\d+)/i); + if (targetsMatch) { + lexicon[currentUrl].targets = { + exactMatch: parseInt(targetsMatch[1]), + partial: parseInt(targetsMatch[2]), + semantic: parseInt(targetsMatch[3]), + }; + continue; + } + + if (line.match(/###.*[Ee]xact/)) { currentCategory = "exactMatch"; continue; } + if (line.match(/###.*([Pp]artial|[Pp]artiel)/)) { currentCategory = "partial"; continue; } + if (line.match(/###.*([Ss]émantique|[Ss]emantic|[Vv]ariation)/)) { currentCategory = "semantic"; continue; } + if (line.match(/^###/)) { currentCategory = null; continue; } + + const itemMatch = line.match(/^-\s+(.+)/); + if (itemMatch && currentCategory && currentCategory in lexicon[currentUrl]) { + (lexicon[currentUrl] as unknown as Record)[currentCategory].push(itemMatch[1].trim().toLowerCase()); + } + } + + return lexicon; +} + +function getTargets(entry: LexiconEntry | undefined): { exactMatch: number; partial: number; semantic: number } { + if (entry?.targets) return entry.targets; + if (_globalTargets) return _globalTargets; + return { exactMatch: 30, partial: 30, semantic: 40 }; +} + +function walkDir(dir: string, results: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".next") continue; + walkDir(fullPath, results); + } else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + results.push(fullPath); + } + } +} + +function findFiles(): string[] { + const root = process.cwd(); + const results: string[] = []; + for (const dir of SCAN_DIRS) { + const fullDir = path.join(root, dir); + if (fs.existsSync(fullDir)) walkDir(fullDir, results); + } + return results; +} + +function extractJsxLinks(content: string, filePath: string): AnchorOccurrence[] { + const results: AnchorOccurrence[] = []; + const linkRegex = + /(?:]*?\bhref=(?:"([^"#][^"]*?)"|'([^'#][^']*?)'|`([^`#][^`]*?)`)[^>]*?>([\s\S]*?)<\/(?:Link|a)>/g; + + let match: RegExpExecArray | null; + while ((match = linkRegex.exec(content)) !== null) { + const href = match[1] || match[2] || match[3]; + const anchorText = match[4].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); + if (!anchorText || !href) continue; + results.push({ + file: path.relative(process.cwd(), filePath), + line: content.slice(0, match.index).split("\n").length, + href, + anchorText, + category: "semantic", + }); + } + return results; +} + +function extractMdxLinks(content: string, filePath: string): AnchorOccurrence[] { + const results: AnchorOccurrence[] = []; + const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + + let match: RegExpExecArray | null; + while ((match = mdLinkRegex.exec(content)) !== null) { + const anchorText = match[1].trim(); + const href = match[2].trim(); + if (!anchorText || !href) continue; + results.push({ + file: path.relative(process.cwd(), filePath), + line: content.slice(0, match.index).split("\n").length, + href, + anchorText, + category: "semantic", + }); + } + return results; +} + +function normalizeUrl(url: string): string { + return url.replace(/\/$/, "").toLowerCase().split("?")[0].split("#")[0]; +} + +function wordMatch(a: string, b: string): boolean { + if (b.length < 4) return a === b; + const re = new RegExp(`(? +): "exact_match" | "partial" | "semantic" { + const text = anchorText.toLowerCase().trim().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/'/g, ' ').replace(/→/g, '').replace(/→/g, ''); + const lex = lexicon[targetUrl]; + if (!lex) return "semantic"; + if (lex.exactMatch.some((em) => text === em)) return "exact_match"; + if (lex.partial?.some((pt) => wordMatch(text, pt) || wordMatch(pt, text))) return "partial"; + if (lex.semantic.some((sem) => wordMatch(text, sem) || wordMatch(sem, text))) return "semantic"; + + const exactWords = lex.exactMatch.join(" ").split(" "); + const matchedWords = exactWords.filter((w) => w.length > 3 && text.includes(w)); + if (matchedWords.length >= 2) return "partial"; + return "semantic"; +} + +function buildReport( + targetUrl: string, + occurrences: AnchorOccurrence[] +): PageReport { + const total = occurrences.length; + + if (total === 0) { + return { + targetUrl, + total: 0, + exactMatch: { count: 0, pct: 0, anchors: [] }, + partial: { count: 0, pct: 0, anchors: [] }, + semantic: { count: 0, pct: 0, anchors: [] }, + occurrences: [], + alert: false, + }; + } + + const em = occurrences.filter((o) => o.category === "exact_match"); + const pt = occurrences.filter((o) => o.category === "partial"); + const sm = occurrences.filter((o) => o.category === "semantic"); + const pct = (n: number) => Math.round((n / total) * 100); + + return { + targetUrl, + total, + exactMatch: { count: em.length, pct: pct(em.length), anchors: [...new Set(em.map((o) => o.anchorText))] }, + partial: { count: pt.length, pct: pct(pt.length), anchors: [...new Set(pt.map((o) => o.anchorText))] }, + semantic: { count: sm.length, pct: pct(sm.length), anchors: [...new Set(sm.map((o) => o.anchorText))] }, + occurrences, + alert: pct(em.length) > DEFAULT_THRESHOLD, + }; +} + +interface Projection { + projExact: number; + projPartial: number; + projSemantic: number; + exactToPartial: number; + exactToSemantic: number; + partialToExact: number; + semanticToExact: number; + partialToSemantic: number; + semanticToPartial: number; +} + +function computeProjection( + total: number, + emCount: number, + ptCount: number, + smCount: number, + targets: { exactMatch: number; partial: number; semantic: number } +): Projection { + const zero: Projection = { + projExact: 0, projPartial: 0, projSemantic: 0, + exactToPartial: 0, exactToSemantic: 0, + partialToExact: 0, semanticToExact: 0, + partialToSemantic: 0, semanticToPartial: 0, + }; + if (total === 0) return zero; + + const emIdeal = Math.round(total * targets.exactMatch / 100); + const emMax = Math.floor(total * (targets.exactMatch + ALERT_MARGIN) / 100); + const projExact = Math.min(emIdeal, emMax); + + const remaining = total - projExact; + const ptRatio = targets.partial / (targets.partial + targets.semantic); + let projPartial = Math.round(remaining * ptRatio); + let projSemantic = remaining - projPartial; + + if (remaining >= 2) { + if (projPartial === 0) { projPartial = 1; projSemantic -= 1; } + else if (projSemantic === 0) { projSemantic = 1; projPartial -= 1; } + } + + const excessExact = Math.max(0, emCount - projExact); + const missingExact = Math.max(0, projExact - emCount); + + const ptNeed = Math.max(0, projPartial - ptCount); + const smNeed = Math.max(0, projSemantic - smCount); + const totalNeed = ptNeed + smNeed; + + let exactToPartial = 0, exactToSemantic = 0; + if (excessExact > 0) { + if (totalNeed > 0) { + exactToPartial = Math.round(excessExact * (ptNeed / totalNeed)); + exactToSemantic = excessExact - exactToPartial; + } else { + exactToPartial = Math.round(excessExact * ptRatio); + exactToSemantic = excessExact - exactToPartial; + } + } + + let partialToExact = 0, semanticToExact = 0; + if (missingExact > 0) { + partialToExact = Math.min(missingExact, ptCount); + semanticToExact = Math.min(missingExact - partialToExact, smCount); + } + + const ptAfter = ptCount - partialToExact + exactToPartial; + const smAfter = smCount - semanticToExact + exactToSemantic; + const partialToSemantic = Math.max(0, ptAfter - projPartial); + const semanticToPartial = Math.max(0, smAfter - projSemantic); + + return { + projExact, projPartial, projSemantic, + exactToPartial, exactToSemantic, + partialToExact, semanticToExact, + partialToSemantic, semanticToPartial, + }; +} + +function printReport(reports: PageReport[], isFullAudit: boolean, lexiconUrls: Set, lexicon: Record): void { + const R = "\x1b[31m"; + const G = "\x1b[32m"; + const Y = "\x1b[33m"; + const C = "\x1b[36m"; + const B = "\x1b[1m"; + const X = "\x1b[0m"; + + const gap = (actual: number, target: number): string => { + const diff = actual - target; + if (Math.abs(diff) <= 5) return `${G}≈ cible${X}`; + return diff > 0 ? `${R}+${diff}% vs cible${X}` : `${Y}${diff}% vs cible${X}`; + }; + + const mode = isFullAudit ? "Audit complet" : "Pages hub"; + const globalNote = _globalTargets + ? `global (${_globalTargets.exactMatch}/${_globalTargets.partial}/${_globalTargets.semantic})` + : "fallback 30/30/40"; + console.log(`\n${B}═══════════════════════════════════════════════════${X}`); + console.log(`${B} Audit Diversité des Ancres - ${mode} - Marge : ±${ALERT_MARGIN}%${X}`); + console.log(`${B}═══════════════════════════════════════════════════${X}`); + console.log(`\n${B} Objectif cible par page (depuis lexicon URL > ${globalNote})${X}`); + console.log(` ${G}Exact match${X} ex: "audit Symfony gratuit"`); + console.log(` ${Y}Partiel${X} ex: "notre audit Symfony de 30 minutes"`); + console.log(` ${C}Sémantique${X} ex: "diagnostic technique Symfony"`); + console.log(`\n ${G}≈ cible${X} écart ≤ 5% ${R}+N% vs cible${X} trop haut ${Y}-N% vs cible${X} trop bas\n`); + + for (const report of reports) { + const targets = getTargets(lexicon[report.targetUrl]); + const status = report.alert ? `${R}⚠ ALERTE${X}` : `${G}✓ OK${X}`; + console.log(`${B}${C}${report.targetUrl}${X} ${status}`); + console.log(` Total occurrences : ${report.total} (cibles: EM=${targets.exactMatch}% PT=${targets.partial}% SM=${targets.semantic}% · seuil alerte: ${DEFAULT_THRESHOLD}% exact match)`); + + if (report.total === 0) { + console.log(` ${Y}Aucun lien trouvé vers cette page.${X}\n`); + continue; + } + + const emColor = report.exactMatch.pct > DEFAULT_THRESHOLD ? R : G; + console.log(` Exact match : ${emColor}${report.exactMatch.pct}%${X} (${report.exactMatch.count}) cible ~${targets.exactMatch}% ${gap(report.exactMatch.pct, targets.exactMatch)}`); + console.log(` → ${report.exactMatch.anchors.join(" | ") || "-"}`); + console.log(` Partiel : ${report.partial.pct}% (${report.partial.count}) cible ~${targets.partial}% ${gap(report.partial.pct, targets.partial)}`); + console.log(` → ${report.partial.anchors.join(" | ") || "-"}`); + console.log(` Semantic : ${C}${report.semantic.pct}%${X} (${report.semantic.count}) cible ~${targets.semantic}% ${gap(report.semantic.pct, targets.semantic)}`); + console.log(` → ${report.semantic.anchors.slice(0, 5).join(" | ") || "-"}`); + + if (isFullAudit && !lexiconUrls.has(report.targetUrl)) { + console.log(` ${Y}ℹ Catégorisation approximative (lexique inféré) - ajouter cette URL dans le lexicon pour un résultat précis.${X}`); + } + + if (report.alert) { + const proj = computeProjection(report.total, report.exactMatch.count, report.partial.count, report.semantic.count, targets); + const parts: string[] = []; + if (proj.exactToPartial > 0) parts.push(`${proj.exactToPartial} exact → partiel`); + if (proj.exactToSemantic > 0) parts.push(`${proj.exactToSemantic} exact → sémantique`); + if (proj.partialToExact > 0) parts.push(`${proj.partialToExact} partiel → exact`); + if (proj.semanticToExact > 0) parts.push(`${proj.semanticToExact} sémantique → exact`); + if (proj.partialToSemantic > 0) parts.push(`${proj.partialToSemantic} partiel → sémantique`); + if (proj.semanticToPartial > 0) parts.push(`${proj.semanticToPartial} sémantique → partiel`); + if (parts.length > 0) console.log(` ${R}→ ${parts.join(" · ")}${X}`); + } + + console.log(); + } + + const alerts = reports.filter((r) => r.alert); + if (alerts.length === 0) { + console.log(`${G}${B}✓ Toutes les pages sont sous le seuil.${X}\n`); + } else { + console.log(`${R}${B}⚠ ${alerts.length} page(s) dépassent le seuil d'exact match.${X}\n`); + } +} + +function buildSection( + r: PageReport, + lexiconUrls: Set, + lexicon: Record +): string { + const statusIcon = r.alert ? "⚠️" : "✅"; + const inLexicon = lexiconUrls.has(r.targetUrl); + const note = !inLexicon + ? "\n> ⚠️ Catégorisation approximative - ajouter dans `.docs/reference/anchor-lexicon.md`.\n" + : ""; + + const targets = getTargets(lexicon[r.targetUrl]); + + const categoryLabel: Record = { + exact_match: "🔵 Exact", + partial: "🟡 Partiel", + semantic: "✅ Sémantique", + }; + + const tableRows = (): string => { + if (r.total === 0) return `| - | - | - | - |`; + return r.occurrences + .map((o) => `| ${categoryLabel[o.category]} | \`${o.file}:${o.line}\` | ${o.anchorText} | - |`) + .join("\n"); + }; + + const { projExact, projPartial, projSemantic, exactToPartial, exactToSemantic, partialToExact, semanticToExact, partialToSemantic, semanticToPartial } = computeProjection( + r.total, + r.exactMatch.count, + r.partial.count, + r.semantic.count, + targets + ); + + const pct = (n: number) => (r.total > 0 ? Math.round((n / r.total) * 100) : 0); + + const actionParts: string[] = []; + if (exactToPartial > 0) actionParts.push(`${exactToPartial} exact → partiel`); + if (exactToSemantic > 0) actionParts.push(`${exactToSemantic} exact → sémantique`); + if (partialToExact > 0) actionParts.push(`${partialToExact} partiel → exact`); + if (semanticToExact > 0) actionParts.push(`${semanticToExact} sémantique → exact`); + if (partialToSemantic > 0) actionParts.push(`${partialToSemantic} partiel → sémantique`); + if (semanticToPartial > 0) actionParts.push(`${semanticToPartial} sémantique → partiel`); + + const hasChanges = actionParts.length > 0; + + const actionLine = hasChanges + ? `> 🔧 Actions : ${actionParts.join(" · ")}` + : null; + + const projLine = hasChanges + ? `*Après correction → Exact match : **${pct(projExact)}%** · Partiel : **${pct(projPartial)}%** · Sémantique : **${pct(projSemantic)}%***` + : null; + + const targetsNote = `> Cibles : Exact match ${targets.exactMatch}% · Partiel ${targets.partial}% · Sémantique ${targets.semantic}%`; + + return [ + `### \`${r.targetUrl}\` ${statusIcon}`, + "", + targetsNote, + "", + `Exact match : **${r.exactMatch.pct}%** · Partiel : **${r.partial.pct}%** · Sémantique : **${r.semantic.pct}%** · Total : ${r.total}`, + projLine, + actionLine, + note, + "| Statut | Fichier | Ancre actuelle | Ancre proposée |", + "|--------|---------|----------------|----------------|", + tableRows(), + ] + .filter(Boolean) + .join("\n"); +} + +function writeRevisionPlan( + reports: PageReport[], + lexiconUrls: Set, + lexicon: Record +): void { + const date = new Date().toISOString().slice(0, 10); + const alerts = reports.filter((r) => r.alert); + const ok = reports.filter((r) => !r.alert); + + const alertSections = alerts.map((r) => buildSection(r, lexiconUrls, lexicon)).join("\n\n---\n\n"); + const okSections = ok.map((r) => buildSection(r, lexiconUrls, lexicon)).join("\n\n---\n\n"); + + const sections = [ + "## ⚠️ Pages en alerte", + "", + alertSections, + "", + "---", + "", + "## Pages OK", + "", + okSections, + ].join("\n"); + + const output = [ + `# Plan de révision des ancres - ${date}`, + "", + `> ${alerts.length} page(s) en alerte sur ${reports.length} auditées.`, + "", + "## Acceptance criteria", + "", + "- [ ] Script d'audit opérationnel", + "- [ ] Rapport initial produit", + `- [ ] ${alerts.length === 0 ? "~~Aucune page hub au-dessus du seuil~~" : "Aucune page hub au-dessus du seuil après révision"}`, + "- [ ] Lexique à jour (`docs/anchor-lexicon.md`)", + "", + "---", + "", + "## Détail par page", + "", + sections, + "", + "## Légende", + "- 🔵 Exact match", + "- 🟡 Partiel", + "- ✅ Sémantique", + ].join("\n"); + + const outPath = path.resolve(".docs/reference/anchor-revision-plan.md"); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, output, "utf-8"); + console.log(`Plan de révision écrit : ${outPath}`); +} + +async function main() { + const args = process.argv.slice(2); + const isFullAudit = args.includes("--all"); + const lexicon = loadLexicon(); + const files = findFiles(); + + console.log(`Scan de ${files.length} fichiers...`); + + const allOccurrences: AnchorOccurrence[] = []; + + for (const file of files) { + const content = fs.readFileSync(file, "utf-8"); + const ext = path.extname(file); + const raw = ext === ".mdx" + ? extractMdxLinks(content, file) + : extractJsxLinks(content, file); + allOccurrences.push(...raw); + } + + let targetUrls: string[]; + + if (isFullAudit) { + const allHrefs = allOccurrences + .map((o) => normalizeUrl(o.href)) + .filter((href) => href.startsWith("/") && !href.match(/\.(png|jpg|svg|ico|webp|pdf)$/)); + targetUrls = [...new Set(allHrefs)].sort(); + + for (const url of targetUrls) { + if (lexicon[url]) continue; + const urlLabel = url.split('/').filter(Boolean).pop()?.replace(/-/g, ' ') ?? ''; + lexicon[url] = { exactMatch: [urlLabel], partial: [], semantic: [] }; + } + } else { + targetUrls = Object.keys(lexicon); + } + + const reports: PageReport[] = targetUrls.map((url) => { + const normalized = normalizeUrl(url); + const relevant = allOccurrences + .filter((o) => normalizeUrl(o.href) === normalized) + .map((o) => ({ ...o, category: categorizeAnchor(o.anchorText, url, lexicon) })); + return buildReport(url, relevant); + }); + + const lexiconUrls = new Set(Object.keys(loadLexicon())); + + printReport(reports, isFullAudit, lexiconUrls, lexicon); + writeRevisionPlan(reports, lexiconUrls, lexicon); + + process.exit(reports.some((r) => r.alert) ? 1 : 0); +} + +main().catch((err) => { + console.error("Erreur :", err); + process.exit(2); +}); \ No newline at end of file diff --git a/src/app/notre-expertise/page.tsx b/src/app/notre-expertise/page.tsx index a3beaa29..eef45b6c 100644 --- a/src/app/notre-expertise/page.tsx +++ b/src/app/notre-expertise/page.tsx @@ -221,7 +221,7 @@ export default function NotreExpertise() {
  • Agence Symfony à Lille : notre ancrage régional
  • Prestataire Symfony en France
  • -
  • Accompagnement et conseil
  • +
  • Aide à la prise de décision stratégique
diff --git a/src/components/seo/GonePage.tsx b/src/components/seo/GonePage.tsx index a43b03d3..3c049366 100644 --- a/src/components/seo/GonePage.tsx +++ b/src/components/seo/GonePage.tsx @@ -36,7 +36,7 @@ export default function GonePage() { href="/article/7-bonnes-raisons-de-rejoindre-efficience-it" className="font-semibold text-primary hover:underline" > - 7 bonnes raisons de rejoindre Efficience IT + 7 raisons de choisir Efficience IT