Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions content/blog/migration-mysql-postgresql-doctrine-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ category: "Symfony"
excerpt: "Migrer de MySQL vers PostgreSQL avec Doctrine sur un projet Symfony. Différences de typage, génération du schéma et migration des données."
image: "/images/blog/migration-mysql-postgresql.webp"
proficiencyLevel: "Expert"
mainTech:
- postgresql
- doctrine
- symfony
- php
howTo:
name: "Migrer une base MySQL vers PostgreSQL avec Doctrine"
description: "Étapes pour migrer un projet Symfony de MySQL vers PostgreSQL en s'appuyant sur Doctrine, sans interruption de service."
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/lib/blog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,52 @@ describe("getPostBySlug", () => {
expect(post!.category).toBe("");
expect(post!.excerpt).toBe("");
expect(post!.wordCount).toBe(0);
expect(post!.mainTech).toBeUndefined();
} finally {
readFileSpy.mockRestore();
existsSpy.mockRestore();
}
});

it("parses mainTech array, filtering unknown keys", () => {
const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true);
const readFileSpy = jest
.spyOn(fs, "readFileSync")
.mockReturnValue("---\nmainTech: [\"symfony\", \"unknown-tech\", 42]\n---\n" as never);

try {
const post = getPostBySlug(TEMP_SLUG);
expect(post!.mainTech).toEqual(["symfony"]);
} finally {
readFileSpy.mockRestore();
existsSpy.mockRestore();
}
});

it("returns undefined mainTech when frontmatter has only unknown keys", () => {
const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true);
const readFileSpy = jest
.spyOn(fs, "readFileSync")
.mockReturnValue("---\nmainTech: [\"unknown\"]\n---\n" as never);

try {
const post = getPostBySlug(TEMP_SLUG);
expect(post!.mainTech).toBeUndefined();
} finally {
readFileSpy.mockRestore();
existsSpy.mockRestore();
}
});

it("returns undefined mainTech when frontmatter has a non-array value", () => {
const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true);
const readFileSpy = jest
.spyOn(fs, "readFileSync")
.mockReturnValue("---\nmainTech: symfony\n---\n" as never);

try {
const post = getPostBySlug(TEMP_SLUG);
expect(post!.mainTech).toBeUndefined();
} finally {
readFileSpy.mockRestore();
existsSpy.mockRestore();
Expand Down
76 changes: 75 additions & 1 deletion src/__tests__/lib/structured-data.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { howToJsonLd, reviewsJsonLd, serviceJsonLd, eventJsonLd, jobPostingJsonLd } from "@/lib/structured-data";
import { howToJsonLd, reviewsJsonLd, serviceJsonLd, eventJsonLd, jobPostingJsonLd, articleJsonLd, TECH_ENTITIES, type TechKey } from "@/lib/structured-data";
import { categorySlugMap } from "@/lib/blog";
import type { Job } from "@/../data/jobs";

Expand Down Expand Up @@ -141,3 +141,77 @@ describe("jobPostingJsonLd", () => {
expect(result.skills).toBeUndefined();
});
});

describe("TECH_ENTITIES", () => {
it("exposes Wikidata entity links for known frameworks", () => {
expect(TECH_ENTITIES.symfony.sameAs).toContain("https://www.wikidata.org/wiki/Q2063468");
expect(TECH_ENTITIES.php.sameAs).toContain("https://www.wikidata.org/wiki/Q59");
});
});

