diff --git a/.env.ftp.example b/.env.ftp.example index caf594a..9ab7658 100644 --- a/.env.ftp.example +++ b/.env.ftp.example @@ -3,4 +3,7 @@ FTP_USER=ecobsoleiq FTP_PORT=21 FTP_REMOTE_DIR=/www FTP_PASS=your-ftp-password -PUBLIC_SITE_URL=https://ton-domaine.fr/ +PUBLIC_SITE_URL_PROD=https://www.lelectronrare.fr/ +PUBLIC_SITE_URL_PREVIEW=https://www.lelectronrare.fr/preview/ +FTP_DNS_RETRIES=5 +FTP_DNS_DELAY=2 diff --git a/.github/workflows/deploy-ovh-ftp.yml b/.github/workflows/deploy-ovh-ftp.yml new file mode 100644 index 0000000..05b7ead --- /dev/null +++ b/.github/workflows/deploy-ovh-ftp.yml @@ -0,0 +1,61 @@ +name: Deploy External Site to OVH FTP + +on: + workflow_dispatch: + inputs: + target: + description: Deployment target + required: true + default: preview + type: choice + options: + - preview + - production + +permissions: + contents: read + +concurrency: + group: ovh-ftp-${{ inputs.target }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + env: + FTP_HOST: ${{ secrets.OVH_FTP_HOST }} + FTP_USER: ${{ secrets.OVH_FTP_USER }} + FTP_PASS: ${{ secrets.OVH_FTP_PASS }} + FTP_PORT: ${{ secrets.OVH_FTP_PORT }} + FTP_REMOTE_DIR: ${{ secrets.OVH_FTP_REMOTE_DIR }} + PUBLIC_SITE_URL_PROD: ${{ vars.PUBLIC_SITE_URL_PROD }} + PUBLIC_SITE_URL_PREVIEW: ${{ vars.PUBLIC_SITE_URL_PREVIEW }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + apps/lab-interactif/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install lab dependencies + run: npm ci --prefix apps/lab-interactif + + - name: Install lftp + run: | + sudo apt-get update + sudo apt-get install -y lftp + + - name: Deploy target + env: + DEPLOY_TARGET_SKIP_BUILD: "0" + run: bash scripts/deploy-ovh-ftp-target.sh "${{ inputs.target }}" diff --git a/apps/lab-interactif/index.html b/apps/lab-interactif/index.html index 3f69a06..019bb15 100644 --- a/apps/lab-interactif/index.html +++ b/apps/lab-interactif/index.html @@ -10,6 +10,6 @@
- + diff --git a/apps/lab-interactif/src/App.jsx b/apps/lab-interactif/src/App.jsx index e383c85..6dfe48d 100644 --- a/apps/lab-interactif/src/App.jsx +++ b/apps/lab-interactif/src/App.jsx @@ -2,13 +2,14 @@ import { NavLink, Route, Routes } from 'react-router-dom'; import { LabHomePage } from './pages/LabHomePage'; import { SignalsPage } from './pages/SignalsPage'; import { PrototypesPage } from './pages/PrototypesPage'; +import { withSiteBase } from './lib/paths'; export function App() { return (
- + Retour site principal
diff --git a/apps/lab-interactif/vite.config.js b/apps/lab-interactif/vite.config.js index f394788..8ad2d79 100644 --- a/apps/lab-interactif/vite.config.js +++ b/apps/lab-interactif/vite.config.js @@ -1,9 +1,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const normalizeUrl = (value) => (value.endsWith('/') ? value : `${value}/`); +const rawSiteUrl = process.env.PUBLIC_SITE_URL || process.env.EXTERNAL_SITE_URL || 'https://electron-rare.github.io/'; +const parsedSiteUrl = new URL(normalizeUrl(rawSiteUrl)); +const siteBasePath = parsedSiteUrl.pathname === '/' ? '' : parsedSiteUrl.pathname.replace(/\/$/, ''); +const labBase = `${siteBasePath}/lab/`; + export default defineConfig({ plugins: [react()], - base: '/lab/', + base: labBase, build: { outDir: '../../lab', emptyOutDir: true diff --git a/astro.config.mjs b/astro.config.mjs index 2cdf5c2..a354aed 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,10 +2,15 @@ import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; import tailwindcss from '@tailwindcss/vite'; -const siteUrl = process.env.PUBLIC_SITE_URL || 'https://electron-rare.github.io/'; +const normalizeUrl = (value) => (value.endsWith('/') ? value : `${value}/`); +const rawSiteUrl = process.env.PUBLIC_SITE_URL || process.env.EXTERNAL_SITE_URL || 'https://electron-rare.github.io/'; +const siteUrl = normalizeUrl(rawSiteUrl); +const sitePathname = new URL(siteUrl).pathname; +const basePath = sitePathname === '/' ? '/' : sitePathname.replace(/\/$/, ''); export default defineConfig({ site: siteUrl, + base: basePath, integrations: [react()], vite: { plugins: [tailwindcss()] diff --git a/docs/ovh-ftp-preview-solution-2026-03-14.md b/docs/ovh-ftp-preview-solution-2026-03-14.md new file mode 100644 index 0000000..d25e412 --- /dev/null +++ b/docs/ovh-ftp-preview-solution-2026-03-14.md @@ -0,0 +1,50 @@ +# OVH FTP Preview Deploy + +## Pourquoi + +Le deploy FTP direct depuis la machine locale bloque sur le canal de donnees FTP OVH. +Le build local fonctionne, mais l'upload reste instable. + +## Solution retenue + +Deployer vers OVH FTP depuis un runner GitHub Actions public. + +Le runner : +- build le site avec la bonne `PUBLIC_SITE_URL` +- choisit la cible `preview` ou `production` +- synchronise `dist/` via `lftp` + +## Secrets GitHub requis + +- `OVH_FTP_HOST` +- `OVH_FTP_USER` +- `OVH_FTP_PASS` +- `OVH_FTP_PORT` +- `OVH_FTP_REMOTE_DIR` + +## Variables GitHub requises + +- `PUBLIC_SITE_URL_PROD=https://www.lelectronrare.fr/` +- `PUBLIC_SITE_URL_PREVIEW=https://www.lelectronrare.fr/preview/` + +## Workflow + +Fichier : +- `.github/workflows/deploy-ovh-ftp.yml` + +Script cible : +- `scripts/deploy-ovh-ftp-target.sh` + +## Usage + +### Preview + +```bash +gh workflow run deploy-ovh-ftp.yml --ref -f target=preview +``` + +### Production + +```bash +gh workflow run deploy-ovh-ftp.yml --ref -f target=production +``` diff --git a/public/assets/brand/logo-lockup.svg b/public/assets/brand/logo-lockup.svg index d2c9d4b..4f59d5c 100644 --- a/public/assets/brand/logo-lockup.svg +++ b/public/assets/brand/logo-lockup.svg @@ -1 +1 @@ - + diff --git a/public/assets/brand/logo-mark.svg b/public/assets/brand/logo-mark.svg index cca4d1f..7f319bf 100644 --- a/public/assets/brand/logo-mark.svg +++ b/public/assets/brand/logo-mark.svg @@ -1 +1 @@ - + diff --git a/public/favicon.svg b/public/favicon.svg index 44f064e..9ed3a3d 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - + diff --git a/scripts/deploy-ovh-ftp-target.sh b/scripts/deploy-ovh-ftp-target.sh new file mode 100644 index 0000000..56de8c0 --- /dev/null +++ b/scripts/deploy-ovh-ftp-target.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET="${1:-preview}" +LOCAL_DIR="${2:-dist}" +FTP_ENV_FILE="${FTP_ENV_FILE:-.env.ftp}" + +load_env_file() { + if [[ ! -f "$FTP_ENV_FILE" ]]; then + return + fi + + set -a + # shellcheck disable=SC1090 + source "$FTP_ENV_FILE" + set +a +} + +resolve_base_remote_dir() { + if [[ -n "${FTP_REMOTE_DIR:-}" ]]; then + printf '%s\n' "${FTP_REMOTE_DIR%/}" + return + fi + + if [[ -n "${FTP_URL:-}" && "$FTP_URL" =~ ^ftp://[^@:/]+@[^:/]+(:([0-9]+))?(/.*)?$ ]]; then + printf '%s\n' "${BASH_REMATCH[3]:-/www}" + return + fi + + printf '%s\n' "/www" +} + +set_target_env() { + local base_remote_dir + base_remote_dir="$(resolve_base_remote_dir)" + + case "$TARGET" in + preview) + export PUBLIC_SITE_URL="${PUBLIC_SITE_URL_PREVIEW:-https://www.lelectronrare.fr/preview/}" + export FTP_REMOTE_DIR="${base_remote_dir}/preview" + ;; + production|prod) + export PUBLIC_SITE_URL="${PUBLIC_SITE_URL_PROD:-${PUBLIC_SITE_URL:-https://www.lelectronrare.fr/}}" + export FTP_REMOTE_DIR="${base_remote_dir}" + ;; + *) + echo "Unknown target: ${TARGET}. Use 'preview' or 'production'." + exit 1 + ;; + esac + + unset FTP_URL +} + +main() { + load_env_file + set_target_env + + if [[ "${DEPLOY_TARGET_SKIP_BUILD:-0}" != "1" ]]; then + npm run build:external + else + echo "[deploy-ovh-ftp-target] Skipping build:external (already built)." + fi + + bash scripts/deploy-ovh-ftp.sh "$LOCAL_DIR" +} + +main "$@" diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index c4cad9e..2f45930 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import type { CtaLink } from '@/lib/types'; +import { withSiteBase } from '@/lib/site'; import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking'; const heroCtas: CtaLink[] = [ @@ -24,6 +25,7 @@ const heroCtas: CtaLink[] = [ ]; const HERO_TITLE_WORDS = ['Conception', 'electronique', 'sur mesure']; +const assetPath = (value: string) => withSiteBase(value); const HERO_VARIANT_ASSETS: Record< string, @@ -45,18 +47,18 @@ const HERO_VARIANT_ASSETS: Record< } > = { v12: { - routingDesktopWebp: '/assets/da/openai/hero-pcb-routing-map-v2.webp', - routingMobileWebp: '/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.webp', - routingDesktop: '/assets/da/openai/hero-pcb-routing-map-v2.png', - routingMobile: '/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.png', - instrumentDesktopWebp: '/assets/da/openai/hero-instrument-panel-v2.webp', - instrumentMobileWebp: '/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.webp', - instrumentDesktop: '/assets/da/openai/hero-instrument-panel-v2.png', - instrumentMobile: '/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.png', - notebookDesktopWebp: '/assets/da/openai/hero-carnet-labo-open-v2.webp', - notebookMobileWebp: '/assets/da/openai/carnet-labo-ouvert.webp', - notebookDesktop: '/assets/da/openai/hero-carnet-labo-open-v2.png', - notebookMobile: '/assets/da/openai/carnet-labo-ouvert.png', + routingDesktopWebp: assetPath('/assets/da/openai/hero-pcb-routing-map-v2.webp'), + routingMobileWebp: assetPath('/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.webp'), + routingDesktop: assetPath('/assets/da/openai/hero-pcb-routing-map-v2.png'), + routingMobile: assetPath('/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.png'), + instrumentDesktopWebp: assetPath('/assets/da/openai/hero-instrument-panel-v2.webp'), + instrumentMobileWebp: assetPath('/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.webp'), + instrumentDesktop: assetPath('/assets/da/openai/hero-instrument-panel-v2.png'), + instrumentMobile: assetPath('/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.png'), + notebookDesktopWebp: assetPath('/assets/da/openai/hero-carnet-labo-open-v2.webp'), + notebookMobileWebp: assetPath('/assets/da/openai/carnet-labo-ouvert.webp'), + notebookDesktop: assetPath('/assets/da/openai/hero-carnet-labo-open-v2.png'), + notebookMobile: assetPath('/assets/da/openai/carnet-labo-ouvert.png'), routingCaption: 'Architecture pcb', instrumentCaption: 'Appareil de mesure (asset)' } diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 49cc10f..437c264 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,5 +1,14 @@ --- -import { GTM_CONTAINER_ID_DEFAULT, SITE_META, SITE_OG_IMAGE_URL, SITE_URL } from '@/lib/site'; +import { + CANONICAL_URL, + GTM_CONTAINER_ID_DEFAULT, + PUBLIC_SITE_ROOT_URL, + SITE_IS_SUBPATH_BUILD, + SITE_META, + SITE_OG_IMAGE_URL, + stripSiteBase, + withSiteBase +} from '@/lib/site'; interface Props { title?: string; @@ -11,25 +20,28 @@ const { description = SITE_META.description } = Astro.props; -const canonical = SITE_URL; +const pagePath = stripSiteBase(Astro.url.pathname); +const canonical = new URL(pagePath, CANONICAL_URL).href; +const pageUrl = new URL(pagePath, PUBLIC_SITE_ROOT_URL).href; +const robotsContent = SITE_IS_SUBPATH_BUILD ? 'noindex,nofollow,noarchive' : 'index,follow,max-image-preview:large'; const gtmId = import.meta.env.PUBLIC_GTM_CONTAINER_ID || GTM_CONTAINER_ID_DEFAULT; const structuredDataJsonLd = JSON.stringify({ '@context': 'https://schema.org', '@graph': [ { '@type': 'WebSite', - '@id': `${canonical}#website`, - url: canonical, + '@id': `${CANONICAL_URL}#website`, + url: CANONICAL_URL, name: "L'électron rare", inLanguage: 'fr-FR', description: SITE_META.description }, { '@type': 'Organization', - '@id': `${canonical}#organization`, + '@id': `${CANONICAL_URL}#organization`, name: "L'électron rare", - url: canonical, - logo: `${canonical}assets/brand/logo-mark.png`, + url: CANONICAL_URL, + logo: new URL('assets/brand/logo-mark.png', CANONICAL_URL).href, sameAs: [ 'https://fr.linkedin.com/in/electron-rare', 'https://www.malt.com/profile/clementsaillant', @@ -38,15 +50,15 @@ const structuredDataJsonLd = JSON.stringify({ }, { '@type': 'Person', - '@id': `${canonical}#person`, + '@id': `${CANONICAL_URL}#person`, name: 'Clément Saillant', alternateName: "L'électron rare", - url: canonical, + url: CANONICAL_URL, jobTitle: 'Concepteur en création électronique, design produit et systèmes expérimentaux', description: "Codeur créatif, itérateur IA et inventeur de systèmes : création électronique, performances audiovisuelles et collaborations studio sous l'identité L'électron rare.", worksFor: { - '@id': `${canonical}#organization` + '@id': `${CANONICAL_URL}#organization` }, sameAs: [ 'https://fr.linkedin.com/in/electron-rare', @@ -66,20 +78,20 @@ const structuredDataJsonLd = JSON.stringify({ {title} - - - - - + + + + + - + - + @@ -413,62 +425,6 @@ const structuredDataJsonLd = JSON.stringify({ })(); - -