diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..80690af --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + # npm dependencies + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies + commit-message: + prefix: chore(deps) + include: scope + + # GitHub Actions workflow updates + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + labels: + - dependencies + - ci + commit-message: + prefix: chore(ci) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d02bb1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main, claude] + pull_request: + branches: [main] + +jobs: + build: + name: Type-check & build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build (tsc + vite) + run: npm run build + + - name: Verify build artefacts + run: npm run verify + + audit: + name: npm audit (critical only) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Audit + run: npm audit --audit-level=critical diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..1f70874 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,32 @@ +name: SonarCloud + +on: + push: + branches: [main, claude] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + sonar: + name: Analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - uses: SonarSource/sonarcloud-github-action@master + continue-on-error: true + with: + args: -Dsonar.qualitygate.wait=false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index a547bf3..91ef82f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,15 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - +# Dependencies node_modules +package-lock.json + +# Build output dist -dist-ssr -*.local -# Editor directories and files -.vscode/* -!.vscode/extensions.json +# Confidential assets +public/datas/ + +# IDE +.vscode .idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +*.swp +CLAUDE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a65c11a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,203 @@ +# Architecture Overview + +D-CAS 2.0 is a client-side, framework-free single-page application built with TypeScript, Vite, and Tailwind CSS. This document describes how the system is organized and how its key features work. + +## Directory Structure + +``` +src/ +├── main.ts # Entry point: renders header into #header-mount, +│ # initializes offline support & router +├── router.ts # Client-side router & route definitions +├── layout.ts # Re-exports header / bindHeaderEvents / initOffline +├── style.css # Tailwind directives +├── i18n.ts # Translation resources (EN/FR/ES) & language detection +│ +├── views/ # Page-level components (return HTML strings — no header) +│ ├── home.ts # Interactive questionnaire +│ ├── catalog.ts # Disease list with search & sorting +│ ├── about.ts # About page (credits, CIRAD info) +│ ├── privacy.ts # Privacy policy +│ └── legal.ts # Legal notice & copyright +│ +├── components/ # Reusable UI pieces (return HTML strings) +│ ├── header.ts # Navigation, language selector, offline toggle +│ ├── questionnaire.ts # Question tree navigation & disease results +│ ├── disease_result.ts # Disease detail view with carousel +│ ├── call_to_action.ts # CTA for sugarcane diseases book +│ ├── cirad_corner.ts # CIRAD corner logo (home page) +│ ├── breadcrumb.ts # Navigation breadcrumb +│ └── question_button.ts # Styled question button +│ +├── data/ +│ └── key-loader.ts # Loads & caches disease JSON from public/datas/ +│ +└── vite-env.d.ts # Vite type definitions +``` + +`index.html` has two mount points: `#header-mount` (header rendered once at boot, re-rendered only on language change) and `#app` (route content, swapped on navigation). + +## Routing + +**Client-side router** in `router.ts` with three-language URL aliases. Each route may declare an optional `init` callback for post-render wiring: + +```ts +type Route = { + path: string + titleKey: string + view: () => string + init?: () => void +} + +const routes: Route[] = [ + { path: '/', titleKey: 'home.title', view: homeView, init: initQuestionnaire }, + { path: '/catalogue', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue }, + { path: '/catalog', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue }, + { path: '/catalogo', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue }, + // ... etc for about, privacy, legal +] +``` + +**Navigation flow:** +1. User clicks an internal `` tag. +2. Router intercepts the click (skipping `target="_blank"`, `download`, modifier keys, `mailto:`, `tel:` and absolute URLs), prevents default, calls `navigateTo()`. +3. `navigateTo()` pushes state to history and re-renders the main content (`app.innerHTML = route.view()`). +4. `route.init?.()` runs after the view is in the DOM. +5. `window.popstate` listener handles back/forward buttons. + +The header is **not** part of `route.view()` — it lives in its own `#header-mount` and is only re-rendered when the language changes. + +## Internationalization (i18n) + +**Language detection** in `i18n.ts`: +1. Browser's language detector (`i18next-browser-languagedetector`) reads `navigator.language`. +2. Falls back to English if user's language is not supported. + +**Translation resources:** +- Single object with three languages (EN, FR, ES). +- Nested keys: `t('catalogue.diseaseNameColumn')`. +- All visible text must be added here — never hardcoded in views. + +**Changing language:** +- User selects from dropdown in header. +- `i18next.changeLanguage()` updates the language. +- `main.ts`'s `refreshHeader()` re-renders the header into `#header-mount`, then `router.render()` re-renders the current route's main content. +- Service Worker is notified to precache the selected language's image assets. + +**URL paths are localized:** +``` +/catalogue (English) +/catalogo (Spanish) +/catalogue (French, same as English by convention) +``` + +The header component (`header.ts`) maps these paths based on current language via `lp(key)` helper. + +## Data Loading & Caching + +**Disease data** is loaded from JSON files in `public/datas/`: + +``` +identification-key.json (English) +cle-identification.json (French) +clave-de-identificacion.json (Spanish) +diseases-img/ (Disease photographs) +``` + +**Key loader** (`data/key-loader.ts`): +- Single fetch per session (cached in memory). +- If a translated key file is empty, falls back to English. +- Used by both questionnaire and catalog views. + +## Offline Support (Service Worker) + +**Service Worker** in `public/sw.js`: + +1. **App shell** (HTML, identification keys, favicon) — precached at install time. +2. **Vite bundles** (hashed JS/CSS) — at install time, the SW fetches `/.vite/manifest.json` (generated by `build.manifest: true` in `vite.config.ts`) and precaches every entry's `file`, `css`, and `assets`. This guarantees the app works fully offline on first visit. +3. **Disease images** — precached on demand when the user picks a language (or after initial SW registration). Workers run in parallel (6 concurrent fetches) with an in-progress guard per language so rapid language toggles don't queue duplicate runs. +4. **Runtime strategy** — images (`/datas/diseases-img/*` and `/assets/*.{png,jpg,jpeg,webp,svg,gif,ico}`) use cache-first; everything else uses network-first with cache fallback. +5. **Cache busting** — bump `CACHE_NAME` in `sw.js` when the caching strategy changes. Old caches are deleted on activation. + +**Image precaching progress:** +- SW broadcasts `{ type: 'precache-progress', done, total }` to all clients every 10 images. +- Header dropdown shows the progress bar and "Downloading 45/120" / "Ready for offline use ✓". + +**User can toggle offline mode** via header switch (`offline-toggle`). This registers or unregisters the SW and wipes its caches. + +## Component Patterns + +All views and components follow the same pattern: + +```ts +export function myComponent(): string { + const t = i18next.t.bind(i18next) + + return /*html*/` +
+ ${t('my.key')} +
+ ` +} + +export function initMyComponent(): void { + // Set up event listeners + document.querySelector('.my-class')?.addEventListener('click', ...) +} +``` + +**Why this pattern?** +- No JSX, no virtual DOM, no build complexity. +- Easy to modify styling (Tailwind utilities in the HTML template). +- Event listeners bound explicitly (no magic). +- Type-safe (TypeScript strict mode). + +## Adding a New Page + +1. **Create view** in `src/views/mypage.ts`: + ```ts + export function myPageView(): string { return `...` } + export function initMyPage(): void { /* listeners */ } + ``` + +2. **Add translations** to `src/i18n.ts` (EN, FR, ES). + +3. **Register route** in `src/router.ts` (use the `init?` field to wire post-render listeners): + ```ts + { path: '/mypage', titleKey: 'mypage.title', view: myPageView, init: initMyPage } + ``` + +4. **Add navigation link** in `src/components/header.ts` (if needed). + +## Key Technical Decisions + +| Decision | Rationale | +| -------- | --------- | +| No framework | Minimal bundle, easy to modify, direct DOM control | +| TypeScript strict | Catch errors at build time, no `any` escapes | +| Tailwind CSS | Responsive design, utility-first, zero unused CSS | +| Service Worker | Full offline support for field use | +| i18next | Standard i18n library, easy to add languages | +| String HTML templates | No build step for views, fast iteration | +| Client-side router | No backend needed, static hosting compatible | + +## Performance Considerations + +- **Initial load** — ~200KB gzipped (Vite optimizes JS, Tailwind purges unused classes). +- **Disease images** — Downloaded on-demand by Service Worker, cached for offline use. +- **Catalog search** — Linear scan of disease list (fast enough for <200 diseases). +- **Mobile landscape** — Special height handling (`max-md:landscape:h-[200vh]`) to prevent table clipping. + +## Confidential Assets + +`public/datas/` is **gitignored** and must be provided by CIRAD. Without it: +- Questionnaire loads empty data (shows "Downloading..."). +- Catalog shows empty list. +- Images are not available. + +The app degrades gracefully; it doesn't crash. + +--- + +**Last updated:** May 2026 +**Maintained by:** [ffillouxdev](https://github.com/ffillouxdev) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e02be2 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# D-CAS 2.0 — A Guide to Sugarcane Diseases + +> Offline-first Progressive Web App for identifying sugarcane diseases, built for [CIRAD](https://www.cirad.fr/). + +D-CAS 2.0 (*Détermination et Catalogue des Affections de la canne à Sucre*) is a trilingual (EN / FR / ES) field tool that helps agronomists and growers identify sugarcane diseases through a guided questionnaire or a searchable catalog of referenced pathologies. + +## Features + +- **Guided diagnosis** — Answer a few questions about observed symptoms to narrow down possible diseases. +- **Disease catalog** — Browse, search and sort all referenced diseases with their pathogens, symptoms, images and geographical distribution. +- **Fully offline** — Once loaded, the app works without connectivity thanks to a Service Worker that caches the identification key and disease images. +- **Trilingual** — Interface and content available in English, French and Spanish, with per-language disease trees. +- **Mobile-friendly** — Responsive layout optimized for both portrait and landscape phone usage in the field. +- **About / Legal / Privacy pages** — Informational pages accessible from the navigation menu. +- **PWA installable** — Can be installed on mobile and desktop devices. + +## Tech Stack + +| Concern | Choice | +| -------------- | --------------------------------------------------------- | +| Language | TypeScript (strict mode) | +| Bundler | [Vite 5](https://vitejs.dev/) | +| Styling | [Tailwind CSS 4](https://tailwindcss.com/) + PostCSS | +| i18n | [i18next](https://www.i18next.com/) + browser detector | +| Routing | Custom client-side router (`src/router.ts`) | +| Offline | Native Service Worker (`public/sw.js`) | +| Framework | None — vanilla DOM manipulation | + +## Getting Started + +### Prerequisites + +- **Node.js** ≥ 18 +- **npm** ≥ 9 + +### Installation + +```bash +git clone git@github.com:ffillouxdev/D-CAS-2.0.git +cd D-CAS-2.0 +npm install +``` + +### Development + +```bash +npm run dev +``` + +The dev server starts on [http://localhost:5173](http://localhost:5173). + +### Production build + +```bash +npm run build # Type-check with tsc, then bundle to dist/ +npm run preview # Preview the production build locally +``` + +## Project Structure + +``` +D-CAS-2.0/ +├── public/ +│ ├── assets/ # Static images (logos, backgrounds, CTA) +│ ├── datas/ # Identification keys + disease images (gitignored) +│ └── sw.js # Service Worker +├── src/ +│ ├── components/ # Reusable UI pieces (header, questionnaire, ...) +│ ├── data/ # Data loaders (key-loader.ts) +│ ├── views/ # Page-level views (home, catalog, about, privacy, legal) +│ ├── i18n.ts # Translation resources & language detection +│ ├── layout.ts # Shared layout exports +│ ├── router.ts # Client-side router +│ ├── main.ts # Entry point +│ └── style.css # Tailwind directives +├── index.html +├── tailwind.config.js +├── vite.config.ts +└── tsconfig.json +``` + +## Confidential Assets + +The `public/datas/` folder is **gitignored** and must be provided separately by CIRAD. It contains: + +- `identification-key.json` — English disease tree (primary source) +- `cle-identification.json` — French disease tree +- `clave-de-identificacion.json` — Spanish disease tree +- `diseases-img-webp/` — Disease photographs in WebP format (confidential, CIRAD property, ~350 Mo) + +The loader (`src/data/key-loader.ts`) falls back to the English key when a translated key is empty. + +## CI/CD + +| Check | Tool | +|---|---| +| Type-check & build | GitHub Actions | +| npm audit (critical) | GitHub Actions | +| Code quality | [SonarCloud](https://sonarcloud.io/project/overview?id=ffillouxdev_D-CAS-2.0) | + +## Deployment + +Production server: **Red Hat Enterprise Linux 9**, served by **Nginx**. + +```bash +# Build +npm run build + +# Deploy dist/ to server +rsync -avz dist/ root@croult.cirad.fr:/usr/share/nginx/website_canedr/ + +# Deploy data (keys + images) — first time or when updated +rsync -avz /tmp/datas-deploy/ root@croult.cirad.fr:/usr/share/nginx/website_canedr/datas/ +``` + +### Security headers (Nginx) + +HTTP security headers (CSP, HSTS, nosniff, etc.) are defined in +[`deploy/nginx-security-headers.conf`](deploy/nginx-security-headers.conf). +Copy it to `/etc/nginx/snippets/` on the server and `include` it in the +server block (see instructions in the file), then `nginx -t && systemctl reload nginx`. + +## Credits + +Based on the book *A Guide to Sugarcane Diseases*, edited by Philippe Rott, Jean-Claude Girard and Jean Heinrich Daugrois, published by [Éditions Quæ](https://www.quae.com/produit/78/9782876143869/a-guide-to-sugarcane-diseases). + +## License + +Proprietary — © CIRAD. All rights reserved. Disease content and images are the property of CIRAD and contributing photographers. + +## Author + +Developed by **[ffillouxdev](https://github.com/ffillouxdev)** for CIRAD. diff --git a/deploy/canedr.conf b/deploy/canedr.conf new file mode 100644 index 0000000..f3b5daf --- /dev/null +++ b/deploy/canedr.conf @@ -0,0 +1,53 @@ +# Server block for canedr.cirad.fr (D-CAS 2.0) — deployed copy of +# /etc/nginx/conf.d/canedr.conf on croult. Keep this file in sync with the +# server: after editing, copy it over and run `nginx -t && systemctl reload nginx`. +# +# Includes the security-headers snippet (deploy/nginx-security-headers.conf -> +# /etc/nginx/snippets/canedr-security-headers.conf). NGINX CAVEAT: a location +# that sets its own add_header drops inherited add_header, so every such +# location re-includes the snippet. + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name croult.cirad.fr; + root /usr/share/nginx/website_canedr; + index index.html; + ssl_certificate /etc/pki/tls/certs/croult_cert_bundle.pem; + ssl_certificate_key /etc/pki/tls/private/croult.cirad.fr.key; + + # Security headers (HTML page + everything without its own add_header) + include /etc/nginx/snippets/canedr-security-headers.conf; + + location / { + try_files $uri $uri/ /index.html; + } + + # Hashed build bundles: cache hard. Single Cache-Control header (no more + # duplicate from `expires` + `add_header`). + location /assets/ { + include /etc/nginx/snippets/canedr-security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + # Service worker: must never be cached. + location /sw.js { + include /etc/nginx/snippets/canedr-security-headers.conf; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + } + + # PWA manifest: serve with the correct MIME type (was application/octet-stream). + location = /manifest.webmanifest { + include /etc/nginx/snippets/canedr-security-headers.conf; + default_type application/manifest+json; + add_header Cache-Control "public, max-age=3600" always; + } +} + +# Redirect plain HTTP to HTTPS +server { + listen 80; + listen [::]:80; + server_name croult.cirad.fr; + return 301 https://$host$request_uri; +} diff --git a/deploy/nginx-cache.conf b/deploy/nginx-cache.conf new file mode 100644 index 0000000..5ae0659 --- /dev/null +++ b/deploy/nginx-cache.conf @@ -0,0 +1,54 @@ +# Cache policy for canedr.cirad.fr (D-CAS 2.0) +# +# Usage: copy to the server, e.g. +# /etc/nginx/snippets/canedr-cache.conf +# then include it INSIDE the server block, after the security headers include: +# +# server { +# ... +# include /etc/nginx/snippets/canedr-security-headers.conf; +# include /etc/nginx/snippets/canedr-cache.conf; +# } +# +# After any change: nginx -t && systemctl reload nginx +# Verify: curl -sI https://canedr.cirad.fr/assets/.js | grep -i cache-control +# +# NGINX CAVEAT (see canedr-security-headers.conf): a location that sets +# add_header drops ALL add_header inherited from the server block. Every +# location below therefore RE-INCLUDES the security headers so they are not +# silently lost. + +# 1. Hashed build bundles (index-.js / .css) — content-addressed, so the +# filename changes on every build. Cache hard, forever. +location ~* ^/assets/index-.*\.(?:js|css)$ { + include /etc/nginx/snippets/canedr-security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; +} + +# 2. Other static assets (fonts, images, icons, OG image). Names are stable, +# so use a moderate TTL and revalidate. Bump the asset name if you need an +# instant refresh. +location ~* \.(?:png|jpe?g|webp|gif|svg|ico|otf|ttf|woff2?)$ { + include /etc/nginx/snippets/canedr-security-headers.conf; + add_header Cache-Control "public, max-age=2592000" always; # 30 days +} + +# 3. Confidential disease data (JSON identification keys). Moderate cache. +location /datas/ { + include /etc/nginx/snippets/canedr-security-headers.conf; + add_header Cache-Control "public, max-age=86400" always; # 1 day +} + +# 4. Files that MUST always be fresh so updates propagate immediately: +# - index.html references the latest hashed bundle +# - sw.js is the Service Worker (stale SW = stuck app) +# - /.vite/manifest.json is read by the SW at install to precache bundles +location = /index.html { include /etc/nginx/snippets/canedr-security-headers.conf; add_header Cache-Control "no-cache" always; } +location = /sw.js { include /etc/nginx/snippets/canedr-security-headers.conf; add_header Cache-Control "no-cache" always; } +location = /.vite/manifest.json { include /etc/nginx/snippets/canedr-security-headers.conf; add_header Cache-Control "no-cache" always; } + +# 5. PWA / SEO metadata — short cache. +location ~* ^/(manifest\.webmanifest|robots\.txt|sitemap\.xml|llms\.txt)$ { + include /etc/nginx/snippets/canedr-security-headers.conf; + add_header Cache-Control "public, max-age=3600" always; # 1 hour +} diff --git a/deploy/nginx-security-headers.conf b/deploy/nginx-security-headers.conf new file mode 100644 index 0000000..454295e --- /dev/null +++ b/deploy/nginx-security-headers.conf @@ -0,0 +1,41 @@ +# Security headers for canedr.cirad.fr (D-CAS 2.0) +# +# Usage: copy this file to the server, e.g. +# /etc/nginx/snippets/canedr-security-headers.conf +# then include it inside the server block serving the app: +# +# server { +# ... +# include /etc/nginx/snippets/canedr-security-headers.conf; +# } +# +# NGINX CAVEAT: add_header directives are inherited from the server block +# ONLY if a location block defines no add_header of its own. If a location +# adds headers (e.g. Cache-Control for /assets/), re-include this file +# inside that location too, otherwise these headers are silently dropped. +# +# After any change: nginx -t && systemctl reload nginx +# Verify in the browser devtools (Network tab) or with: +# curl -sI https://canedr.cirad.fr/ | grep -iE 'content-security|x-content|referrer|permissions|strict-transport' + +# Content-Security-Policy — only same-origin resources may load. +# - script-src 'self': blocks any injected inline - Guide to Sugarcane Diseases + + CaneDr — A Guide to Sugarcane Diseases + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
+
diff --git a/package-lock.json b/package-lock.json index a294129..dd92f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,38 @@ "": { "name": "guide-to-sugarcane-diseases", "version": "0.0.0", + "dependencies": { + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1" + }, "devDependencies": { + "autoprefixer": "^10.5.0", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.1", "typescript": "~5.6.2", "vite": "^5.4.10" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -380,6 +407,76 @@ "node": ">=12" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -711,17 +808,249 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "optional": true, - "peer": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, "engines": { "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -760,280 +1089,277 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=6" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { - "detect-libc": "^2.0.3" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" }, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true, + "dependencies": { + "is-glob": "^4.0.1" + }, "engines": { - "node": ">= 12.0.0" + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=8" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, "engines": { - "node": ">= 12.0.0" + "node": "*" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "type": "github", + "url": "https://github.com/sponsors/rawify" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" + "dependencies": { + "is-glob": "^4.0.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" + "dependencies": { + "function-bind": "^1.1.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">= 0.4" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" + "node_modules/i18next": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz", + "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } ], - "peer": true, - "engines": { - "node": ">= 12.0.0" + "dependencies": { + "@babel/runtime": "^7.29.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">= 12.0.0" + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">= 12.0.0" + "node": ">=14" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, "engines": { - "node": ">= 12.0.0" + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" } }, "node_modules/nanoid": { @@ -1054,16 +1380,85 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -1088,6 +1483,206 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -1132,6 +1727,29 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1141,11 +1759,166 @@ "node": ">=0.10.0" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1154,6 +1927,42 @@ "node": ">=14.17" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 3c1ecfa..f026a33 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,21 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview" + "build": "tsc && vite build && node scripts/prerender.mjs", + "preview": "vite preview", + "verify": "node scripts/verify-build.js", + "sonar": "sonar" }, "devDependencies": { + "@sonar/scan": "^4.3.6", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.1", "typescript": "~5.6.2", "vite": "^5.4.10" + }, + "dependencies": { + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/assets/a_guide_to_sugarcane-img.png b/public/assets/a_guide_to_sugarcane-img.png new file mode 100644 index 0000000..ba5a8a1 Binary files /dev/null and b/public/assets/a_guide_to_sugarcane-img.png differ diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico new file mode 100644 index 0000000..37da5b1 Binary files /dev/null and b/public/assets/favicon.ico differ diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf new file mode 100644 index 0000000..95eac52 Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf differ diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf new file mode 100644 index 0000000..75e19fe Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf differ diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf new file mode 100644 index 0000000..0a3346e Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf differ diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf new file mode 100644 index 0000000..0640d7e Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf differ diff --git a/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF new file mode 100644 index 0000000..ebf00fa Binary files /dev/null and b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF differ diff --git a/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF new file mode 100644 index 0000000..e7b7f26 Binary files /dev/null and b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF differ diff --git a/public/assets/icon-192.png b/public/assets/icon-192.png new file mode 100644 index 0000000..25aa157 Binary files /dev/null and b/public/assets/icon-192.png differ diff --git a/public/assets/icon-512.png b/public/assets/icon-512.png new file mode 100644 index 0000000..9970e8a Binary files /dev/null and b/public/assets/icon-512.png differ diff --git a/public/assets/logo-cirad.svg b/public/assets/logo-cirad.svg new file mode 100644 index 0000000..e2e9728 --- /dev/null +++ b/public/assets/logo-cirad.svg @@ -0,0 +1,829 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/public/assets/main-bg.png b/public/assets/main-bg.png new file mode 100644 index 0000000..98cf258 Binary files /dev/null and b/public/assets/main-bg.png differ diff --git a/public/assets/og-image.png b/public/assets/og-image.png new file mode 100644 index 0000000..a83c86a Binary files /dev/null and b/public/assets/og-image.png differ diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..a90c0df --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,24 @@ +# CaneDr — A Guide to Sugarcane Diseases + +> CaneDr is a free, trilingual (English / French / Spanish) interactive web guide, published by CIRAD, for identifying sugarcane diseases and disorders. It offers a guided diagnosis based on observed symptoms and a searchable catalog of more than 80 referenced diseases, illustrated with around 500 colour photographs. The scientific content is based on the book *A Guide to Sugarcane Diseases* (2nd edition, Éditions Quæ), edited by Philippe Rott, Jean-Claude Girard and Jean Heinrich Daugrois. + +The site is a Progressive Web App (PWA) that works fully offline once loaded. It collects no personal data: no cookies, no trackers, no analytics. Disease content and photographs are the property of CIRAD and contributing photographers. + +## Pages + +- [Home — Guided diagnosis](https://canedr.cirad.fr/): Answer a few questions about the symptoms observed on the sugarcane to narrow down possible diseases. +- [Disease catalog](https://canedr.cirad.fr/catalog): Browse, search and sort all 80+ referenced diseases and disorders, with their pathogens, symptoms, photographs and geographical distribution. +- [About](https://canedr.cirad.fr/about): Background on the project, the source book and credits. +- [Privacy policy](https://canedr.cirad.fr/privacy): Data handling — no personal data, no cookies, no trackers. +- [Legal notice](https://canedr.cirad.fr/legal): Publisher, hosting, credits and copyright. + +## About the publisher + +- [CIRAD](https://www.cirad.fr/): French agricultural research and cooperation organisation working for the sustainable development of tropical and Mediterranean regions. +- [A Guide to Sugarcane Diseases (Éditions Quæ)](https://www.quae.com/produit/78/9782876143869/a-guide-to-sugarcane-diseases): The source book on which the scientific content is based. + +## Notes + +- Available in English, French and Spanish. Questionnaire and interface text are translated; disease names remain in English by design. +- Contact for questions or photographs of sugarcane diseases: canedr@cirad.fr +- Disease images are confidential and are not available for indexing or reuse. diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..5f039c1 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,33 @@ +{ + "name": "CaneDr — A Guide to Sugarcane Diseases", + "short_name": "CaneDr", + "description": "Identify sugarcane diseases and disorders with this free interactive guide by CIRAD: guided diagnosis, a searchable catalog of 80+ diseases and 500 photographs.", + "lang": "en", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#15803d", + "icons": [ + { + "src": "/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..469861f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,10 @@ +# https://canedr.cirad.fr — robots policy +# Allow search engines to index the whole site... +User-agent: * +Allow: / + +# ...except the confidential data folder (identification keys + disease images, +# property of CIRAD). +Disallow: /datas/ + +Sitemap: https://canedr.cirad.fr/sitemap.xml diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..44e1609 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,176 @@ +const CACHE_NAME = 'dcas-v9' + +const APP_SHELL = [ + '/', + '/index.html', + '/manifest.webmanifest', + '/assets/favicon.ico', + '/assets/icon-192.png', + '/assets/icon-512.png', + '/datas/identification-key.json', + '/datas/cle-identification.json', + '/datas/clave-de-identificacion.json', +] + +const LANG_FILES = { + en: '/datas/identification-key.json', + fr: '/datas/cle-identification.json', + es: '/datas/clave-de-identificacion.json', +} + +// Reads Vite's build manifest at install time and adds the hashed JS/CSS +// bundles to the precache. Falls back gracefully in dev (no manifest). +async function getViteBundleAssets() { + try { + const res = await fetch('/.vite/manifest.json', { cache: 'no-cache' }) + if (!res.ok) return [] + const manifest = await res.json() + const urls = new Set() + for (const entry of Object.values(manifest)) { + if (entry.file) urls.add('/' + entry.file) + if (Array.isArray(entry.css)) entry.css.forEach((f) => urls.add('/' + f)) + if (Array.isArray(entry.assets)) entry.assets.forEach((f) => urls.add('/' + f)) + } + return [...urls] + } catch { + return [] + } +} + +globalThis.addEventListener('install', (event) => { + event.waitUntil((async () => { + const cache = await caches.open(CACHE_NAME) + const bundles = await getViteBundleAssets() + const all = [...new Set([...APP_SHELL, ...bundles])] + await Promise.all(all.map((url) => cache.add(url).catch(() => {}))) + })()) + globalThis.skipWaiting() +}) + +globalThis.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ) + globalThis.clients.claim() +}) + +function isImageRequest(request) { + const url = new URL(request.url) + return url.pathname.startsWith('/datas/diseases-img') || + (url.pathname.startsWith('/assets/') && /\.(png|jpg|jpeg|webp|svg|gif|ico)$/i.test(url.pathname)) +} + +async function cacheFirst(request) { + const cached = await caches.match(request) + if (cached) return cached + try { + const response = await fetch(request) + if (response.ok) { + const cache = await caches.open(CACHE_NAME) + cache.put(request, response.clone()) + } + return response + } catch { + return new Response('Not found', { status: 404 }) + } +} + +async function networkFirst(request) { + try { + const response = await fetch(request) + if (response.ok) { + const cache = await caches.open(CACHE_NAME) + cache.put(request, response.clone()) + } + return response + } catch { + const cached = await caches.match(request) + if (cached) return cached + if (request.mode === 'navigate') { + const fallback = (await caches.match('/index.html')) || (await caches.match('/')) + if (fallback) return fallback + } + return new Response('Not found', { status: 404 }) + } +} + +globalThis.addEventListener('fetch', (event) => { + const { request } = event + if (request.method !== 'GET') return + if (!request.url.startsWith(globalThis.location.origin)) return + + event.respondWith(isImageRequest(request) ? cacheFirst(request) : networkFirst(request)) +}) + +globalThis.addEventListener('message', (event) => { + if (!event.source) return + if (event.data?.type === 'precache-images') { + event.waitUntil(precacheImagesForLang(event.data.lang || 'en')) + } +}) + +async function fetchLangData(lang) { + const path = LANG_FILES[lang] || LANG_FILES.en + try { + const res = await fetch(path) + if (!res.ok) return null + const data = await res.json() + if (!data?.nodes) return null + return data + } catch { + return null + } +} + +async function broadcast(msg) { + const clients = await globalThis.clients.matchAll({ includeUncontrolled: true }) + clients.forEach((c) => c.postMessage(msg)) +} + +const precachingLangs = new Set() +const PRECACHE_CONCURRENCY = 6 + +async function precacheImagesForLang(lang) { + if (precachingLangs.has(lang)) return + precachingLangs.add(lang) + try { + let data = await fetchLangData(lang) + if (!data && lang !== 'en') data = await fetchLangData('en') + if (!data) return + + const urls = new Set() + const collect = (obj) => { + if (obj?.image && Array.isArray(obj.image)) { + obj.image.forEach((u) => urls.add(u)) + } + } + Object.values(data.diseases || {}).forEach(collect) + Object.values(data.other_causes || {}).forEach(collect) + + const all = [...urls] + const total = all.length + let done = 0 + + await broadcast({ type: 'precache-progress', done, total }) + + const cache = await caches.open(CACHE_NAME) + let index = 0 + async function worker() { + while (index < all.length) { + const url = all[index++] + if (!(await cache.match(url))) { + await cache.add(url).catch(() => {}) + } + done++ + if (done % 10 === 0 || done === total) { + await broadcast({ type: 'precache-progress', done, total }) + } + } + } + await Promise.all(Array.from({ length: PRECACHE_CONCURRENCY }, worker)) + } catch {} finally { + precachingLangs.delete(lang) + } +} diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs new file mode 100644 index 0000000..22ddd20 --- /dev/null +++ b/scripts/prerender.mjs @@ -0,0 +1,133 @@ +// Build-time prerenderer. After `vite build`, this renders each page in each +// language to a static HTML file with the correct metadata and the main +// view markup already in the body — so crawlers (and no-JS clients) get real, +// per-language, indexable HTML. The SPA then hydrates normally on top. +// +// It runs the app's own view functions in Node via Vite's SSR loader (no +// headless browser). A few browser globals are shimmed because header()/i18n +// touch location and localStorage. + +import { createServer } from 'vite' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' + +// --- browser global shims (must exist before any app module loads) --- +globalThis.location = { pathname: '/' } +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + key: () => null, + length: 0, +} + +const DIST = 'dist' + +const escAttr = (s) => + String(s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +const escText = (s) => String(s).replace(/&/g, '&').replace(//g, '>') + +function buildHtml(template, { lang, title, desc, canonical, ogLocale, alternates, appHtml }) { + let html = template + + html = html.replace('', ``) + html = html.replace(/[\s\S]*?<\/title>/, `<title>${escText(title)}`) + + html = html.replace(//, ``) + html = html.replace(/]*\/>/, ``) + + html = html.replace(//, ``) + html = html.replace(//, ``) + html = html.replace(//, ``) + html = html.replace(//, ``) + + html = html.replace(//, ``) + html = html.replace(//, ``) + + for (const { hreflang, href } of alternates) { + html = html.replace( + new RegExp(`]*/>`), + ``, + ) + } + + html = html.replace(/
]*)><\/div>/, (_m, attrs) => `
${appHtml}
`) + + return html +} + +const vite = await createServer({ appType: 'custom', server: { middlewareMode: true }, logLevel: 'warn' }) + +try { + const routes = await vite.ssrLoadModule('/src/routes.ts') + const i18next = (await vite.ssrLoadModule('/src/i18n.ts')).default + const views = { + home: (await vite.ssrLoadModule('/src/views/home.ts')).homeView, + catalogue: (await vite.ssrLoadModule('/src/views/catalog.ts')).catalogueView, + about: (await vite.ssrLoadModule('/src/views/about.ts')).aboutView, + privacy: (await vite.ssrLoadModule('/src/views/privacy.ts')).privacyView, + legal: (await vite.ssrLoadModule('/src/views/legal.ts')).legalView, + } + + const { PAGES, LANGS, SITE_ORIGIN, OG_LOCALES, urlFor } = routes + const template = await readFile(join(DIST, 'index.html'), 'utf8') + + let count = 0 + for (const lang of LANGS) { + for (const page of PAGES) { + const path = urlFor(page, lang) + globalThis.location.pathname = path + await i18next.changeLanguage(lang) + const t = i18next.t.bind(i18next) + + const appHtml = views[page.key]() + const title = `${t(page.titleKey)} — CaneDr` + const desc = t(page.descKey) + const canonical = SITE_ORIGIN + path + const alternates = [ + ...LANGS.map((l) => ({ hreflang: l, href: SITE_ORIGIN + urlFor(page, l) })), + { hreflang: 'x-default', href: SITE_ORIGIN + urlFor(page, 'en') }, + ] + + const html = buildHtml(template, { lang, title, desc, canonical, ogLocale: OG_LOCALES[lang], alternates, appHtml }) + + const outDir = path === '/' ? DIST : join(DIST, path.replace(/\/$/, '')) + await mkdir(outDir, { recursive: true }) + await writeFile(join(outDir, 'index.html'), html, 'utf8') + count++ + console.log(` prerendered ${path}`) + } + } + console.log(`✓ prerendered ${count} pages`) + + // --- sitemap.xml (one per page x language, each with hreflang alternates) --- + const today = new Date().toISOString().slice(0, 10) + const priority = (key) => (key === 'home' ? '1.0' : key === 'catalogue' ? '0.9' : key === 'about' ? '0.6' : '0.3') + const changefreq = (key) => (key === 'home' || key === 'catalogue' ? 'monthly' : 'yearly') + + const entries = [] + for (const page of PAGES) { + const alternates = [ + ...LANGS.map((l) => ({ hreflang: l, href: SITE_ORIGIN + urlFor(page, l) })), + { hreflang: 'x-default', href: SITE_ORIGIN + urlFor(page, 'en') }, + ] + const links = alternates + .map((a) => ` `) + .join('\n') + for (const lang of LANGS) { + entries.push( + ` \n ${SITE_ORIGIN + urlFor(page, lang)}\n ${today}\n ${changefreq(page.key)}\n ${priority(page.key)}\n${links}\n `, + ) + } + } + const sitemap = + `\n` + + `\n` + + entries.join('\n') + + `\n\n` + await writeFile(join(DIST, 'sitemap.xml'), sitemap, 'utf8') + console.log(`✓ wrote sitemap.xml (${entries.length} urls)`) +} finally { + await vite.close() +} diff --git a/scripts/verify-build.js b/scripts/verify-build.js new file mode 100644 index 0000000..da90365 --- /dev/null +++ b/scripts/verify-build.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Post-build smoke test: confirms the dist/ artefacts are coherent. +// Run AFTER `npm run build`. Used in CI (and locally) to catch regressions +// in the PWA/SW pipeline without spinning up a browser. +// +// Checks: +// - expected files exist (index.html, sw.js, .vite/manifest.json, favicon) +// - sw.js parses as valid JavaScript and references the vite manifest +// - hashed JS/CSS bundles in index.html are present in the vite manifest +// (so the SW precache list and the actual page payload agree) + +import { readFileSync, existsSync } from 'node:fs' +import { Script } from 'node:vm' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const DIST = join(__dirname, '../dist') + +let failures = 0 + +function check(label, ok, detail = '') { + const mark = ok ? '✓' : '✗' + const suffix = ok ? '' : `\n ${detail}` + console.log(`${mark} ${label}${suffix}`) + if (!ok) failures++ +} + +function readText(path) { + try { return readFileSync(path, 'utf8') } catch { return null } +} + +// 1. Required artefacts +const required = [ + 'index.html', + 'sw.js', + '.vite/manifest.json', + 'assets/favicon.ico', +] +for (const f of required) { + check(`dist/${f} exists`, existsSync(join(DIST, f)), 'file missing — did the build complete?') +} + +// 2. sw.js sanity +const swSrc = readText(join(DIST, 'sw.js')) +if (swSrc) { + let parseErr = '' + try { new Script(swSrc) } catch (e) { parseErr = e.message } + check('sw.js parses as valid JavaScript', !parseErr, parseErr) + check('sw.js fetches /.vite/manifest.json on install', + swSrc.includes('/.vite/manifest.json'), + 'expected the SW to read the vite manifest to precache hashed bundles') + check('sw.js declares a CACHE_NAME', + /CACHE_NAME\s*=\s*['"][^'"]+['"]/.test(swSrc), + 'CACHE_NAME constant not found in sw.js') +} + +// 3. index.html ↔ vite manifest consistency +const indexHtml = readText(join(DIST, 'index.html')) +const manifestRaw = readText(join(DIST, '.vite/manifest.json')) + +if (indexHtml && manifestRaw) { + let manifest + try { manifest = JSON.parse(manifestRaw) } catch (e) { + check('vite manifest is valid JSON', false, e.message) + } + if (manifest) { + const manifestFiles = new Set() + for (const entry of Object.values(manifest)) { + if (entry.file) manifestFiles.add(entry.file) + if (Array.isArray(entry.css)) entry.css.forEach((f) => manifestFiles.add(f)) + if (Array.isArray(entry.assets)) entry.assets.forEach((f) => manifestFiles.add(f)) + } + check('vite manifest declares at least one bundle', manifestFiles.size > 0) + + const referenced = [...indexHtml.matchAll(/(?:src|href)="\/(assets\/[^"]+\.(?:js|css))"/g)] + .map((m) => m[1]) + check('index.html references at least one hashed bundle', referenced.length > 0, + 'no /assets/*.{js,css} found in index.html') + for (const ref of referenced) { + check(`${ref} is declared in the vite manifest`, + manifestFiles.has(ref), + 'present in index.html but missing from manifest.json — SW would not precache it') + } + } +} + +console.log('') +if (failures) { + console.error(`${failures} check(s) failed`) + process.exit(1) +} +console.log('All checks passed') diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..9058ca7 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,11 @@ +sonar.projectKey=ffillouxdev_D-CAS-2.0 +sonar.organization=ffillouxdev +sonar.projectName=D-CAS 2.0 +sonar.projectVersion=2.0.0 + +sonar.sources=src +sonar.exclusions=dist/**,node_modules/**,public/** +sonar.cpd.exclusions=src/i18n.ts,src/views/about.ts,src/views/privacy.ts,src/views/legal.ts +sonar.coverage.exclusions=**/* +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.qualitygate.wait=false diff --git a/src/components/breadcrumb.ts b/src/components/breadcrumb.ts new file mode 100644 index 0000000..3de7aca --- /dev/null +++ b/src/components/breadcrumb.ts @@ -0,0 +1,26 @@ +import { useT } from '../i18n' + +export interface BreadcrumbItem { + label: string + nodeId: string +} + +export function breadcrumb(history: BreadcrumbItem[]): string { + const t = useT() + const isHomeOnly = history.length === 0 + const home = /*html*/`
${t('nav.home')}` + + const items = history.map((item, i) => { + const isLast = i === history.length - 1 + if (isLast) { + return /*html*/`${item.label}` + } + return /*html*/`${item.label}` + }) + + return /*html*/` + + ` +} diff --git a/src/components/call_to_action.ts b/src/components/call_to_action.ts new file mode 100644 index 0000000..6ee4f98 --- /dev/null +++ b/src/components/call_to_action.ts @@ -0,0 +1,32 @@ +import { useT } from '../i18n' + +export function callToAction(showCiradBadge = false): string { + const t = useT() + + const ciradBadge = showCiradBadge ? /*html*/` + + CIRAD + + ` : '' + + return /*html*/` +
+
+
+

+ ${t('cta.intro')} + "${t('cta.bookTitle')}" + ${t('cta.authors')} +

+ + ${t('cta.buttonMobile')} + + + ${ciradBadge} +
+ ${t('cta.bookTitle')} +
+
+ ` +} diff --git a/src/components/cirad_corner.ts b/src/components/cirad_corner.ts new file mode 100644 index 0000000..6420393 --- /dev/null +++ b/src/components/cirad_corner.ts @@ -0,0 +1,8 @@ +export function ciradCorner(): string { + return /*html*/` + + CIRAD + + ` +} diff --git a/src/components/disease_result.ts b/src/components/disease_result.ts new file mode 100644 index 0000000..644b730 --- /dev/null +++ b/src/components/disease_result.ts @@ -0,0 +1,177 @@ +import i18next, { useT } from '../i18n' +import type { Disease } from '../data/key-loader' + +const IMG_EXT_RE = /\.(jpe?g|png|webp|gif)$/i +const NUM_PREFIX_RE = /^[\d.]+\s+/ +const FIGURE_RE = /Figure\s+\d+\s*/gi + +function formatFilename(path: string): string { + const base = (path.split('/').pop() ?? path).replace(IMG_EXT_RE, '') + const sep = base.lastIndexOf('_') + if (sep === -1) return base + const disease = base.slice(0, sep) + .replace(NUM_PREFIX_RE, '') + .replace(FIGURE_RE, '') + .trim() + const photographer = base.slice(sep + 1).trim().replace(IMG_EXT_RE, '') + if (!disease) return base + return `(© ${photographer})` +} + +export interface DiseaseResultOptions { + // Optional markup rendered above the title (e.g. breadcrumb for home, + // simple `result_DiseaseName` label for catalogue). + topSlot?: string +} + +function carousel(images: string[], diseaseName: string): string { + const t = useT() + const hasImages = images.length > 0 + const hasMultiple = images.length > 1 + const firstSrc = hasImages ? images[0] : '' + + const frame = /*html*/` +
+ ${hasImages + ? /*html*/`${diseaseName} image` + : /*html*/` +
+ CaneDr + ${t('result.noImage')} +
+ ` + } +
+ ` + + // Single image or no images: just the frame, no navigation controls. + if (!hasMultiple) { + return /*html*/` +
+ ${frame} + ${hasImages ? /*html*/`
${formatFilename(images[0])}
` : ''} +
+ ` + } + + // Multiple images: full carousel with arrows. + return /*html*/` +
+
+ + + ${frame} + + +
+ +
+
1 / ${images.length}
+
${images.length > 0 ? formatFilename(images[0]) : ''}
+
+
+ ` +} + +function geoZones(geo: Disease['geo_locations']): string { + const t = useT() + if (!geo || geo.length === 0) return '' + + const details: string[] = [] + for (const entry of geo) { + for (const [continent, countries] of Object.entries(entry)) { + const countries_html = countries.map(c => /*html*/`
  • ${c}
  • `).join('') + details.push(/*html*/` +
    + ${continent} + +
    + `) + } + } + + return /*html*/` +
    +

    ${t('result.geoLocations')} 🌐 :

    +

    ⚠️ — ${t('result.geoWarning')} ${t('result.geoWarningEmail')}

    + ${details.join('')} +
    + ` +} + +export function renderPathogen(text: string): string { + return text.replace(/\*([^*]+)\*/g, '$1') +} + +// Builds the `result_` breadcrumb label shown above the disease view. +// Strips cross-reference parentheticals (e.g. "(see also X; Y)") that bloat +// names like "Basal stem, root and sheath rot(see also ...; ...; ...)", then +// caps to 40 chars with an ellipsis as a safety net. +const RESULT_LABEL_MAX = 40 +function stripParentheticals(str: string): string { + let out = '' + let depth = 0 + for (const ch of str) { + if (ch === '(') { depth++; continue } + if (ch === ')') { depth = Math.max(0, depth - 1); continue } + if (depth === 0) out += ch + } + return out +} +export function formatResultLabel(name: string): string { + let compact = stripParentheticals(name).replace(/\s+/g, ' ').trim() + if (compact.length > RESULT_LABEL_MAX) { + compact = compact.slice(0, RESULT_LABEL_MAX).trimEnd() + '…' + } + return `${i18next.t('result.prefix')}_${compact}` +} + +export function diseaseResult(disease: Disease, opts: DiseaseResultOptions = {}): string { + const top = opts.topSlot ?? '' + + return /*html*/` + ${top} +

    + ${disease.name} +

    + ${disease.pathogen ? /*html*/`

    ${renderPathogen(disease.pathogen)}

    ` : '
    '} + ${carousel(disease.image ?? [], disease.name)} + ${geoZones(disease.geo_locations)} + ` +} + +// Attach carousel navigation behaviour to a root element containing a +// [data-carousel] block rendered by `diseaseResult`. Safe to call when no +// carousel is present — it will no-op. +export function bindCarousel(root: ParentNode, images: string[]): void { + if (images.length <= 1) return + + const container = root.querySelector('[data-carousel]') + if (!container) return + + const img = container.querySelector('[data-carousel-img]') as HTMLImageElement | null + const idxEl = container.querySelector('[data-carousel-index]') + const filenameEl = container.querySelector('[data-carousel-filename]') + const prev = container.querySelector('[data-carousel-prev]') as HTMLButtonElement | null + const next = container.querySelector('[data-carousel-next]') as HTMLButtonElement | null + + let idx = 0 + + function show(i: number): void { + idx = (i + images.length) % images.length + if (img) img.src = images[idx] + if (idxEl) idxEl.textContent = String(idx + 1) + if (filenameEl) filenameEl.textContent = formatFilename(images[idx]) + } + + prev?.addEventListener('click', () => show(idx - 1)) + next?.addEventListener('click', () => show(idx + 1)) +} diff --git a/src/components/header.ts b/src/components/header.ts new file mode 100644 index 0000000..b985670 --- /dev/null +++ b/src/components/header.ts @@ -0,0 +1,243 @@ +import i18next, { useT } from '../i18n' +import { type Lang, type PageKey, pageByKey, resolvePath, urlFor } from '../routes' + +const languages = [ + { code: 'en', flag: '🇬🇧', label: 'english' }, + { code: 'fr', flag: '🇫🇷', label: 'français' }, + { code: 'es', flag: '🇪🇸', label: 'español' }, +] + +// Localized URL of a page in the current language (e.g. /fr/catalogue). +function lp(key: PageKey): string { + return urlFor(pageByKey(key), i18next.language as Lang) +} + +function isActive(key: PageKey): boolean { + return resolvePath(globalThis.location.pathname).page?.key === key +} + +export function header(): string { + const t = useT() + const lang = languages.find(l => l.code === i18next.language) ?? languages[0] + const offlineChecked = isOfflineEnabled() ? 'checked' : '' + + const options = languages.map(({ code, flag, label }) => + `` + ).join('') + + const linkClass = (key: PageKey) => { + const active = isActive(key) + return active + ? 'text-green-700 font-bold' + : 'text-gray-700 hover:text-green-700' + } + + const mobileNavClass = (key: PageKey) => { + const active = isActive(key) + return active + ? 'flex flex-col items-center gap-0.5 text-green-700' + : 'flex flex-col items-center gap-0.5 text-gray-500' + } + + return /*html*/` + +
    +
    + + + + CaneDr + DCAS v2.0.0 + + + + + + + +
    +
    +
    + + + + + + + ` +} + +function isOfflineEnabled(): boolean { + return localStorage.getItem('dcas-offline') !== 'false' +} + +async function registerSW(): Promise { + if (!('serviceWorker' in navigator)) return + await navigator.serviceWorker.register('/sw.js') + await navigator.serviceWorker.ready + requestImagePrecache() +} + +function requestImagePrecache(): void { + const sw = navigator.serviceWorker.controller + if (!sw) return + sw.postMessage({ type: 'precache-images', lang: i18next.language }) +} + +async function unregisterSW(): Promise { + if (!('serviceWorker' in navigator)) return + const registrations = await navigator.serviceWorker.getRegistrations() + for (const reg of registrations) { + await reg.unregister() + } + const keys = await caches.keys() + for (const key of keys) { + await caches.delete(key) + } +} + +export function initOffline(): void { + if (isOfflineEnabled()) { + registerSW() + } + + // Outside-click handler: closes any open dropdown. Registered ONCE for the + // lifetime of the page — re-binding on every router render would leak. + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement + const menuDropdown = document.getElementById('menu-dropdown') + const mobileMenuDropdown = document.getElementById('mobile-menu-dropdown') + if (menuDropdown && !menuDropdown.contains(target)) menuDropdown.classList.add('hidden') + if (mobileMenuDropdown && !mobileMenuDropdown.contains(target)) mobileMenuDropdown.classList.add('hidden') + }) + + // SW precache progress messages: registered ONCE on a persistent target + // (navigator.serviceWorker). Re-binding on every render would leak handlers. + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type !== 'precache-progress') return + const { done, total } = event.data + const ready = done >= total + const pct = total > 0 ? Math.round((done / total) * 100) : 0 + const text = ready + ? i18next.t('nav.offlineReady') + : `${i18next.t('nav.offlineDownloading')} ${done}/${total}` + document.querySelectorAll('.offline-progress').forEach((el) => { + el.classList.remove('hidden') + const textEl = el.querySelector('.offline-progress-text') + const barEl = el.querySelector('.offline-progress-bar') + if (textEl) textEl.textContent = text + if (barEl) barEl.style.width = `${ready ? 100 : pct}%` + if (ready) setTimeout(() => el.classList.add('hidden'), 4000) + }) + }) + } +} + +function bindOfflineToggle(id: string): void { + const toggle = document.getElementById(id) as HTMLInputElement | null + if (!toggle) return + toggle.checked = isOfflineEnabled() + toggle.addEventListener('change', () => { + const enabled = toggle.checked + localStorage.setItem('dcas-offline', enabled ? 'true' : 'false') + // Sync the other toggle + const otherId = id === 'offline-toggle' ? 'mobile-offline-toggle' : 'offline-toggle' + const other = document.getElementById(otherId) as HTMLInputElement | null + if (other) other.checked = enabled + if (enabled) { registerSW() } else { unregisterSW() } + }) +} + +export function bindHeaderEvents(navigate: (path: string) => void): void { + const select = document.getElementById('lang-select') as HTMLSelectElement | null + select?.addEventListener('change', () => { + const lang = select.value as Lang + // Navigate to the same page in the chosen language; the router picks up the + // language from the new URL and re-renders. + const current = resolvePath(globalThis.location.pathname).page ?? pageByKey('home') + navigate(urlFor(current, lang)) + requestImagePrecache() + }) + + // Desktop dropdown + const menuBtn = document.getElementById('menu-btn') + const menuDropdown = document.getElementById('menu-dropdown') + menuBtn?.addEventListener('click', (e) => { + e.stopPropagation() + menuDropdown?.classList.toggle('hidden') + }) + + // Mobile dropdown + const mobileMenuBtn = document.getElementById('mobile-menu-btn') + const mobileMenuDropdown = document.getElementById('mobile-menu-dropdown') + mobileMenuBtn?.addEventListener('click', (e) => { + e.stopPropagation() + mobileMenuDropdown?.classList.toggle('hidden') + }) + + // Offline toggles + bindOfflineToggle('offline-toggle') + bindOfflineToggle('mobile-offline-toggle') +} diff --git a/src/components/question_button.ts b/src/components/question_button.ts new file mode 100644 index 0000000..ec61d60 --- /dev/null +++ b/src/components/question_button.ts @@ -0,0 +1,7 @@ +export function questionButton(label: string, next: string): string { + return /*html*/` + + ` +} diff --git a/src/components/questionnaire.ts b/src/components/questionnaire.ts new file mode 100644 index 0000000..f61b535 --- /dev/null +++ b/src/components/questionnaire.ts @@ -0,0 +1,181 @@ +import { useT } from '../i18n' +import { loadKey, type IdentificationKey } from '../data/key-loader' +import { questionButton } from './question_button' +import { breadcrumb, type BreadcrumbItem } from './breadcrumb' +import { diseaseResult, bindCarousel, formatResultLabel } from './disease_result' + +interface State { + key: IdentificationKey | null + currentNodeId: string + history: BreadcrumbItem[] + listenerAttached: boolean +} + +const state: State = { + key: null, + currentNodeId: 'root', + history: [], + listenerAttached: false, +} + +function backButton(): string { + if (state.history.length === 0) return '' + const t = useT() + return /*html*/` + + ` +} + +function goBack(): void { + if (state.history.length <= 1) { + navigateToBreadcrumb('root') + } else { + const prev = state.history[state.history.length - 2] + navigateToBreadcrumb(prev.nodeId) + } +} + +function renderQuestion(): string { + if (!state.key) return '' + + const node = state.key.nodes[state.currentNodeId] + if (!node) return '' + + const buttons = node.options.map(opt => questionButton(opt.label, opt.next)).join('') + + return /*html*/` + ${backButton()} + ${breadcrumb(state.history)} +

    + ${node.text} +

    +
    + ${buttons} +
    + ` +} + +function lookupDisease(id: string) { + if (!state.key) return undefined + return state.key.diseases[id] ?? state.key.other_causes?.[id] +} + +function renderDisease(): string { + const disease = lookupDisease(state.currentNodeId) + if (!disease) return '' + + return diseaseResult(disease, { topSlot: backButton() + breadcrumb(state.history) }) +} + +function update(): void { + const container = document.getElementById('questionnaire') + if (!container) return + + const isDisease = state.currentNodeId.startsWith('D_') + const html = isDisease ? renderDisease() : renderQuestion() + + // Create temporary container and parse HTML to preserve event listeners on container + const temp = document.createElement('div') + temp.innerHTML = html + + // Replace container's children while keeping the container itself intact + container.replaceChildren(...temp.childNodes) + + if (isDisease) { + const disease = lookupDisease(state.currentNodeId) + if (disease) bindCarousel(container, disease.image ?? []) + } +} + +function navigateTo(nodeId: string, label: string): void { + state.currentNodeId = nodeId + + // For diseases, add result item to breadcrumb only + if (nodeId.startsWith('D_') && state.key) { + const diseaseName = lookupDisease(nodeId)?.name || label + const resultLabel = formatResultLabel(diseaseName) + state.history.push({ label: resultLabel, nodeId }) + } else { + state.history.push({ label, nodeId }) + } + + update() +} + +function truncateBreadcrumbLabel(label: string, maxLength: number = 40): string { + // Find first punctuation mark (period, comma, semicolon) + const punctIndex = label.search(/[.,;]/) + if (punctIndex > 0 && punctIndex < maxLength) { + return label.substring(0, punctIndex) + } + // Otherwise truncate at maxLength + if (label.length > maxLength) { + return label.substring(0, maxLength) + } + return label +} + +function navigateToBreadcrumb(nodeId: string): void { + if (nodeId === 'root') { + state.history = [] + state.currentNodeId = 'root' + } else { + const idx = state.history.findIndex(item => item.nodeId === nodeId) + if (idx >= 0) { + state.history = state.history.slice(0, idx + 1) + state.currentNodeId = nodeId + } + } + update() +} + +function handleClick(e: MouseEvent): void { + // Handle back button click + if ((e.target as HTMLElement).closest('[data-go-back]')) { + e.preventDefault() + e.stopPropagation() + goBack() + return + } + + // Handle question button click + const btn = (e.target as HTMLElement).closest('.question-btn') as HTMLElement | null + if (btn) { + const next = btn.dataset.next + let label = btn.textContent?.trim() ?? '' + // Truncate label for breadcrumb (only for questions, not diseases) + if (next && !next.startsWith('D_')) { + label = truncateBreadcrumbLabel(label) + } + if (next) navigateTo(next, label) + return + } + + // Handle breadcrumb click — stop propagation so the router doesn't intercept + const crumb = (e.target as HTMLElement).closest('[data-breadcrumb]') as HTMLElement | null + if (crumb) { + e.preventDefault() + e.stopPropagation() + const nodeId = crumb.dataset.breadcrumb + if (nodeId) navigateToBreadcrumb(nodeId) + } +} + +export async function initQuestionnaire(): Promise { + // Reset state on each home render + state.currentNodeId = 'root' + state.history = [] + state.listenerAttached = false + + state.key = await loadKey() + update() + + const container = document.getElementById('questionnaire') + if (!container || state.listenerAttached) return + + container.addEventListener('click', handleClick) + state.listenerAttached = true +} diff --git a/src/data/key-loader.ts b/src/data/key-loader.ts new file mode 100644 index 0000000..f389621 --- /dev/null +++ b/src/data/key-loader.ts @@ -0,0 +1,60 @@ +import i18next from '../i18n' + +export interface KeyNode { + type: string + text: string + options: Array<{ label: string; next: string }> +} + +export interface Disease { + name: string + image: string[] + pathogen?: string + geo_locations?: Array> +} + +export interface IdentificationKey { + nodes: Record + diseases: Record + other_causes?: Record +} + +const fileMap: Record = { + en: '/datas/identification-key.json', + fr: '/datas/cle-identification.json', + es: '/datas/clave-de-identificacion.json', +} + +let cache: IdentificationKey | null = null +let cachedLang: string | null = null + +async function fetchKey(path: string): Promise { + try { + const res = await fetch(path) + if (!res.ok) return null + const data = await res.json() + if (!data?.nodes) return null + return data as IdentificationKey + } catch { + return null + } +} + +export async function loadKey(): Promise { + const lang = i18next.language + if (cache && cachedLang === lang) return cache + + const path = fileMap[lang] ?? fileMap.en + let data = await fetchKey(path) + + // Fallback to English if the language file is missing or empty + if (!data && lang !== 'en') { + data = await fetchKey(fileMap.en) + } + + if (!data) throw new Error('Failed to load identification key') + + cache = data + cachedLang = lang + return cache +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..3554b88 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,352 @@ +import i18next from 'i18next' +import { langFromPath } from './routes' + +const resources = { + en: { + translation: { + nav: { + home: 'Home', + catalogue: 'Catalog', + more: 'More', + about: 'About', + offline: 'Available offline', + offlineDownloading: 'Downloading', + offlineReady: 'Ready for offline use ✓', + privacy: 'Privacy', + legal: 'Legal notice', + }, + seo: { + descHome: 'Identify sugarcane diseases and disorders with this free interactive guide by CIRAD: guided diagnosis, a searchable catalog of 80+ diseases and 500 photographs.', + descCatalogue: 'Browse the complete catalog of 80+ referenced sugarcane diseases and disorders, with their pathogens, symptoms, photographs and geographical distribution.', + descAbout: 'About CaneDr, the interactive guide to sugarcane diseases by CIRAD, based on the book "A Guide to Sugarcane Diseases" (Éditions Quæ).', + descPrivacy: 'Privacy policy for CaneDr: no personal data, no cookies, no trackers, no analytics.', + descLegal: 'Legal notice for CaneDr, the guide to sugarcane diseases published by CIRAD.', + titleHome: 'Sugarcane Disease Identification Guide', + }, + about: { + title: 'About', + introHeading: 'About this website', + introBody: 'This website on identification of sugarcane diseases is an interactive and easy-to-use tool containing an identification system for sugarcane diseases and disorders distributed worldwide. It is based on the book "A guide to sugarcane diseases" (second edition) published by QUAE (Philippe Rott, Jean-Claude Girard, and Jean Heinrich Daugrois, scientific editors). This scientific knowledge base contains the description of more than 80 diseases and disorders of sugarcane including 500 colour photographs.', + creditsHeading: 'Credits', + creditsDevelopment: 'Developed by FILLOUX Florian under the direction of MAHÉ Frédéric and ROTT Philippe.', + creditsPhotographers: 'We wish to thank all the persons who contributed photos to the website.', + creditsCiradIntro: 'Published by', + creditsCiradName: 'CIRAD', + creditsCiradDescription: 'French agricultural research and cooperation organisation working for the sustainable development of tropical and Mediterranean regions.', + creditsCiradLink: 'Visit cirad.fr', + }, + home: { + title: 'Where do you observe the symptoms on the sugarcane?', + subtitle: 'A Guide to Sugarcane Diseases', + hint: 'Answer a few questions about the observed symptoms\nto identify the disease.', + cta: 'Start diagnosis', + }, + catalogue: { + title: 'Catalog', + subtitle: 'Complete list of referenced diseases', + empty: 'The catalog will be available once cle.json is loaded.', + searchPlaceholder: 'Search for a disease by name...', + diseaseNameColumn: 'Common name', + pathogenColumn: 'Pathogen name (Syn. = Synonymy*)', + pathogenColumnShort: 'Pathogen name', + pathogenSynonym: '(Syn. = Synonymy*)', + linkLabel: 'see more +', + closeLabel: 'Close', + backToList: '← Back to the list', + sectionDiseases: 'Diseases', + sectionOtherCauses: 'Other causes', + }, + result: { + noImage: 'No image available
    (please contact the webmaster at canedr@cirad.fr if you have a photograph for this disease or disorder)', + prevImage: 'Previous image', + nextImage: 'Next image', + geoLocations: 'Geographical locations', + geoWarning: 'If you observe these symptoms in a location not mentioned below, please contact us!', + geoWarningEmail: 'canedr@cirad.fr', + prefix: 'Result', + }, + cta: { + intro: 'For additionnal information, please see the book', + bookTitle: 'A guide to sugarcane diseases', + authors: 'edited by Philippe ROTT, Jean-Claude GIRARD and Jean Heinrich DAUGROIS', + buttonMobile: 'Web book →', + buttonDesktop: 'The free web book →', + }, + contact: { + heading: 'Contact us', + body: 'For any questions, information or photographs of sugarcane diseases, please contact us at', + }, + privacy: { + title: 'Privacy Policy', + dataHeading: 'Data collected', + dataBody: 'The application itself collects no personal data: no account, no form, no advertising cookie, no tracker, no analytics. A technical preference (your chosen language) is stored locally in your browser and never leaves your device.', + offlineHeading: 'Offline use', + offlineBody: 'The application may be cached by your browser (PWA) so it can work offline. This data remains exclusively on your device and can be removed by clearing your browser cache.', + logsHeading: 'Connection logs', + logsBody: 'Like any website, the server hosting CaneDr may record technical connection logs, including the IP address, the date and time of the request and the browser type. This data is processed for IT security and to ensure the proper operation of the service (legal basis: legitimate interest) and is kept for a limited period.', + controllerHeading: 'Data controller', + controllerBody: 'CIRAD — Centre de coopération internationale en recherche agronomique pour le développement, a French public establishment of an industrial and commercial nature (EPIC), 42 rue Scheffer, 75116 Paris, France. Phone: +33 1 53 70 20 00.', + dpoHeading: 'Data Protection Officer (DPO)', + dpoBody: 'For any question regarding your personal data, you may contact CIRAD\'s Data Protection Officer:
    Email: dpo@cirad.fr
    Address: Délégué à la protection des données (DPO) – Direction régionale Montpellier Occitanie – Avenue Agropolis – TA 178/04 – 34398 Montpellier Cedex 5, France.', + rightsHeading: 'Your rights', + rightsBody: 'In accordance with the General Data Protection Regulation (GDPR) and the French Data Protection Act, you have the right to access, rectify, erase and port your data, as well as the right to restriction and to object on legitimate grounds. You may exercise these rights with the DPO (contact details above). You also have the right to lodge a complaint with the CNIL, the French data protection authority (www.cnil.fr).', + }, + legal: { + title: 'Legal Notice', + publisherHeading: 'Publisher', + publisherBody: 'CaneDr is published by CIRAD — Centre de coopération internationale en recherche agronomique pour le développement, a French public establishment of an industrial and commercial nature (EPIC).
    Registered office: 42 rue Scheffer, 75116 Paris, France.
    RCS Paris 331 596 270.
    Phone: +33 1 53 70 20 00.
    Email: www@cirad.fr', + directorHeading: 'Director of publication', + directorBody: 'Anthony Farisano, acting Chairman and CEO of CIRAD.', + hostingHeading: 'Hosting', + hostingBody: 'This website is hosted by CIRAD on its own servers.
    CIRAD — 42 rue Scheffer, 75116 Paris, France.
    Phone: +33 1 53 70 20 00.', + creditsHeading: 'Credits', + creditsBody: 'Based on the book "A Guide to Sugarcane Diseases". Application developed for CIRAD.', + copyrightHeading: 'Copyright', + copyrightBody: '© CIRAD. All rights reserved. Disease content and images are the property of CIRAD and contributing photographers. Any reproduction, redistribution or commercial use without prior written authorization is prohibited.', + }, + questionnaire: { + prevQuestion: '← Previous question', + }, + notFound: 'Page not found', + backHome: 'Back to home', + }, + }, + fr: { + translation: { + nav: { + home: 'Accueil', + catalogue: 'Catalogue', + more: 'Plus', + about: 'À propos', + offline: 'Accessible hors connexion', + offlineDownloading: 'Téléchargement', + offlineReady: 'Prêt hors connexion ✓', + privacy: 'Confidentialité', + legal: 'Mentions légales', + }, + seo: { + descHome: 'Identifiez les maladies et désordres de la canne à sucre avec ce guide interactif gratuit du CIRAD : diagnostic guidé, catalogue de plus de 80 maladies et 500 photographies.', + descCatalogue: 'Parcourez le catalogue complet de plus de 80 maladies et désordres référencés de la canne à sucre : agents pathogènes, symptômes, photographies et répartition géographique.', + descAbout: "À propos de CaneDr, le guide interactif des maladies de la canne à sucre du CIRAD, basé sur l'ouvrage « A Guide to Sugarcane Diseases » (Éditions Quæ).", + descPrivacy: 'Politique de confidentialité de CaneDr : aucune donnée personnelle, aucun cookie, aucun traceur, aucune mesure d\'audience.', + descLegal: 'Mentions légales de CaneDr, le guide des maladies de la canne à sucre publié par le CIRAD.', + titleHome: 'Guide d\'identification des maladies de la canne à sucre', + }, + about: { + title: 'À propos', + introHeading: 'À propos de ce guide', + introBody: "Ce guide web sur les maladies de la canne à sucre est un outil interactif et facile à utiliser, contenant un système d'identification de maladies et désordres affectant la canne à sucre dans le monde entier. Il est basé sur l'ouvrage « A guide to sugarcane diseases » (seconde édition) publié par QUAE (Philippe Rott, Jean-Claude Girard et Jean Heinrich Daugrois, éditeurs scientifiques). Cette base de connaissances scientifique contient la description de plus de 80 maladies et désordres de la canne à sucre, ainsi que 500 photographies couleur.", + creditsHeading: 'Crédits', + creditsDevelopment: 'Développé par FILLOUX Florian sous la direction de MAHÉ Frédéric et de ROTT Philippe.', + creditsPhotographers: 'Nous tenons à remercier toutes les personnes qui ont contribué des photos au guide web.', + creditsCiradIntro: 'Édité par', + creditsCiradName: 'CIRAD', + creditsCiradDescription: 'Centre de coopération internationale en recherche agronomique pour le développement, œuvrant pour le développement durable des régions tropicales et méditerranéennes.', + creditsCiradLink: 'Visiter cirad.fr', + }, + home: { + title: 'Où observez-vous les symptômes sur la canne à sucre ?', + subtitle: 'Guide des maladies de la canne à sucre', + hint: 'Répondez à quelques questions sur les symptômes observés\npour identifier la maladie.', + cta: 'Commencer le diagnostic', + }, + catalogue: { + title: 'Catalogue', + subtitle: 'Liste complète des maladies référencées', + empty: 'Le catalogue sera disponible une fois le fichier cle.json chargé.', + searchPlaceholder: 'Rechercher une maladie par nom...', + diseaseNameColumn: 'Nom commun', + pathogenColumn: 'Nom agent pathogène (Syn. = Synonymy*)', + pathogenColumnShort: 'Nom agent pathogène', + pathogenSynonym: '(Syn. = Synonymie*)', + linkLabel: 'voir plus +', + closeLabel: 'Fermer', + backToList: '← Retour à la liste', + sectionDiseases: 'Maladies', + sectionOtherCauses: 'Autres causes', + }, + result: { + noImage: 'Aucune image disponible
    (veuillez contacter le webmaster à canedr@cirad.fr si vous avez une photographie pour cette maladie ou ce désordre)', + prevImage: 'Image précédente', + nextImage: 'Image suivante', + geoLocations: 'Localisations géographiques', + geoWarning: "Si vous observez ces symptômes dans un lieu non mentionné ci-dessous, contactez-nous !", + geoWarningEmail: 'canedr@cirad.fr', + prefix: 'Résultat', + }, + cta: { + intro: 'Pour plus d\'informations, consultez l\'ouvrage', + bookTitle: 'A guide to sugarcane diseases', + authors: 'édité par Philippe ROTT, Jean-Claude GIRARD et Jean Heinrich DAUGROIS', + buttonMobile: 'Web book →', + buttonDesktop: 'Consulter l\'ouvrage gratuit →', + }, + contact: { + heading: 'Contactez-nous', + body: 'Pour toute question, information ou photographie de maladies de la canne à sucre, veuillez nous contacter à', + }, + privacy: { + title: 'Politique de confidentialité', + dataHeading: 'Données collectées', + dataBody: "L'application elle-même ne collecte aucune donnée personnelle : pas de compte, pas de formulaire, pas de cookie publicitaire, pas de traceur, aucune mesure d'audience. Une préférence technique (la langue choisie) est enregistrée localement dans votre navigateur et ne quitte jamais votre appareil.", + offlineHeading: 'Fonctionnement hors connexion', + offlineBody: "L'application peut être mise en cache par votre navigateur (PWA) afin de fonctionner hors connexion. Ces données restent exclusivement sur votre appareil et peuvent être effacées en vidant le cache du navigateur.", + logsHeading: 'Journaux de connexion', + logsBody: "Comme tout site web, le serveur qui héberge CaneDr peut enregistrer des journaux techniques de connexion contenant notamment l'adresse IP, la date et l'heure de la requête et le type de navigateur. Ces données sont traitées à des fins de sécurité informatique et de bon fonctionnement du service (base légale : intérêt légitime) et conservées pour une durée limitée.", + controllerHeading: 'Responsable du traitement', + controllerBody: 'CIRAD — Centre de coopération internationale en recherche agronomique pour le développement, établissement public à caractère industriel et commercial (EPIC), 42 rue Scheffer, 75116 Paris, France. Téléphone : +33 1 53 70 20 00.', + dpoHeading: 'Délégué à la protection des données (DPO)', + dpoBody: 'Pour toute question relative à vos données personnelles, vous pouvez contacter le Délégué à la protection des données du CIRAD :
    Courriel : dpo@cirad.fr
    Adresse : Délégué à la protection des données (DPO) – Direction régionale Montpellier Occitanie – Avenue Agropolis – TA 178/04 – 34398 Montpellier Cedex 5, France.', + rightsHeading: 'Vos droits', + rightsBody: "Conformément au Règlement général sur la protection des données (RGPD) et à la loi « Informatique et Libertés », vous disposez de droits d'accès, de rectification, d'effacement et de portabilité de vos données, ainsi que d'un droit à la limitation et d'opposition pour motifs légitimes. Vous pouvez exercer ces droits auprès du DPO (coordonnées ci-dessus). Vous disposez également du droit d'introduire une réclamation auprès de la CNIL (www.cnil.fr).", + }, + legal: { + title: 'Mentions légales', + publisherHeading: 'Éditeur', + publisherBody: 'CaneDr est édité par le CIRAD — Centre de coopération internationale en recherche agronomique pour le développement, établissement public à caractère industriel et commercial (EPIC).
    Siège social : 42 rue Scheffer, 75116 Paris, France.
    RCS Paris 331 596 270.
    Téléphone : +33 1 53 70 20 00.
    Courriel : www@cirad.fr', + directorHeading: 'Directeur de la publication', + directorBody: 'Anthony Farisano, président-directeur général du CIRAD par intérim.', + hostingHeading: 'Hébergement', + hostingBody: 'Ce site est hébergé par le CIRAD sur ses propres serveurs.
    CIRAD — 42 rue Scheffer, 75116 Paris, France.
    Téléphone : +33 1 53 70 20 00.', + creditsHeading: 'Crédits', + creditsBody: "Basé sur l'ouvrage « A Guide to Sugarcane Diseases ». Application développée pour le CIRAD.", + copyrightHeading: 'Droits d\'auteur', + copyrightBody: '© CIRAD. Tous droits réservés. Les contenus et images des maladies sont la propriété du CIRAD et des photographes contributeurs. Toute reproduction, redistribution ou utilisation commerciale sans autorisation écrite préalable est interdite.', + }, + questionnaire: { + prevQuestion: '← Question précédente', + }, + notFound: 'Page introuvable', + backHome: "Retour à l'accueil", + }, + }, + es: { + translation: { + nav: { + home: 'Inicio', + catalogue: 'Catálogo', + more: 'Más', + about: 'Acerca de', + offline: 'Disponible sin conexión', + offlineDownloading: 'Descargando', + offlineReady: 'Listo sin conexión ✓', + privacy: 'Privacidad', + legal: 'Aviso legal', + }, + seo: { + descHome: 'Identifique las enfermedades y trastornos de la caña de azúcar con esta guía interactiva gratuita del CIRAD: diagnóstico guiado, catálogo de más de 80 enfermedades y 500 fotografías.', + descCatalogue: 'Consulte el catálogo completo de más de 80 enfermedades y trastornos referenciados de la caña de azúcar: patógenos, síntomas, fotografías y distribución geográfica.', + descAbout: 'Acerca de CaneDr, la guía interactiva de enfermedades de la caña de azúcar del CIRAD, basada en el libro «A Guide to Sugarcane Diseases» (Éditions Quæ).', + descPrivacy: 'Política de privacidad de CaneDr: sin datos personales, sin cookies, sin rastreadores, sin analíticas.', + descLegal: 'Aviso legal de CaneDr, la guía de enfermedades de la caña de azúcar publicada por el CIRAD.', + titleHome: 'Guía de identificación de enfermedades de la caña de azúcar', + }, + about: { + title: 'Acerca de', + introHeading: 'Acerca de este sitio web', + introBody: 'Este sitio web sobre identificación de enfermedades de la caña de azúcar es una herramienta interactiva y fácil de usar, que contiene un sistema de identificación de enfermedades y desórdenes que afectan a la caña de azúcar en todo el mundo. Está basado en el libro «A guide to sugarcane diseases» (segunda edición) publicado por QUAE (Philippe Rott, Jean-Claude Girard y Jean Heinrich Daugrois, editores científicos). Esta base de conocimientos científicos contiene la descripción de más de 80 enfermedades y desórdenes de la caña de azúcar, junto con 500 fotografías en color.', + creditsHeading: 'Créditos', + creditsDevelopment: 'Desarrollado por FILLOUX Florian bajo la dirección de MAHÉ Frédéric y ROTT Philippe.', + creditsPhotographers: 'Agradecemos a todas las personas que contribuyeron con fotos al sitio web.', + creditsCiradIntro: 'Editado por', + creditsCiradName: 'CIRAD', + creditsCiradDescription: 'Centro de cooperación internacional en investigación agronómica para el desarrollo, dedicado al desarrollo sostenible de las regiones tropicales y mediterráneas.', + creditsCiradLink: 'Visitar cirad.fr', + }, + home: { + title: '¿Dónde observa los síntomas en la caña de azúcar?', + subtitle: 'Guía de enfermedades de la caña de azúcar', + hint: 'Responda algunas preguntas sobre los síntomas observados\npara identificar la enfermedad.', + cta: 'Iniciar diagnóstico', + }, + catalogue: { + title: 'Catálogo', + subtitle: 'Lista completa de enfermedades referenciadas', + empty: 'El catálogo estará disponible una vez cargado el archivo cle.json.', + searchPlaceholder: 'Buscar una enfermedad por nombre...', + diseaseNameColumn: 'Nombre común', + pathogenColumn: 'Nombre agente patógeno (Syn. = Synonymy*)', + pathogenColumnShort: 'Nombre agente patógeno', + pathogenSynonym: '(Sin. = Sinonimia*)', + linkLabel: 'ver más +', + closeLabel: 'Cerrar', + backToList: '← Volver a la lista', + sectionDiseases: 'Enfermedades', + sectionOtherCauses: 'Otras causas', + }, + result: { + noImage: 'Sin imagen disponible
    (por favor, contacte al webmaster en canedr@cirad.fr si dispone de una fotografía para esta enfermedad o desorden)', + prevImage: 'Imagen anterior', + nextImage: 'Imagen siguiente', + geoLocations: 'Ubicaciones geográficas', + geoWarning: 'Si observa estos síntomas en una ubicación no mencionada a continuación, ¡contáctenos!', + geoWarningEmail: 'canedr@cirad.fr', + prefix: 'Resultado', + }, + cta: { + intro: 'Para más información, consulte el libro', + bookTitle: 'A guide to sugarcane diseases', + authors: 'editado por Philippe ROTT, Jean-Claude GIRARD y Jean Heinrich DAUGROIS', + buttonMobile: 'Web book →', + buttonDesktop: 'Consultar el libro gratuito →', + }, + contact: { + heading: 'Contáctenos', + body: 'Para cualquier pregunta, información o fotografía de enfermedades de la caña de azúcar, contáctenos en', + }, + privacy: { + title: 'Política de privacidad', + dataHeading: 'Datos recopilados', + dataBody: 'La aplicación en sí no recopila ningún dato personal: sin cuenta, sin formulario, sin cookie publicitaria, sin rastreador, sin medición de audiencia. Una preferencia técnica (el idioma elegido) se almacena localmente en su navegador y nunca sale de su dispositivo.', + offlineHeading: 'Uso sin conexión', + offlineBody: 'La aplicación puede almacenarse en caché en su navegador (PWA) para funcionar sin conexión. Estos datos permanecen exclusivamente en su dispositivo y pueden eliminarse vaciando la caché del navegador.', + logsHeading: 'Registros de conexión', + logsBody: 'Como cualquier sitio web, el servidor que aloja CaneDr puede registrar registros técnicos de conexión que incluyen la dirección IP, la fecha y hora de la solicitud y el tipo de navegador. Estos datos se tratan con fines de seguridad informática y para garantizar el correcto funcionamiento del servicio (base jurídica: interés legítimo) y se conservan durante un periodo limitado.', + controllerHeading: 'Responsable del tratamiento', + controllerBody: 'CIRAD — Centro de cooperación internacional en investigación agronómica para el desarrollo, establecimiento público de carácter industrial y comercial (EPIC), 42 rue Scheffer, 75116 París, Francia. Teléfono: +33 1 53 70 20 00.', + dpoHeading: 'Delegado de protección de datos (DPO)', + dpoBody: 'Para cualquier pregunta relativa a sus datos personales, puede ponerse en contacto con el Delegado de protección de datos del CIRAD:
    Correo electrónico: dpo@cirad.fr
    Dirección: Délégué à la protection des données (DPO) – Direction régionale Montpellier Occitanie – Avenue Agropolis – TA 178/04 – 34398 Montpellier Cedex 5, Francia.', + rightsHeading: 'Sus derechos', + rightsBody: 'De conformidad con el Reglamento General de Protección de Datos (RGPD) y la ley francesa de protección de datos, usted dispone de los derechos de acceso, rectificación, supresión y portabilidad de sus datos, así como del derecho de limitación y de oposición por motivos legítimos. Puede ejercer estos derechos ante el DPO (datos de contacto más arriba). También dispone del derecho a presentar una reclamación ante la CNIL, la autoridad francesa de protección de datos (www.cnil.fr).', + }, + legal: { + title: 'Aviso legal', + publisherHeading: 'Editor', + publisherBody: 'CaneDr está editado por el CIRAD — Centro de cooperación internacional en investigación agronómica para el desarrollo, establecimiento público de carácter industrial y comercial (EPIC).
    Domicilio social: 42 rue Scheffer, 75116 París, Francia.
    RCS París 331 596 270.
    Teléfono: +33 1 53 70 20 00.
    Correo electrónico: www@cirad.fr', + directorHeading: 'Director de la publicación', + directorBody: 'Anthony Farisano, presidente-director general del CIRAD en funciones.', + hostingHeading: 'Alojamiento', + hostingBody: 'Este sitio está alojado por el CIRAD en sus propios servidores.
    CIRAD — 42 rue Scheffer, 75116 París, Francia.
    Teléfono: +33 1 53 70 20 00.', + creditsHeading: 'Créditos', + creditsBody: 'Basado en el libro "A Guide to Sugarcane Diseases". Aplicación desarrollada para CIRAD.', + copyrightHeading: 'Derechos de autor', + copyrightBody: '© CIRAD. Todos los derechos reservados. Los contenidos e imágenes de las enfermedades son propiedad del CIRAD y de los fotógrafos contribuyentes. Se prohíbe toda reproducción, redistribución o uso comercial sin autorización escrita previa.', + }, + questionnaire: { + prevQuestion: '← Pregunta anterior', + }, + notFound: 'Página no encontrada', + backHome: 'Volver al inicio', + }, + }, +} + +// Language comes from the URL prefix (/fr/, /es/, else English). On the server +// (prerendering) there is no location, so default to English — the prerenderer +// switches the language explicitly per page. +const initialLng = + typeof location !== 'undefined' ? langFromPath(location.pathname) : 'en' + +i18next.init({ + resources, + lng: initialLng, + fallbackLng: 'en', + supportedLngs: ['en', 'fr', 'es'], + interpolation: { escapeValue: false }, +}) + +export default i18next + +export function useT() { + return i18next.t.bind(i18next) +} diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..22a4a9f --- /dev/null +++ b/src/layout.ts @@ -0,0 +1 @@ +export { header, bindHeaderEvents, initOffline } from './components/header' diff --git a/src/main.ts b/src/main.ts index 10be2f3..2eb75b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,7 @@ import './style.css' +import { initRouter } from './router' +import { initOffline } from './layout' -document.querySelector('#app')!.innerHTML = /*html*/ ` -
    -

    - Guide to Sugarcane Diseases -

    -
    -` - +const app = document.querySelector('#app')! +initRouter(app) +initOffline() diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..578e5b1 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,128 @@ +import i18next from './i18n' +import { bindHeaderEvents, header } from './layout' +import { homeView } from './views/home' +import { catalogueView, initCatalogue } from './views/catalog' +import { aboutView } from './views/about' +import { privacyView } from './views/privacy' +import { legalView } from './views/legal' +import { initQuestionnaire } from './components/questionnaire' +import { + type Lang, + type PageKey, + type PageMeta, + LANGS, + SITE_ORIGIN, + OG_LOCALES, + resolvePath, + urlFor, +} from './routes' + +const VIEWS: Record string; init?: () => void }> = { + home: { view: homeView, init: initQuestionnaire }, + catalogue: { view: catalogueView, init: initCatalogue }, + about: { view: aboutView }, + privacy: { view: privacyView }, + legal: { view: legalView }, +} + +const notFoundView = () => { + const t = i18next.t.bind(i18next) + return /*html*/` + ${header()} +
    +

    ${t('notFound')}

    + +
    + ` +} + +function setMeta(selector: string, attr: string, content: string): void { + let el = document.head.querySelector(selector) + if (!el) { + el = document.createElement('meta') + const [name, value] = attr.split('=') + el.setAttribute(name, value) + document.head.appendChild(el) + } + el.setAttribute('content', content) +} + +function setLink(rel: string, href: string, hreflang?: string): void { + const selector = hreflang + ? `link[rel="${rel}"][hreflang="${hreflang}"]` + : `link[rel="${rel}"]` + let el = document.head.querySelector(selector) + if (!el) { + el = document.createElement('link') + el.setAttribute('rel', rel) + if (hreflang) el.setAttribute('hreflang', hreflang) + document.head.appendChild(el) + } + el.setAttribute('href', href) +} + +function updateMetaTags(page: PageMeta | null, lang: Lang, title: string): void { + const t = i18next.t.bind(i18next) + const url = SITE_ORIGIN + globalThis.location.pathname + const desc = page ? t(page.descKey) : t('seo.descHome') + + setMeta('meta[name="description"]', 'name=description', desc) + setLink('canonical', url) + setMeta('meta[property="og:title"]', 'property=og:title', title) + setMeta('meta[property="og:description"]', 'property=og:description', desc) + setMeta('meta[property="og:url"]', 'property=og:url', url) + setMeta('meta[property="og:locale"]', 'property=og:locale', OG_LOCALES[lang]) + setMeta('meta[name="twitter:title"]', 'name=twitter:title', title) + setMeta('meta[name="twitter:description"]', 'name=twitter:description', desc) + + // Per-page hreflang alternates (only meaningful for a real page). + if (page) { + for (const l of LANGS) setLink('alternate', SITE_ORIGIN + urlFor(page, l), l) + setLink('alternate', SITE_ORIGIN + urlFor(page, 'en'), 'x-default') + } +} + +export function render(app: HTMLElement): void { + const { lang, page } = resolvePath(globalThis.location.pathname) + if (i18next.language !== lang) i18next.changeLanguage(lang) + document.documentElement.lang = lang + + const t = i18next.t.bind(i18next) + const title = page ? `${t(page.titleKey)} — CaneDr` : `${t('notFound')} — CaneDr` + + document.title = title + updateMetaTags(page, lang, title) + + app.innerHTML = page ? VIEWS[page.key].view() : notFoundView() + + bindHeaderEvents((path) => navigateTo(path, app)) + + if (page) VIEWS[page.key].init?.() +} + +export function navigateTo(path: string, app: HTMLElement): void { + history.pushState(null, '', path) + render(app) +} + +const EXTERNAL_HREF_RE = /^(https?:|\/\/|#|mailto:|tel:)/ + +export function initRouter(app: HTMLElement): void { + globalThis.addEventListener('popstate', () => render(app)) + + document.addEventListener('click', (e) => { + if (e.defaultPrevented) return + if (e.button !== 0) return + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + const anchor = (e.target as HTMLElement).closest('a') + if (!anchor) return + if (anchor.target && anchor.target !== '_self') return + if (anchor.hasAttribute('download')) return + const href = anchor.getAttribute('href') + if (!href || EXTERNAL_HREF_RE.test(href)) return + e.preventDefault() + navigateTo(href, app) + }) + + render(app) +} diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..b052df9 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,71 @@ +// Language-aware routing model shared by the runtime router (router.ts), +// the header (header.ts) and the build-time prerenderer (scripts/prerender.mjs). +// +// URL scheme: English at the root, French under /fr/, Spanish under /es/. +// Each page has a localized slug per language so URLs stay meaningful, e.g. +// EN /catalog FR /fr/catalogue ES /es/catalogo +// This file is pure data + helpers — no browser or i18next imports — so it can +// run unchanged in Node during prerendering. + +export const LANGS = ['en', 'fr', 'es'] as const +export type Lang = (typeof LANGS)[number] + +export type PageKey = 'home' | 'catalogue' | 'about' | 'privacy' | 'legal' + +export interface PageMeta { + key: PageKey + titleKey: string + descKey: string + /** Path AFTER the language prefix; empty string for the home page. */ + slug: Record +} + +export const SITE_ORIGIN = 'https://canedr.cirad.fr' +export const OG_LOCALES: Record = { en: 'en_US', fr: 'fr_FR', es: 'es_ES' } + +export const PAGES: PageMeta[] = [ + { key: 'home', titleKey: 'seo.titleHome', descKey: 'seo.descHome', slug: { en: '', fr: '', es: '' } }, + { key: 'catalogue', titleKey: 'catalogue.title', descKey: 'seo.descCatalogue', slug: { en: '/catalog', fr: '/catalogue', es: '/catalogo' } }, + { key: 'about', titleKey: 'about.title', descKey: 'seo.descAbout', slug: { en: '/about', fr: '/a-propos', es: '/acerca-de' } }, + { key: 'privacy', titleKey: 'privacy.title', descKey: 'seo.descPrivacy', slug: { en: '/privacy', fr: '/confidentialite', es: '/privacidad' } }, + { key: 'legal', titleKey: 'legal.title', descKey: 'seo.descLegal', slug: { en: '/legal', fr: '/mentions-legales', es: '/aviso-legal' } }, +] + +export function pageByKey(key: PageKey): PageMeta { + const page = PAGES.find((p) => p.key === key) + if (!page) throw new Error(`Unknown page key: ${key}`) + return page +} + +/** Language carried by a pathname's prefix ('/fr/...' -> 'fr', else 'en'). */ +export function langFromPath(pathname: string): Lang { + const m = pathname.match(/^\/(fr|es)(\/|$)/) + return m ? (m[1] as Lang) : 'en' +} + +/** Absolute (origin-less) URL of a page in a given language. */ +export function urlFor(page: PageMeta, lang: Lang): string { + if (page.key === 'home') return lang === 'en' ? '/' : `/${lang}/` + return (lang === 'en' ? '' : `/${lang}`) + page.slug[lang] +} + +export interface Resolved { + lang: Lang + /** null when the pathname matches no known page (404). */ + page: PageMeta | null +} + +/** Map a pathname to its language and page, honouring the language prefix. */ +export function resolvePath(pathname: string): Resolved { + let lang: Lang = 'en' + let rest = pathname + const m = pathname.match(/^\/(fr|es)(\/.*)?$/) + if (m) { + lang = m[1] as Lang + rest = m[2] ?? '/' + } + if (rest.length > 1 && rest.endsWith('/')) rest = rest.slice(0, -1) + if (rest === '' || rest === '/') return { lang, page: pageByKey('home') } + const page = PAGES.find((p) => p.key !== 'home' && p.slug[lang] === rest) ?? null + return { lang, page } +} diff --git a/src/style.css b/src/style.css index e69de29..50f841b 100644 --- a/src/style.css +++ b/src/style.css @@ -0,0 +1,61 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@font-face { + font-family: 'Frutiger LT Pro'; + src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Frutiger LT Pro'; + src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf') format('opentype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Frutiger LT Pro'; + src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf') format('opentype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Frutiger LT Pro'; + src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf') format('opentype'); + font-weight: 700; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Myriad Pro'; + src: url('/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Myriad Pro'; + src: url('/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF') format('opentype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@layer base { + body { + font-family: 'Myriad Pro', ui-sans-serif, system-ui, sans-serif; + } + + h1, h2, h3, h4, h5, h6 { + font-family: 'Frutiger LT Pro', ui-sans-serif, system-ui, sans-serif; + } +} diff --git a/src/views/about.ts b/src/views/about.ts new file mode 100644 index 0000000..eed42cb --- /dev/null +++ b/src/views/about.ts @@ -0,0 +1,45 @@ +import { useT } from '../i18n' +import { header } from '../layout' + +export function aboutView(): string { + const t = useT() + + return /*html*/` + ${header()} +
    +
    +

    ${t('about.title')}

    +
    +

    ${t('about.introHeading')}

    +

    ${t('about.introBody')}

    +
    + +
    +

    ${t('about.creditsHeading')}

    +

    ${t('about.creditsDevelopment')}

    +

    ${t('about.creditsPhotographers')}

    + +

    ${t('contact.heading')}

    +

    + ${t('contact.body')} + canedr@cirad.fr +

    + +
    +
    +

    + ${t('about.creditsCiradIntro')} + ${t('about.creditsCiradName')} +

    +

    ${t('about.creditsCiradDescription')}

    + ${t('about.creditsCiradLink')} → +
    + + CIRAD + +
    +
    +
    +
    + ` +} diff --git a/src/views/catalog.ts b/src/views/catalog.ts new file mode 100644 index 0000000..681770d --- /dev/null +++ b/src/views/catalog.ts @@ -0,0 +1,249 @@ +import { useT } from '../i18n' +import { header } from '../layout' +import { callToAction } from '../components/call_to_action' +import { loadKey } from '../data/key-loader' +import { diseaseResult, bindCarousel, renderPathogen, formatResultLabel } from '../components/disease_result' +export function catalogueView(): string { + const t = useT() + + return /*html*/` + ${header()} +
    +
    +

    ${t('catalogue.title')} — ${t('catalogue.subtitle')}

    +
    + +
    + +
    + + + + + + + + + + + +
    + ${t('catalogue.diseaseNameColumn')} + + ${t('catalogue.pathogenColumnShort')} +
    +
    +
    + + + + ${callToAction(true)} +
    + ` +} + +export async function initCatalogue(): Promise { + // loadKey() caches the JSON in memory — called once here for the list, and + // reused without refetch when rendering each disease result. + const key = await loadKey() + const t = useT() + + type Disease = (typeof key.diseases)[string] + type Entry = Disease & { id: string; section: string } + type SortKey = 'name' | 'pathogen' + type SortDir = 'asc' | 'desc' + + const diseases: Entry[] = Object.entries(key.diseases).filter(([id]) => id !== 'D_no_convincing_result').map(([id, d]) => ({ ...d, id, section: t('catalogue.sectionDiseases') })) + const otherCauses: Entry[] = Object.entries(key.other_causes ?? {}).filter(([id]) => id !== 'D_borer_attack_in_nursery').map(([id, d]) => ({ ...d, id, section: t('catalogue.sectionOtherCauses') })) + let sortKey: SortKey = 'name' + let sortDir: SortDir = 'asc' + let query = '' + let selectedSection: string | null = null + + function sortEntries(list: Entry[]): Entry[] { + return list.slice().sort((a, b) => { + const av = (sortKey === 'name' ? a.name : (a.pathogen ?? '')).toLowerCase() + const bv = (sortKey === 'name' ? b.name : (b.pathogen ?? '')).toLowerCase() + return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av) + }) + } + + function filterEntries(list: Entry[]): Entry[] { + if (!query) return list + return list.filter(d => + d.name.toLowerCase().includes(query) || + (d.pathogen ?? '').toLowerCase().includes(query) || + d.section.toLowerCase().includes(query) + ) + } + + function updateSortIndicators(): void { + const nameEl = document.getElementById('sort-name') + const pathEl = document.getElementById('sort-pathogen') + const dirArrow = sortDir === 'asc' ? '↑' : '↓' + if (nameEl) nameEl.textContent = sortKey === 'name' ? dirArrow : '↕' + if (pathEl) pathEl.textContent = sortKey === 'pathogen' ? dirArrow : '↕' + } + + function renderEntryRow(d: Entry): string { + return /*html*/` + + ${d.name} + ${d.pathogen ? renderPathogen(d.pathogen) : '—'} + + + + + ` + } + + function renderSection(heading: string, list: Entry[]): string { + if (list.length === 0) return '' + const isSelected = selectedSection === heading + return /*html*/` + + + ${heading} + + + ${list.map(renderEntryRow).join('')} + ` + } + + function renderRows(): string { + let diseasesView = sortEntries(filterEntries(diseases)) + let otherView = sortEntries(filterEntries(otherCauses)) + + if (selectedSection !== null) { + diseasesView = selectedSection === t('catalogue.sectionDiseases') ? diseasesView : [] + otherView = selectedSection === t('catalogue.sectionOtherCauses') ? otherView : [] + } + + if (diseasesView.length === 0 && otherView.length === 0) { + return `${t('catalogue.empty')}` + } + return ( + renderSection(t('catalogue.sectionDiseases'), diseasesView) + + renderSection(t('catalogue.sectionOtherCauses'), otherView) + ) + } + + function refresh(): void { + const tbody = document.getElementById('catalogue-list') + if (tbody) tbody.innerHTML = renderRows() + updateSortIndicators() + + // Hide pathogen header when only other causes are visible + let visibleDiseases = filterEntries(diseases) + let visibleOther = filterEntries(otherCauses) + if (selectedSection !== null) { + visibleDiseases = selectedSection === t('catalogue.sectionDiseases') ? visibleDiseases : [] + visibleOther = selectedSection === t('catalogue.sectionOtherCauses') ? visibleOther : [] + } + const onlyOtherCauses = visibleDiseases.length === 0 && visibleOther.length > 0 + + const thPathogen = document.getElementById('th-pathogen') + if (thPathogen) { + if (onlyOtherCauses) { + thPathogen.innerHTML = '×' + delete thPathogen.dataset.sort + thPathogen.classList.remove('cursor-pointer', 'hover:bg-gray-50') + } else { + thPathogen.innerHTML = `${t('catalogue.pathogenColumnShort')} ` + thPathogen.dataset.sort = 'pathogen' + thPathogen.classList.add('cursor-pointer', 'hover:bg-gray-50') + } + thPathogen.style.display = '' + } + } + + function showResult(diseaseId: string): void { + const disease = key.diseases[diseaseId] ?? key.other_causes?.[diseaseId] + if (!disease) return + + const main = document.querySelector('main') + const listView = document.getElementById('catalogue-list-view') + const resultView = document.getElementById('catalogue-result-view') + if (!main || !listView || !resultView) return + + const resultLabel = formatResultLabel(disease.name) + const topSlot = /*html*/` + + ` + + resultView.innerHTML = diseaseResult(disease, { topSlot }) + bindCarousel(resultView, disease.image ?? []) + + main.classList.remove('md:h-[calc(100vh-4.5rem)]', 'portrait:h-screen', '[@media(orientation:landscape)_and_(max-height:480px)]:h-[200vh]', 'overflow-hidden', 'flex', 'flex-col') + main.classList.add('min-h-[calc(100vh-4.5rem)]') + listView.classList.add('hidden') + resultView.classList.remove('hidden') + + const back = document.getElementById('catalogue-back') + if (back) back.addEventListener('click', showList) + } + + function showList(): void { + const main = document.querySelector('main') + const listView = document.getElementById('catalogue-list-view') + const resultView = document.getElementById('catalogue-result-view') + if (!main || !listView || !resultView) return + + main.classList.add('md:h-[calc(100vh-4.5rem)]', 'portrait:h-screen', '[@media(orientation:landscape)_and_(max-height:480px)]:h-[200vh]', 'overflow-hidden', 'flex', 'flex-col') + main.classList.remove('min-h-[calc(100vh-4.5rem)]') + resultView.classList.add('hidden') + resultView.innerHTML = '' + listView.classList.remove('hidden') + } + + refresh() + + const search = document.getElementById('catalogue-search') as HTMLInputElement | null + if (search) { + search.addEventListener('input', () => { + query = search.value.toLowerCase() + refresh() + }) + } + + const thead = document.querySelector('thead') + if (thead) { + thead.addEventListener('click', (e) => { + const th = (e.target as HTMLElement).closest('th[data-sort]') as HTMLElement | null + if (!th) return + const clicked = th.dataset.sort as SortKey + if (sortKey === clicked) { + sortDir = sortDir === 'asc' ? 'desc' : 'asc' + } else { + sortKey = clicked + sortDir = 'asc' + } + refresh() + }) + } + + const tbody = document.getElementById('catalogue-list') + if (tbody) { + tbody.addEventListener('click', (e) => { + const sectionFilter = (e.target as HTMLElement).closest('[data-section-filter]') as HTMLElement | null + if (sectionFilter) { + const section = sectionFilter.dataset.sectionFilter + if (section) { + selectedSection = selectedSection === section ? null : section + refresh() + } + return + } + + const btn = (e.target as HTMLElement).closest('.disease-link-btn') as HTMLElement | null + if (!btn) return + const id = btn.dataset.diseaseId + if (id) showResult(id) + }) + } +} diff --git a/src/views/home.ts b/src/views/home.ts new file mode 100644 index 0000000..1cb7f6a --- /dev/null +++ b/src/views/home.ts @@ -0,0 +1,14 @@ +import { header } from '../layout' +import { callToAction } from '../components/call_to_action' +import { ciradCorner } from '../components/cirad_corner' + +export function homeView(): string { + return /*html*/` + ${header()} +
    + ${ciradCorner()} +
    + ${callToAction()} +
    + ` +} diff --git a/src/views/legal.ts b/src/views/legal.ts new file mode 100644 index 0000000..a600312 --- /dev/null +++ b/src/views/legal.ts @@ -0,0 +1,31 @@ +import { useT } from '../i18n' +import { header } from '../layout' + +export function legalView(): string { + const t = useT() + + const sections: Array<{ heading: string; body: string }> = [ + { heading: t('legal.publisherHeading'), body: t('legal.publisherBody') }, + { heading: t('legal.directorHeading'), body: t('legal.directorBody') }, + { heading: t('legal.hostingHeading'), body: t('legal.hostingBody') }, + { heading: t('legal.creditsHeading'), body: t('legal.creditsBody') }, + { heading: t('legal.copyrightHeading'), body: t('legal.copyrightBody') }, + ] + + const sectionsHtml = sections.map(({ heading, body }) => /*html*/` +
    +

    ${heading}

    +

    ${body}

    +
    + `).join('') + + return /*html*/` + ${header()} +
    +
    +

    ${t('legal.title')}

    + ${sectionsHtml} +
    +
    + ` +} diff --git a/src/views/privacy.ts b/src/views/privacy.ts new file mode 100644 index 0000000..02f089e --- /dev/null +++ b/src/views/privacy.ts @@ -0,0 +1,32 @@ +import { useT } from '../i18n' +import { header } from '../layout' + +export function privacyView(): string { + const t = useT() + + const sections: Array<{ heading: string; body: string }> = [ + { heading: t('privacy.dataHeading'), body: t('privacy.dataBody') }, + { heading: t('privacy.offlineHeading'), body: t('privacy.offlineBody') }, + { heading: t('privacy.logsHeading'), body: t('privacy.logsBody') }, + { heading: t('privacy.controllerHeading'), body: t('privacy.controllerBody') }, + { heading: t('privacy.dpoHeading'), body: t('privacy.dpoBody') }, + { heading: t('privacy.rightsHeading'), body: t('privacy.rightsBody') }, + ] + + const sectionsHtml = sections.map(({ heading, body }) => /*html*/` +
    +

    ${heading}

    +

    ${body}

    +
    + `).join('') + + return /*html*/` + ${header()} +
    +
    +

    ${t('privacy.title')}

    + ${sectionsHtml} +
    +
    + ` +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ac49a9b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from 'vite' +import type { Plugin } from 'vite' + +// Moves before