portfolio

portfolio

Plateforme portfolio full stack composée d'un CMS headless Strapi 5 et d'un site Astro 5, avec contenu bilingue fr/en, rebuild frontend déclenché depuis le CMS et workflows qualité/performance. Le projet industrialise la livraison avec Docker, GitHub Actions et un enchaînement automatisé entre publication Strapi et redéploiement Astro.

🎯 Contexte et objectifs

  • Séparer clairement l’administration du contenu et le site public pour conserver un back-office éditable tout en servant un frontend statique plus simple à déployer.
  • Publier un portfolio bilingue avec routes statiques, fallback de traduction quand une locale manque et taxonomie technique exploitable côté UI.
  • Industrialiser la livraison avec Docker, GitHub Actions et un enchaînement automatisé entre publication Strapi et redéploiement du site Astro.

🛠️ Réalisations

🧩 Conception

  • cms-portfolio repose sur une architecture CMS headless en TypeScript autour de Strapi 5, PostgreSQL et des plugins users-permissions, cloudinary et color-picker, ce qui structure un back-office de contenu extensible. Source: cms-portfolio/package.json
    Extrait de code
    "dependencies": {
      "@strapi/plugin-cloud": "5.31.3",
      "@strapi/plugin-color-picker": "5.31.3",
      "@strapi/plugin-users-permissions": "5.31.3",
      "@strapi/provider-upload-cloudinary": "5.31.3",
      "@strapi/strapi": "5.31.3",
      "pg": "8.8.0",
      "react": "18.3.1",
      "react-dom": "18.3.1",
      "react-router-dom": "6.30.1",
      "styled-components": "6.1.18"
    },
    "devDependencies": {
      "@strapi/types": "^5.13.0",
      "@strapi/upgrade": "5.31.3",
      "typescript": "^5"
    }
    
  • Le modèle de données du CMS est réellement orienté portfolio: project est localisé, versionné via draftAndPublish et relié aux technologies; professional-experience et skill sont également localisés et structurés pour exposer expériences, compétences, notation et relations métier. Source: cms-portfolio/src/api/project/content-types/project/schema.json
    Extrait de code
    "options": {
      "draftAndPublish": true
    },
    "pluginOptions": {
      "i18n": {
        "localized": true
      }
    },
    "attributes": {
      "title": {
        "type": "string",
        "pluginOptions": {
          "i18n": {
            "localized": true
          }
        }
      },
      "slug": {
        "type": "uid",
        "required": true,
        "targetField": "title"
      },
      "technologies": {
        "type": "relation",
        "relation": "manyToMany",
        "target": "api::technology.technology",
        "inversedBy": "projects"
      }
    }
    
    Source: [cms-portfolio/src/api/professional-experience/content-types/professional-experience/schema.json](https://github.com/open-repos/cms-portfolio/blob/d9de1ec6ca5862e6cfbf50d3d1b0aa60e51aa9fb/src/api/professional-experience/content-types/professional-experience/schema.json)
    Extrait de code
    "attributes": {
      "start_date": {
        "type": "date",
        "required": true
      },
      "missions": {
        "type": "richtext",
        "pluginOptions": {
          "i18n": {
            "localized": true
          }
        },
        "required": true
      },
      "technologies": {
        "type": "relation",
        "relation": "manyToMany",
        "target": "api::technology.technology",
        "pluginOptions": {
          "i18n": {
            "localized": true
          }
        },
        "inversedBy": "professional_experiences"
      },
      "resume": {
        "type": "richtext",
        "required": true
      }
    }
    
  • portfolio-website-astro fournit la couche publique avec Astro 5, TypeScript, Tailwind 4, marked, highlight.js, lint/format/hooks Git et audits Lighthouse, ce qui matérialise un frontend statique orienté qualité. Source: portfolio-website-astro/package.json
    Extrait de code
    "scripts": {
      "dev": "astro dev",
      "build": "astro build",
      "prepare": "husky",
      "format:check": "prettier --check \"**/*.{astro,ts,js,css,md}\" --ignore-path .prettierignore",
      "astro:check": "astro check",
      "lint": "eslint \"src/**/*.{astro,ts,js}\" --ignore-path .eslintignore",
      "audit": "npm run build && lhci autorun",
      "lint-staged": "lint-staged"
    },
    "dependencies": {
      "@lucide/astro": "^0.542.0",
      "astro": "^5.7.6",
      "highlight.js": "^11.11.1",
      "marked": "^15.0.11",
      "marked-highlight": "^2.2.2",
      "typescript": "^5.8.3"
    },
    "devDependencies": {
      "@lhci/cli": "^0.14.0",
      "tailwindcss": "^4.1.7"
    }
    
  • L’i18n frontend est pensé de manière typée dès la conception avec un contrat de traductions et un jeu explicite de locales supportées, ce qui réduit les écarts entre contenu et interface. Source: portfolio-website-astro/src/utils/i18n.ts
    Extrait de code
    import type { Translations } from '../types/translations';
    import fr from '../locales/fr.json';
    import en from '../locales/en.json';
    
    export const translations = {
      fr,
      en,
    } as const satisfies Record<string, Translations>;
    
    export type SupportedLocale = keyof typeof translations;
    
    export const supportedLocales: SupportedLocale[] = Object.keys(translations) as SupportedLocale[];
    
    export function useTranslations(lang: SupportedLocale): Translations {
      return translations[lang];
    }
    

💻 Développement

  • Backend :
  • Une fabrique générique de lifecycle handlers synchronise les champs non localisés, sélectionne une locale source et fusionne les relations par identifiants lors de la création d’une traduction, ce qui couvre un vrai besoin de cohérence i18n côté CMS. Source: cms-portfolio/src/utils/i18n-sync.ts
    Extrait de code
    export const createI18nLifecycleHandlers = <T extends CTUID>(
      config: I18nSyncConfig<T>,
      opts?: { rebuild?: (s: Core.Strapi) => Promise<void> }
    ) => {
      const { uid, populate, fields, rebuildOnPublish = true } = config;
    
      return {
        async beforeCreate(event: any) {
          const { data } = event.params;
          const targetLocale: string | undefined = data?.locale;
          if (!targetLocale) return;
    
          const pop = normalizePopulate(uid, populate);
          let original: any = null;
    
          if (data?.documentId) {
            original = await findOriginal(uid, data.documentId, targetLocale, pop);
          }
    
          if (!original) return;
    
          for (const f of fields.copyIfMissing ?? []) {
            if (data[f] === undefined && original?.[f] !== undefined) data[f] = original[f];
          }
          for (const f of fields.mergeRelationsById ?? []) {
            const srcIds = Array.isArray(original?.[f])
              ? original[f].map((t: any) => (typeof t === 'object' ? t?.id : t)).filter((v: any) => typeof v === 'number')
              : [];
            const reqIds = relInputToIds(data?.[f]);
            const merged = Array.from(new Set<number>([...srcIds, ...reqIds]));
            if (merged.length) data[f] = { set: merged };
          }
        },
      };
    };
    
  • Le lifecycle spécifique à project copie les technologies, l’image, le slug et la date depuis la locale d’origine, puis déclenche un rebuild Astro après publication, ce qui relie directement administration de contenu et diffusion publique. Source: cms-portfolio/src/api/project/content-types/project/lifecycles.ts
    Extrait de code
    export default {
      async afterCreate(event) {
        if (event.result.publishedAt) {
          await triggerAstroRebuild(strapi);
        }
      },
      async afterUpdate(event) {
        if (event.result.publishedAt) {
          await triggerAstroRebuild(strapi);
        }
      },
      async beforeCreate(event) {
        const { data } = event.params;
    
        if (data.locale && data.documentId) {
          const original = await strapi.db.query('api::project.project').findOne({
            where: {
              documentId: data.documentId,
              locale: { $ne: data.locale },
            },
            populate: ['technologies', 'image'],
          });
    
          if (original) {
            if (original.technologies?.length) {
              data.technologies = {
                connect: original.technologies.map((tech) => ({ id: tech.id })),
              };
            }
            if (original.image?.id) data.image = original.image.id;
            if (original.slug) data.slug = original.slug;
            if (original.pubDate) data.pubDate = original.pubDate;
          }
        }
      },
    };
    
  • Le bootstrap Strapi exécute un seed uniquement quand SEED_ON_BOOT=true et positionne SEED_IN_PROGRESS pendant l’exécution, ce qui met en place un amorçage automatisable sans mélanger seed et rebuild public. Source: cms-portfolio/src/index.ts
    Extrait de code
    export default {
      async bootstrap({ strapi }: { strapi: Core.Strapi }) {
        if (process.env.SEED_ON_BOOT !== 'true') return;
    
        process.env.SEED_IN_PROGRESS = 'true';
        try {
          strapi.log.info('[seed] start');
          await runSeed(strapi);
          strapi.log.info('[seed] done');
        } finally {
          delete process.env.SEED_IN_PROGRESS;
        }
      },
    };
    
  • La configuration middleware active une CSP explicite, ouvre les sources média nécessaires à Cloudinary et configure CORS, ce qui montre une sécurité HTTP gérée au niveau applicatif et pas laissée par défaut. Source: cms-portfolio/config/middlewares.ts
    Extrait de code
    export default [
      'strapi::logger',
      'strapi::errors',
      {
        name: 'strapi::security',
        config: {
          contentSecurityPolicy: {
            useDefaults: true,
            directives: {
              'connect-src': ["'self'", 'https:'],
              'img-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
              'media-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
              upgradeInsecureRequests: null,
            },
          },
        },
      },
      {
        name: 'strapi::cors',
        config: {
          origin: ['*'],
          methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
          headers: '*',
          keepHeadersOnError: true,
        },
      },
    ];
    
  • Frontend :
  • La route dynamique des projets récupère le contenu Strapi par slug, peuple les médias et relations nécessaires, puis bascule vers une autre locale supportée si la traduction demandée est absente. Source: portfolio-website-astro/src/pages/[lang]/projects/[slug].astro
    Extrait de code
    const buildProjectUrl = (locale: SupportedLocale) => {
      const requestUrl = new URL(`${baseUrl}/api/projects`);
      requestUrl.searchParams.append('filters[slug][$eq]', String(slug));
      requestUrl.searchParams.append('locale', String(locale));
      requestUrl.searchParams.append('populate[image]', 'true');
      requestUrl.searchParams.append('populate[localizations]', 'true');
      requestUrl.searchParams.append('populate[technologies][populate][icon]', 'true');
      requestUrl.searchParams.append('populate[technologies][populate][technology_type]', 'true');
      return requestUrl;
    };
    
    const currentLocaleProject = await fetchProjectForLocale(lang);
    let project: ProjectAPIItem | undefined = currentLocaleProject;
    
    if (!project) {
      const fallbackLocales = supportedLocales.filter((locale) => locale !== lang);
      for (const fallbackLocale of fallbackLocales) {
        const fallbackProject = await fetchProjectForLocale(fallbackLocale);
        if (fallbackProject) {
          availableLocale = fallbackLocale;
          availableLocaleProject = fallbackProject;
          break;
        }
      }
    }
    
  • La navigation projet met en œuvre un filtrage croisé et une pagination côté client à partir d’attributs data-*, avec désactivation dynamique des options incompatibles et recalcul du nombre de pages. Source: portfolio-website-astro/src/utils/paginatedFilterList.ts
    Extrait de code
    export function initPaginatedFilterLists() {
      const roots = document.querySelectorAll<HTMLElement>('[data-list-root]');
    
      roots.forEach((root) => {
        const items = Array.from(root.querySelectorAll<HTMLElement>('[data-list-item]'));
        const typeCheckboxes = Array.from(
          root.querySelectorAll<HTMLInputElement>('[data-filter-type-checkbox]')
        );
        const techCheckboxes = Array.from(
          root.querySelectorAll<HTMLInputElement>('[data-filter-tech-checkbox]')
        );
    
        const applyState = () => {
          let selectedTypes = getCheckedValues(typeCheckboxes);
          let selectedTechs = getCheckedValues(techCheckboxes);
    
          const itemsForTech = items.filter((item) => {
            const itemTypes = splitDatasetValues(item.dataset.techTypes);
            return hasAnyMatch(itemTypes, selectedTypes);
          });
          const allowedTechs = collectValuesFromItems(itemsForTech, 'techNames');
          setCheckboxAvailability(techCheckboxes, allowedTechs);
    
          const filteredItems = items.filter((item) => {
            const itemTypes = splitDatasetValues(item.dataset.techTypes);
            const itemTechs = splitDatasetValues(item.dataset.techNames);
            return hasAnyMatch(itemTypes, selectedTypes) && hasAnyMatch(itemTechs, selectedTechs);
          });
    
  • Le layout partagé génère les balises alternate et canonical par contenu/localisation, ce qui ancre un SEO multilingue explicite directement dans la structure des pages. Source: portfolio-website-astro/src/layouts/BaseLayout.astro
    Extrait de code
    const alternateLinks: AlternateLink[] =
      entity && contentType && slug
        ? [
            { href: `${baseUrl}/${currentLang}/${contentType}/${slug}`, hreflang: currentLang },
            ...otherLangs.map((loc) => ({
              href: `${baseUrl}/${loc.locale}/${contentType}/${slug}`,
              hreflang: loc.locale,
            })),
            { href: `${baseUrl}/${contentType}/${slug}`, hreflang: 'x-default' },
          ]
        : [];
    ---
    
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>{title || 'Mon Portfolio'}</title>
      <meta name="description" content={description} />
      {
        alternateLinks.map((link) => (
          <link rel="alternate" href={link.href} hreflang={link.hreflang} />
        ))
      }
      {
        entity && contentType && slug && (
          <link rel="canonical" href={`${baseUrl}/${currentLang}/${contentType}/${slug}`} />
        )
      }
    </head>
    

🏗️ DevOps & Qualité

  • Le CMS dispose d’un outillage Docker distinct pour la production et d’un déploiement VM via scp/ssh dans GitHub Actions, avec reconstruction des conteneurs et nettoyage Docker avant redémarrage. Source: cms-portfolio/.github/workflows/deploy.yml
    Extrait de code
    on:
      push:
        branches: [ master ]
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Copy files via SCP
            uses: appleboy/scp-action@v0.1.7
          - name: Rebuild and restart Strapi on VM
            uses: appleboy/ssh-action@v1.0.0
            with:
              script: |
                cd /var/www/strapi-portfolio
                docker compose -f docker-compose.prod.yml down --remove-orphans || true
                docker builder prune -af || true
                docker image prune -af || true
                docker compose -f docker-compose.prod.yml build --pull --force-rm || { echo "Build failed"; exit 1; }
                docker compose -f docker-compose.prod.yml up -d || { echo "Compose up failed"; exit 1; }
    
  • Le frontend automatise le build et le déploiement sur push ou repository_dispatch, et il ajoute des workflows dédiés à l’audit Lighthouse, GreenFrame, Ecoindex, ESLint et Prettier pour contrôler qualité, performance et impact environnemental. Source: portfolio-website-astro/.github/workflows/deploy.yml
    Extrait de code
    on:
      push:
        branches: [main]
      repository_dispatch:
        types: [strapi-content-update]
    jobs:
      build-and-deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Install dependencies
            run: npm ci
          - name: Build site
            run: npm run build
            env:
              PUBLIC_SITE_URL: ${{ secrets.PUBLIC_SITE_URL }}
              PUBLIC_STRAPI_URL: ${{ secrets.PUBLIC_STRAPI_URL }}
              STRAPI_API_TOKEN: ${{ secrets.STRAPI_TOKEN }}
          - name: Deploy to VM via SSH
            uses: appleboy/scp-action@v1
    
    Source: [portfolio-website-astro/.github/workflows/green-audit.yml](https://github.com/open-repos/website-portfolio/blob/ba9c02e0f84241d75bce7d2aee72ac3c106bd236/.github/workflows/green-audit.yml)
    Extrait de code
    jobs:
      greenframe:
        runs-on: ubuntu-latest
        env:
          PAGES_TO_TEST: / /fr/projects /en/projects
        steps:
          - name: Build the site
            run: npm run build
          - name: Run GreenFrame on local build
            if: github.ref == 'refs/heads/develop'
            run: |
              for PAGE in $PAGES_TO_TEST; do
                greenframe analyze "http://localhost:4321$PAGE" --threshold=0.045
              done
          - name: Run GreenFrame on production
            if: github.ref == 'refs/heads/main'
            run: |
              for PAGE in $PAGES_TO_TEST; do
                SITE_URL="${PUBLIC_SITE_URL%/}"
                greenframe analyze "$SITE_URL$PAGE" --threshold=0.045
              done
    

📈 Résultats

  • Sur la base de l’historique Git local observé entre le 27 avril 2025 et le 5 mars 2026, l’ensemble représente 136 commits répartis entre portfolio-website-astro (83) et cms-portfolio (53), pour 3 auteurs uniques visibles dans Git. Le résultat concret est une chaîne complète allant du contenu éditable dans Strapi jusqu’au site Astro buildé et déployé.
  • Le bénéfice technique est directement lisible dans le code: une publication CMS peut déclencher un rebuild frontend, le site sert deux locales avec fallback de traduction et les workflows automatisent la qualité, le déploiement et une partie des audits de performance et d’empreinte.

🔧 Environnement technique

  • Backend: Strapi 5, TypeScript, PostgreSQL via pg, React pour l’admin Strapi, @strapi/plugin-users-permissions, @strapi/provider-upload-cloudinary, @strapi/plugin-color-picker, lifecycle hooks, seed bootstrap et middleware sécurité. Source: cms-portfolio/package.json
    Extrait de code
    "dependencies": {
      "@strapi/plugin-cloud": "5.31.3",
      "@strapi/plugin-color-picker": "5.31.3",
      "@strapi/plugin-users-permissions": "5.31.3",
      "@strapi/provider-upload-cloudinary": "5.31.3",
      "@strapi/strapi": "5.31.3",
      "pg": "8.8.0",
      "react": "18.3.1",
      "react-dom": "18.3.1"
    }
    
  • Frontend: Astro 5, TypeScript, Tailwind CSS 4, marked, marked-highlight, highlight.js, @lucide/astro, PostCSS, ts-node, dotenv, pagination/filtrage côté client, SEO multilingue et génération statique. Source: portfolio-website-astro/package.json
    Extrait de code
    "dependencies": {
      "@astrojs/check": "^0.9.4",
      "@lucide/astro": "^0.542.0",
      "astro": "^5.7.6",
      "highlight.js": "^11.11.1",
      "marked": "^15.0.11",
      "marked-highlight": "^2.2.2",
      "typescript": "^5.8.3"
    },
    "devDependencies": {
      "@tailwindcss/postcss": "^4.1.7",
      "dotenv": "^16.5.0",
      "postcss": "^8.5.3",
      "tailwindcss": "^4.1.7",
      "ts-node": "^10.9.2"
    }
    
  • DevOps et qualité: Docker multi-stage, docker-compose dev/prod, GitHub Actions, Husky, lint-staged, ESLint, Prettier, Lighthouse CI, GreenFrame et Ecoindex. Source: portfolio-website-astro/package.json
    Extrait de code
    "scripts": {
      "prepare": "husky",
      "format:check": "prettier --check \"**/*.{astro,ts,js,css,md}\" --ignore-path .prettierignore",
      "astro:check": "astro check",
      "lint": "eslint \"src/**/*.{astro,ts,js}\" --ignore-path .eslintignore",
      "audit": "npm run build && lhci autorun",
      "lint-staged": "lint-staged"
    },
    "devDependencies": {
      "@lhci/cli": "^0.14.0",
      "eslint": "^8.57.1",
      "husky": "^9.1.7",
      "lint-staged": "^15.5.1",
      "prettier": "^3.6.2",
      "stylelint": "^16.19.1"
    }
    
🌐 Voir le projet

Technologies utilisées

Frontend
Astro
React
Tailwind CSS
TypeScript
DevOps
Docker
docker-compose
GitHub Actions
Qualite / Tests
ESLint
Prettier
Bases de donnees (SGBD & SQL)
PostgreSQL
Backend
Strapi