describe("entity linking via mainTech", () => {
const articleInput = {
url: "https://www.itefficience.com/article/test",
isTech: true,
title: "Test",
excerpt: "Excerpt",
author: { "@type": "Person" as const, name: "Auteur", url: "https://example.com", jobTitle: "Author", sameAs: [] },
category: "Symfony",
date: "2026-04-01",
wordCount: 1200,
timeRequiredMinutes: 6,
};

it("serviceJsonLd emits about[] from known mainTech keys", () => {
const result = serviceJsonLd({
name: "Service",
description: "Description",
path: "/foo",
mainTech: ["symfony", "php"],
});
expect(Array.isArray(result.about)).toBe(true);
expect(result.about).toHaveLength(2);
expect(result.about?.[0]).toMatchObject({
"@type": "Thing",
name: "Symfony",
sameAs: expect.arrayContaining(["https://www.wikidata.org/wiki/Q2063468"]),
});
});

it("serviceJsonLd omits about when mainTech is missing", () => {
const result = serviceJsonLd({ name: "Service", description: "Description", path: "/foo" });
expect(result.about).toBeUndefined();
});

it("filters out unknown tech keys passed at runtime", () => {
const result = serviceJsonLd({
name: "Service",
description: "Description",
path: "/foo",
mainTech: ["symfony", "unknown-tech" as unknown as TechKey],
});
expect(result.about).toHaveLength(1);
expect(result.about?.[0].name).toBe("Symfony");
});

it("returns no about when all mainTech keys are unknown at runtime", () => {
const result = serviceJsonLd({
name: "Service",
description: "Description",
path: "/foo",
mainTech: ["nope", "also-nope"] as unknown as TechKey[],
});
expect(result.about).toBeUndefined();
});

it("articleJsonLd emits about when mainTech is provided", () => {
const result = articleJsonLd({ ...articleInput, mainTech: ["symfony"] });
expect(result.about).toHaveLength(1);
expect(result.about?.[0].name).toBe("Symfony");
});

it("articleJsonLd omits about when mainTech is absent", () => {
const result = articleJsonLd(articleInput);
expect(result.about).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions src/app/api-nodejs-nestjs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'API performantes avec NestJS et Node.js : microservices, GraphQL, temps réel, architecture hexagonale.",
path: "/api-nodejs-nestjs",
mainTech: ["nodejs","typescript"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/api-sur-mesure-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ const service = serviceJsonLd({
description:
"Développement d'API REST et GraphQL sur mesure avec Symfony et API Platform : authentification OAuth2/JWT, documentation OpenAPI, TDD et monitoring.",
path: "/api-sur-mesure-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/architecture-hexagonale-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const service = serviceJsonLd({
description:
"Conception et migration d'applications Symfony vers l'architecture hexagonale et le Domain-Driven Design : domaine isolé, ports et adaptateurs, testabilité et évolutivité.",
path: "/architecture-hexagonale-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/article/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export default async function ArticlePage({ params }: ArticlePageProps) {
wordCount: post.wordCount,
timeRequiredMinutes: readingTime(post.wordCount),
proficiencyLevel: post.proficiencyLevel,
mainTech: post.mainTech,
});

const breadcrumb = breadcrumbJsonLd([
Expand Down
1 change: 1 addition & 0 deletions src/app/audit-code-php/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const service = serviceJsonLd({
description:
"Audit technique approfondi de votre code PHP : analyse statique PHPStan niveau max, revue manuelle, rapport détaillé avec plan d'action priorisé et recommandations concrètement actionnables.",
path: "/audit-code-php",
mainTech: ["php","symfony"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/base-de-donnees-postgresql-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const service = serviceJsonLd({
description:
"Intégration de PostgreSQL dans vos projets Symfony avec Doctrine. Optimisation des requêtes, migration depuis MySQL, types avancés et indexation.",
path: "/base-de-donnees-postgresql-symfony",
mainTech: ["postgresql","symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/cloud-et-devops/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const service = serviceJsonLd({
description:
"Hébergement cloud, automatisation DevOps, migration d'infrastructure et CI/CD pour les projets web professionnels.",
path: "/cloud-et-devops",
mainTech: ["docker"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-frontend/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'interfaces frontend sur mesure avec React, Vue.js, Next.js et TypeScript. Applications connectées à vos APIs Symfony ou Node.js.",
path: "/developpement-frontend",
mainTech: ["typescript","react","vuejs"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-nodejs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'applications Node.js sur mesure : APIs REST et GraphQL, temps réel, microservices et BFF avec NestJS, TypeScript et les meilleures pratiques d'architecture.",
path: "/developpement-nodejs",
mainTech: ["nodejs","typescript"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-php/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'applications web PHP sur mesure avec PHP 8, Symfony, Doctrine et les meilleures pratiques d'architecture logicielle.",
path: "/developpement-php",
mainTech: ["php","symfony"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-react/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'applications React et TypeScript sur mesure : SPA, dashboards, backoffices. Connectées à vos APIs Symfony ou Node.js.",
path: "/developpement-react",
mainTech: ["react","typescript"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-typescript/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ const service = serviceJsonLd({
description:
"Développement d'applications typées et maintenables en TypeScript. Migration JavaScript, applications React et Node.js, configuration et outillage.",
path: "/developpement-typescript",
mainTech: ["typescript"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-vuejs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'applications Vue.js et Nuxt sur mesure. Interfaces légères, intégration Symfony native et montée en charge progressive.",
path: "/developpement-vuejs",
mainTech: ["vuejs","typescript"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/developpement-web-sur-mesure/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const service = serviceJsonLd({
description:
"Conception et développement d'applications web avec Symfony, Sylius et les technologies PHP modernes.",
path: "/developpement-web-sur-mesure",
mainTech: ["php","symfony"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/ecommerce-sylius/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const service = serviceJsonLd({
description:
"Développement de boutiques e-commerce avec Sylius, la plateforme open source basée sur Symfony.",
path: "/ecommerce-sylius",
mainTech: ["sylius","symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/hebergement-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const service = serviceJsonLd({
description:
"Hébergement cloud, déploiement CI/CD et monitoring pour les applications Symfony.",
path: "/hebergement-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/integration-docker-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const service = serviceJsonLd({
description:
"Conteneurisation et déploiement d'applications Symfony avec Docker. Dockerfile optimisé, Docker Compose, CI/CD et monitoring en production.",
path: "/integration-docker-symfony",
mainTech: ["docker","symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/integration-elasticsearch-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const service = serviceJsonLd({
description:
"Intégration d'Elasticsearch dans vos projets Symfony. Indexation, recherche full-text, filtres à facettes et suggestions pour une expérience utilisateur fluide.",
path: "/integration-elasticsearch-symfony",
mainTech: ["elasticsearch","symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/integration-redis-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const service = serviceJsonLd({
description:
"Intégration de Redis dans vos applications Symfony pour le cache, les sessions, les files d'attente Messenger et l'amélioration des performances.",
path: "/integration-redis-symfony",
mainTech: ["redis","symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/maintenance-applicative-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ const service = serviceJsonLd({
description:
"Maintenance corrective, évolutive et préventive de vos applications Symfony : correction de bugs, évolutions fonctionnelles, mises à jour de sécurité, monitoring et SLA sur mesure.",
path: "/maintenance-applicative-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/migration-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ const service = serviceJsonLd({
description:
"Migration Symfony 4, 5, 6 vers Symfony 7 : montée de version progressive par paliers, sans interruption de service. Audit des dépréciations, refactoring Rector et validation continue.",
path: "/migration-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/modernisation-application-php/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ const service = serviceJsonLd({
description:
"Migration et modernisation d'applications PHP obsolètes vers Symfony : audit technique, refactoring progressif, mise en place de tests et déploiement continu.",
path: "/modernisation-application-php",
mainTech: ["php","symfony"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/modernisation-applicative/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const service = serviceJsonLd({
name: "Modernisation applicative",
description: "Parcours structuré de modernisation d'applications PHP et Symfony : audit technique, refactoring progressif, migration architecturale et maintenance continue.",
path: "/modernisation-applicative",
mainTech: ["php","symfony"],
});

const situations = [
Expand Down
1 change: 1 addition & 0 deletions src/app/reprise-projet-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const service = serviceJsonLd({
description:
"Audit, stabilisation, documentation et maintenance d'applications Symfony reprises en cours de vie : nous prenons le relais de votre prestataire et assurons la continuité de votre projet.",
path: "/reprise-projet-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/securite-application-symfony/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const service = serviceJsonLd({
description:
"Audit de vulnérabilités, protection OWASP, conformité RGPD et mise en place de bonnes pratiques de sécurité pour vos applications Symfony.",
path: "/securite-application-symfony",
mainTech: ["symfony","php"],
});

const webPage = webPageJsonLd({
Expand Down
1 change: 1 addition & 0 deletions src/app/tests-automatises-php/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const service = serviceJsonLd({
description:
"Mise en place de stratégies de tests automatisés pour applications PHP et Symfony : tests unitaires, intégration, fonctionnels et e2e, avec intégration CI/CD pour sécuriser chaque livraison.",
path: "/tests-automatises-php",
mainTech: ["php","symfony"],
});

const webPage = webPageJsonLd({
Expand Down
9 changes: 9 additions & 0 deletions src/lib/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { BlogPost } from "@/types/blog";
import { TECH_ENTITIES, type TechKey } from "@/lib/structured-data";

const BLOG_DIR = path.join(process.cwd(), "content/blog");

function parseMainTech(value: unknown): TechKey[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter((v): v is TechKey => typeof v === "string" && v in TECH_ENTITIES);
return valid.length > 0 ? valid : undefined;
}

function countWords(markdown: string): number {
const text = markdown
.replace(/`` ` ``/g, '')
Expand Down Expand Up @@ -44,6 +51,7 @@ export function getAllPosts(): BlogPost[] {
faq: data.faq,
event: data.event,
howTo: data.howTo,
mainTech: parseMainTech(data.mainTech),
content,
wordCount: countWords(content),
};
Expand Down Expand Up @@ -74,6 +82,7 @@ export function getPostBySlug(slug: string): BlogPost | undefined {
faq: data.faq,
event: data.event,
howTo: data.howTo,
mainTech: parseMainTech(data.mainTech),
content,
wordCount: countWords(content),
};
Expand Down
Loading
Loading