create-wp-reactor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # create-wp-reactor
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor
6
+
7
+ - Stack dev local : `docker-compose.dev.yml` (WordPress image base + MariaDB +
8
+ Redis, thème enfant bind-monté) + `bootstrap.sh` + scripts `pnpm docker:*`.
9
+ - Docs déploiement : `docs/deploy.md` (procédure Coolify ordonnée + Cloudflare),
10
+ `docs/environment.md` (variables local / GitHub / Coolify), `docs/traefik-labels.md`.
11
+ - Script de purge edge Cloudflare (`scripts/clear-cache.mjs`) en post-deploy.
12
+ - Renommage `GA_FRONTEND_URL` → `FRONTEND_URL` dans les templates.
13
+
14
+ ### Patch
15
+
16
+ - Fix : le `Dockerfile` de la webapp cliente était absent du repo généré
17
+ (effacé par le sync). Déplacé dans le starter → porté correctement au scaffold.
18
+
19
+ ## 0.1.0
20
+
21
+ - Première publication. Scaffolder de repo client mince : `npm create wp-reactor <nom>`.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # create-wp-reactor
2
+
3
+ Scaffolder de repo client **WP Reactor** — un framework headless WordPress
4
+ (kernel TanStack Start + modules commerce/auth/editorial + générateur de blocs).
5
+
6
+ ```bash
7
+ npm create wp-reactor@latest mon-client
8
+ # ou : pnpm create wp-reactor mon-client
9
+ # ou : npx create-wp-reactor mon-client
10
+ ```
11
+
12
+ Génère un monorepo client « 2 mondes » :
13
+
14
+ ```
15
+ apps/webapp/ coque TanStack Start (deps @wp-reactor/* publiées)
16
+ apps/wordpress/theme-<nom>/ thème ENFANT (branding + blocs propres)
17
+ docker-compose.dev.yml stack dev local (WordPress + DB + Redis)
18
+ .github/workflows/deploy.yml CI réutilisable du framework (GHCR → Coolify)
19
+ docs/ deploy.md, environment.md, traefik-labels.md
20
+ ```
21
+
22
+ ## Options
23
+
24
+ ```
25
+ create-wp-reactor <nom> [--dir <path>] [--namespace <ns>]
26
+ ```
27
+
28
+ - `--dir` : dossier cible (défaut : `<nom>` dans le cwd).
29
+ - `--namespace` : namespace des blocs Gutenberg (défaut : `<nom>` sans tirets).
30
+
31
+ ## Après génération
32
+
33
+ Les packages runtime `@wp-reactor/*` sont sur **GitHub Packages** (privé) →
34
+ authentifie-toi avant `pnpm install` :
35
+
36
+ ```bash
37
+ echo "//npm.pkg.github.com/:_authToken=<PAT read:packages>" >> ~/.npmrc
38
+ cd mon-client && pnpm install && pnpm dev
39
+ ```
40
+
41
+ Voir le `README.md` et `docs/` du repo généré pour le dev Docker et le déploiement.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wp-reactor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Scaffolder de repo client mince WP Reactor : émet apps/webapp (depuis le starter) + thème enfant + CI réutilisable + docker. Usage : npm create wp-reactor <nom>.",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "files": [
13
13
  "dist",
14
14
  "bin",
15
- "templates"
15
+ "templates",
16
+ "CHANGELOG.md"
16
17
  ],
17
18
  "scripts": {
18
19
  "sync": "node scripts/sync-template.mjs",
@@ -1,5 +1,23 @@
1
- # Webapp (bakées au build Vite)
2
- VITE_WORDPRESS_URL=https://__PROJECT_NAME__-wp.example.com
1
+ # Copie ce fichier en `.env` (gitignored). Doc complète : docs/environment.md
2
+ # Sert au stack dev local (docker-compose.dev.yml) + `pnpm dev`.
3
+
4
+ # --- WordPress local (docker-compose.dev.yml) ---
5
+ WP_PORT=8890
6
+ WP_HOME=http://localhost:8890
7
+ FRONTEND_URL=http://localhost:3000
8
+ WORDPRESS_DB_NAME=wordpress
9
+ WORDPRESS_DB_USER=wordpress
10
+ WORDPRESS_DB_PASSWORD=wordpress
11
+ MYSQL_ROOT_PASSWORD=root
12
+
13
+ # --- Provisioning (wp-bootstrap, profil tools) ---
14
+ WP_SITE_TITLE=__PROJECT_TITLE__
15
+ WP_ADMIN_USER=admin
16
+ WP_ADMIN_PASSWORD=admin
17
+ WP_ADMIN_EMAIL=admin@example.com
18
+
19
+ # --- Webapp (bakées au build Vite / lues par `pnpm dev`) ---
20
+ VITE_WORDPRESS_URL=http://localhost:8890
3
21
  VITE_GRAPHQL_PATH=/graphql
4
22
  VITE_FEATURE_EDITORIAL=true
5
23
  VITE_FEATURE_ECOMMERCE=true
@@ -6,8 +6,11 @@ Repo client **WP Reactor** (framework headless WordPress) — monorepo léger «
6
6
  apps/webapp/ coque TanStack Start (deps @wp-reactor/* publiées)
7
7
  apps/wordpress/theme-__PROJECT_NAME__/ thème ENFANT (branding + blocs propres)
8
8
  apps/wordpress/Dockerfile image WP = base framework + thème enfant overlayé
9
+ docker-compose.dev.yml stack dev local (WordPress + DB + Redis)
9
10
  .github/workflows/deploy.yml appelle les workflows réutilisables du framework
10
11
  wp-reactor.config.json config gen-block (namespace, chemins)
12
+ docs/environment.md où va chaque variable (local / GitHub / Coolify)
13
+ docs/traefik-labels.md labels Traefik à coller dans Coolify
11
14
  ```
12
15
 
13
16
  ## Prérequis
@@ -19,14 +22,27 @@ Authentifie-toi avant `pnpm install` :
19
22
  echo "//npm.pkg.github.com/:_authToken=<PAT read:packages>" >> ~/.npmrc
20
23
  ```
21
24
 
22
- ## Dev
25
+ ## Dev rapide (Docker)
26
+
27
+ WordPress (image base framework) + DB + Redis en conteneurs, webapp sur l'hôte.
23
28
 
24
29
  ```bash
30
+ # L'image WordPress base est privée (GHCR) → login une fois :
31
+ echo <PAT read:packages> | docker login ghcr.io -u <user> --password-stdin
32
+
25
33
  pnpm install
26
- cp .env.example .env # renseigne VITE_WORDPRESS_URL, SESSION_SECRET…
27
- pnpm dev # webapp sur :3000
34
+ cp .env.example .env # ajuste si besoin
35
+
36
+ pnpm docker:up # WordPress + DB + Redis
37
+ pnpm docker:bootstrap # install WP + plugins GraphQL + thème enfant
38
+ pnpm dev # webapp sur http://localhost:3000
28
39
  ```
29
40
 
41
+ WordPress (admin / GraphQL) : `http://localhost:8890` (cf `WP_PORT`).
42
+ Arrêt : `pnpm docker:down` — remise à zéro (volumes) : `pnpm docker:reset`.
43
+
44
+ > Détail des variables d'environnement : **[`docs/environment.md`](docs/environment.md)**.
45
+
30
46
  ## Générer un bloc
31
47
 
32
48
  ```bash
@@ -36,5 +52,8 @@ pnpm gen:block schemas/<slug>.json # écrit thème enfant + renderer webapp +
36
52
  ## Déploiement
37
53
 
38
54
  Push sur `main` → `.github/workflows/deploy.yml` build+push les images webapp & WordPress
39
- sur GHCR via les workflows réutilisables du framework, puis déclenche Coolify (secrets
40
- `COOLIFY_*` requis).
55
+ sur GHCR via les workflows réutilisables du framework, puis déclenche Coolify.
56
+
57
+ - **Procédure ordonnée + Cloudflare** : **[`docs/deploy.md`](docs/deploy.md)**
58
+ - **Variables / secrets** (GitHub Actions + Coolify) : **[`docs/environment.md`](docs/environment.md)**
59
+ - **Labels Traefik** à coller dans l'UI Coolify : **[`docs/traefik-labels.md`](docs/traefik-labels.md)**
@@ -0,0 +1,40 @@
1
+ # syntax=docker/dockerfile:1.7
2
+ # Build de la coque webapp. Les @wp-reactor/* sont tirés depuis GitHub Packages
3
+ # (cf .npmrc) — passer NODE_AUTH_TOKEN (read:packages) au build.
4
+ #
5
+ # NB : ce Dockerfile est un artefact de TEMPLATE (porté dans le repo client par
6
+ # le scaffolder, qui substitue __PROJECT_NAME__). Le contexte de build = racine
7
+ # du repo client.
8
+ FROM node:22-alpine AS base
9
+ WORKDIR /app
10
+ ENV CI=true DO_NOT_TRACK=1
11
+ RUN corepack enable && corepack prepare pnpm@11.3.0 --activate
12
+
13
+ FROM base AS build
14
+ ARG NODE_AUTH_TOKEN
15
+ ARG VITE_WORDPRESS_URL
16
+ ARG VITE_GRAPHQL_PATH
17
+ ARG VITE_FEATURE_EDITORIAL
18
+ ARG VITE_FEATURE_ECOMMERCE
19
+ ARG VITE_FEATURE_AUTH
20
+ ARG SESSION_SECRET
21
+ ENV VITE_WORDPRESS_URL=${VITE_WORDPRESS_URL} \
22
+ VITE_GRAPHQL_PATH=${VITE_GRAPHQL_PATH} \
23
+ VITE_FEATURE_EDITORIAL=${VITE_FEATURE_EDITORIAL} \
24
+ VITE_FEATURE_ECOMMERCE=${VITE_FEATURE_ECOMMERCE} \
25
+ VITE_FEATURE_AUTH=${VITE_FEATURE_AUTH} \
26
+ SESSION_SECRET=${SESSION_SECRET}
27
+ COPY . .
28
+ RUN printf '//npm.pkg.github.com/:_authToken=%s\n' "${NODE_AUTH_TOKEN}" >> .npmrc \
29
+ && pnpm install --frozen-lockfile \
30
+ && pnpm --filter @__PROJECT_NAME__/webapp build \
31
+ && rm -f .npmrc
32
+
33
+ FROM node:22-alpine AS runner
34
+ RUN apk add --no-cache curl
35
+ WORKDIR /app/apps/webapp
36
+ COPY --from=build /app/apps/webapp/.output ./.output
37
+ # Scripts ops (ex. purge Cloudflare en post-deployment Coolify).
38
+ COPY --from=build /app/apps/webapp/scripts ./scripts
39
+ EXPOSE 3000
40
+ CMD ["node", ".output/server/index.mjs"]
@@ -0,0 +1,45 @@
1
+ // Purge complète du cache Cloudflare de la zone webapp.
2
+ //
3
+ // Lancé en "Post-deployment command" Coolify (après le health check, donc une
4
+ // fois le nouveau conteneur live) :
5
+ // node scripts/clear-cache.mjs
6
+ //
7
+ // Pourquoi : un redeploy ne vide pas le cache edge → l'ancien HTML caché
8
+ // référence des assets hashés disparus (404 CSS/JS). On purge pour que le 1er
9
+ // visiteur reconstruise le cache sur le nouveau build.
10
+ //
11
+ // Env requis (Coolify → Environment Variables de l'app webapp) :
12
+ // CF_ZONE_ID — Zone ID Cloudflare de la zone du front
13
+ // CF_API_TOKEN — token scopé `Zone → Cache Purge → Purge` sur cette zone
14
+ // Si l'un manque, la purge est ignorée (exit 0) — déploiement non bloqué.
15
+
16
+ const zoneId = process.env.CF_ZONE_ID;
17
+ const apiToken = process.env.CF_API_TOKEN;
18
+
19
+ if (!zoneId || !apiToken) {
20
+ console.warn("[clear-cache] CF_ZONE_ID/CF_API_TOKEN absent — purge ignorée.");
21
+ process.exit(0);
22
+ }
23
+
24
+ try {
25
+ const res = await fetch(
26
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
27
+ {
28
+ method: "POST",
29
+ headers: {
30
+ Authorization: `Bearer ${apiToken}`,
31
+ "Content-Type": "application/json",
32
+ },
33
+ body: JSON.stringify({ purge_everything: true }),
34
+ },
35
+ );
36
+ const json = await res.json();
37
+ if (!json.success) {
38
+ console.error("[clear-cache] échec:", JSON.stringify(json.errors ?? json));
39
+ process.exit(1);
40
+ }
41
+ console.log("[clear-cache] cache Cloudflare purgé ✅");
42
+ } catch (err) {
43
+ console.error("[clear-cache] erreur:", err);
44
+ process.exit(1);
45
+ }
@@ -0,0 +1,43 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ # Provisioning WordPress local : install core + plugins GraphQL headless +
4
+ # active le thème enfant. Idempotent (relançable sans casse).
5
+ WP_PATH="/var/www/html"
6
+ WP="wp --path=$WP_PATH --allow-root"
7
+
8
+ echo "→ Attente du core WordPress…"
9
+ for _ in $(seq 1 60); do
10
+ [ -f "$WP_PATH/wp-includes/version.php" ] && break
11
+ sleep 2
12
+ done
13
+
14
+ if [ ! -f "$WP_PATH/wp-config.php" ]; then
15
+ $WP config create \
16
+ --dbname="$WORDPRESS_DB_NAME" \
17
+ --dbuser="$WORDPRESS_DB_USER" \
18
+ --dbpass="$WORDPRESS_DB_PASSWORD" \
19
+ --dbhost="db" --skip-check
20
+ fi
21
+
22
+ if ! $WP core is-installed >/dev/null 2>&1; then
23
+ echo "→ Installation de WordPress…"
24
+ $WP core install \
25
+ --url="$WP_HOME" --title="$WP_SITE_TITLE" \
26
+ --admin_user="$WP_ADMIN_USER" --admin_password="$WP_ADMIN_PASSWORD" \
27
+ --admin_email="$WP_ADMIN_EMAIL" --skip-email
28
+ fi
29
+
30
+ $WP option update home "$WP_HOME"
31
+ $WP option update siteurl "$WP_HOME"
32
+
33
+ echo "→ Plugins GraphQL headless…"
34
+ $WP plugin install wp-graphql --activate
35
+ # Expose editorBlocks (+ attributs) consommés par @wp-reactor/editorial.
36
+ $WP plugin install https://github.com/wpengine/wp-graphql-content-blocks/archive/refs/heads/main.zip --activate || true
37
+ # Plugin headless (baké dans l'image base) : activer s'il est présent.
38
+ $WP plugin activate wp-reactor-headless || true
39
+
40
+ echo "→ Thème enfant ${CHILD_THEME}…"
41
+ $WP theme activate "$CHILD_THEME" || echo " (thème ${CHILD_THEME} pas encore prêt, ignoré)"
42
+
43
+ echo "✓ Bootstrap terminé — $WP_HOME"
@@ -0,0 +1,93 @@
1
+ # =============================================================================
2
+ # Stack DEV local __PROJECT_TITLE__ — démarrage rapide.
3
+ # -----------------------------------------------------------------------------
4
+ # WordPress = image BASE du framework (ghcr.io/wp-reactor/wordpress-base, qui
5
+ # bake le thème parent + plugin). Le thème ENFANT est bind-monté en live pour
6
+ # l'éditer sans rebuild. La webapp tourne séparément sur l'hôte (`pnpm dev`,
7
+ # port 3000) — la plus rapide en dev (HMR).
8
+ #
9
+ # Prérequis : l'image base est PRIVÉE (GHCR) → s'authentifier une fois :
10
+ # echo <PAT read:packages> | docker login ghcr.io -u <user> --password-stdin
11
+ #
12
+ # Démarrage :
13
+ # cp .env.example .env # ajuste si besoin
14
+ # docker compose -f docker-compose.dev.yml up -d
15
+ # docker compose -f docker-compose.dev.yml --profile tools run --rm wp-bootstrap
16
+ # pnpm dev # webapp sur http://localhost:3000
17
+ # WordPress (admin/GraphQL) : http://localhost:${WP_PORT}
18
+ # =============================================================================
19
+ name: __PROJECT_NAME__-dev
20
+
21
+ services:
22
+ db:
23
+ image: mariadb:11
24
+ environment:
25
+ MARIADB_DATABASE: ${WORDPRESS_DB_NAME}
26
+ MARIADB_USER: ${WORDPRESS_DB_USER}
27
+ MARIADB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
28
+ MARIADB_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
29
+ volumes:
30
+ - db_data:/var/lib/mysql
31
+ healthcheck:
32
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
33
+ interval: 5s
34
+ timeout: 5s
35
+ retries: 20
36
+
37
+ redis:
38
+ image: redis:7-alpine
39
+
40
+ wordpress:
41
+ image: ghcr.io/wp-reactor/wordpress-base:latest
42
+ depends_on:
43
+ db:
44
+ condition: service_healthy
45
+ redis:
46
+ condition: service_started
47
+ ports:
48
+ - "${WP_PORT}:80"
49
+ environment:
50
+ WORDPRESS_DB_HOST: db
51
+ WORDPRESS_DB_NAME: ${WORDPRESS_DB_NAME}
52
+ WORDPRESS_DB_USER: ${WORDPRESS_DB_USER}
53
+ WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
54
+ # Lues par la config bakée (wp-reactor-config.php via auto_prepend_file).
55
+ WP_HOME: ${WP_HOME}
56
+ WP_SITEURL: ${WP_HOME}
57
+ WP_ENVIRONMENT_TYPE: local
58
+ WP_REDIS_HOST: redis
59
+ FRONTEND_URL: ${FRONTEND_URL}
60
+ WP_DEBUG: "true"
61
+ GRAPHQL_DEBUG: "true"
62
+ volumes:
63
+ - wp_data:/var/www/html
64
+ # Thème ENFANT bind-monté en live (le parent + plugin viennent de l'image base).
65
+ - ./apps/wordpress/theme-__PROJECT_NAME__:/var/www/html/wp-content/themes/__PROJECT_NAME__
66
+
67
+ wp-bootstrap:
68
+ image: wordpress:cli-php8.4
69
+ profiles: ["tools"]
70
+ user: "33:33"
71
+ depends_on:
72
+ db:
73
+ condition: service_healthy
74
+ environment:
75
+ WORDPRESS_DB_HOST: db
76
+ WORDPRESS_DB_NAME: ${WORDPRESS_DB_NAME}
77
+ WORDPRESS_DB_USER: ${WORDPRESS_DB_USER}
78
+ WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
79
+ WP_HOME: ${WP_HOME}
80
+ WP_SITE_TITLE: ${WP_SITE_TITLE}
81
+ WP_ADMIN_USER: ${WP_ADMIN_USER}
82
+ WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD}
83
+ WP_ADMIN_EMAIL: ${WP_ADMIN_EMAIL}
84
+ CHILD_THEME: __PROJECT_NAME__
85
+ volumes:
86
+ - wp_data:/var/www/html
87
+ - ./apps/wordpress/theme-__PROJECT_NAME__:/var/www/html/wp-content/themes/__PROJECT_NAME__
88
+ - ./apps/wordpress/docker/bootstrap.sh:/bootstrap.sh:ro
89
+ entrypoint: ["sh", "/bootstrap.sh"]
90
+
91
+ volumes:
92
+ db_data:
93
+ wp_data:
@@ -0,0 +1,105 @@
1
+ # Déploiement Coolify — procédure ordonnée
2
+
3
+ Deux **applications Coolify autonomes** par environnement : `__PROJECT_NAME__-wordpress`
4
+ et `__PROJECT_NAME__-webapp`. Chacune tire son image GHCR construite par
5
+ `.github/workflows/deploy.yml` (push sur `main`). Variables d'env : voir
6
+ [`environment.md`](./environment.md) ; labels Traefik : [`traefik-labels.md`](./traefik-labels.md).
7
+
8
+ ## Prérequis (une fois)
9
+
10
+ 1. **GHCR accessible par Coolify** : les images sont privées. Dans Coolify →
11
+ *Sources / Registries*, ajouter `ghcr.io` avec un PAT `read:packages`.
12
+ 2. **DNS** chez Cloudflare : `__PROJECT_NAME__.example.com` (front) et
13
+ `wp.__PROJECT_NAME__.example.com` (WordPress) → IP du serveur Coolify, **proxy activé**
14
+ (nuage orange) pour le front, **DNS-only** (nuage gris) pour WordPress
15
+ (l'admin/GraphQL ne doit pas passer par le cache edge).
16
+ 3. **Secrets GitHub Actions** renseignés (cf [`environment.md`](./environment.md) §2).
17
+
18
+ ## Ordre de déploiement (1er provisioning)
19
+
20
+ > L'ordre compte : WordPress doit exister et répondre avant que la webapp (SSR)
21
+ > ne l'interroge.
22
+
23
+ ### 1. App WordPress
24
+
25
+ 1. Coolify → *New Resource* → *Docker Image* → `ghcr.io/<owner>/<repo>/wordpress:main`.
26
+ 2. **Network** : réseau `coolify`. Ajouter un *Network Alias*
27
+ `__PROJECT_NAME__-wordpress-internal` (résolu par la webapp en SSR via `WORDPRESS_INTERNAL_URL`).
28
+ 3. **Environment Variables** : section *App WordPress* de [`environment.md`](./environment.md).
29
+ 4. **Labels** Traefik : bloc WordPress de [`traefik-labels.md`](./traefik-labels.md).
30
+ 5. **Persistent Storage** : volume sur `/var/www/html` (conserve core, uploads, plugins tiers).
31
+ 6. Déployer. Vérifier `https://wp.__PROJECT_NAME__.example.com/wp-admin` et `/graphql`.
32
+
33
+ ### 2. Provisioning WordPress (wp-cli, une fois)
34
+
35
+ Installer DB + plugins GraphQL + activer le thème enfant. Depuis le serveur (ou un
36
+ job Coolify ponctuel), avec accès à la même DB/volume :
37
+
38
+ ```bash
39
+ # active wp-graphql + wp-graphql-content-blocks, thème enfant __PROJECT_NAME__
40
+ wp plugin install wp-graphql --activate
41
+ wp theme activate __PROJECT_NAME__
42
+ ```
43
+
44
+ ### 3. App Webapp
45
+
46
+ 1. Coolify → *New Resource* → *Docker Image* → `ghcr.io/<owner>/<repo>/webapp:main`.
47
+ 2. **Network** : réseau `coolify` (pour résoudre `__PROJECT_NAME__-wordpress-internal`).
48
+ 3. **Environment Variables** : section *App Webapp* de [`environment.md`](./environment.md)
49
+ (dont `WORDPRESS_INTERNAL_URL=http://__PROJECT_NAME__-wordpress-internal`, `SESSION_SECRET`,
50
+ `CF_ZONE_ID`, `CF_API_TOKEN`).
51
+ 4. **Labels** Traefik : bloc Webapp de [`traefik-labels.md`](./traefik-labels.md).
52
+ 5. **Post-deployment command** : `node scripts/clear-cache.mjs` (purge Cloudflare, cf §Cloudflare).
53
+ 6. Déployer. Vérifier `https://__PROJECT_NAME__.example.com`.
54
+
55
+ ## Déploiements suivants (automatiques)
56
+
57
+ Push sur `main` → `deploy.yml` build+push les images puis appelle l'API Coolify
58
+ (`COOLIFY_*`) pour redéployer. La **post-deployment command** purge le cache edge.
59
+ Rien à faire manuellement.
60
+
61
+ ## Cloudflare
62
+
63
+ Le HTML SSR public est **mutualisé pour tous les visiteurs** (l'auth et le panier
64
+ s'hydratent côté client, jamais au SSR) — il est donc cachable à l'edge. Le middleware
65
+ `cacheControl` (framework) émet `s-maxage` + `stale-while-revalidate` sur les pages
66
+ publiques et `private, no-cache` sur les routes perso (`/mon-compte`, `/cart`, `/login`,
67
+ `/preview`) ou toute réponse posant un `Set-Cookie`.
68
+
69
+ ### Cache Rule (zone du front)
70
+
71
+ Crée **une Cache Rule** Cloudflare sur la zone `__PROJECT_NAME__.example.com` :
72
+
73
+ - **Si** : `Method eq GET` **et** l'URI ne commence pas par `/mon-compte`, `/cart`,
74
+ `/login`, `/preview`, `/api`.
75
+ - **Alors** : *Eligible for cache* + *Respect origin TTL* (honore le `s-maxage` envoyé
76
+ par l'origine — ne PAS forcer un Edge TTL fixe).
77
+
78
+ > ⚠️ Ne **pas** bypasser le cache sur présence de cookie : le HTML public ne porte
79
+ > jamais de `Set-Cookie` et reste cachable même si la requête a un cookie de session.
80
+ > Bypasser sur cookie casserait le cache dès le 1er ajout panier.
81
+
82
+ ### Token de purge (post-deploy)
83
+
84
+ `scripts/clear-cache.mjs` purge la zone après chaque deploy (sinon l'ancien HTML
85
+ caché référence des assets hashés disparus → 404 CSS/JS). Crée un **API Token**
86
+ Cloudflare scopé `Zone → Cache Purge → Purge` sur la zone du front, puis renseigne
87
+ dans l'app webapp Coolify :
88
+
89
+ - `CF_ZONE_ID` — Zone ID de `__PROJECT_NAME__.example.com`
90
+ - `CF_API_TOKEN` — le token ci-dessus
91
+
92
+ Si l'un manque, la purge est ignorée (le déploiement n'échoue pas).
93
+
94
+ ### WordPress (host dédié)
95
+
96
+ Le host `wp.__PROJECT_NAME__.example.com` reste **DNS-only** (pas de proxy edge) :
97
+ l'admin, GraphQL (POST) et `/wp-json` ne doivent jamais être mis en cache partagé.
98
+ Les assets/uploads WordPress sont cachés via les labels Traefik (cf `traefik-labels.md`),
99
+ pas par Cloudflare.
100
+
101
+ ## Rollback
102
+
103
+ Les images sont taguées par SHA (`...:<sha>`). Pour revenir en arrière, pointer
104
+ l'app Coolify sur le tag SHA précédent et redéployer. Sauvegarder la DB avant un
105
+ déploiement prod à risque.
@@ -0,0 +1,74 @@
1
+ # Variables d'environnement — où va quoi
2
+
3
+ Trois destinations distinctes. Ne pas mélanger.
4
+
5
+ ## 1. Local (`.env`, gitignored)
6
+
7
+ Pour `docker-compose.dev.yml` + `pnpm dev`. Copie `.env.example` → `.env`.
8
+
9
+ | Variable | Rôle |
10
+ |---|---|
11
+ | `WP_PORT` | Port hôte du WordPress local (ex. 8890) |
12
+ | `WP_HOME` | URL du WP local (`http://localhost:${WP_PORT}`) |
13
+ | `FRONTEND_URL` | URL de la webapp en dev (`http://localhost:3000`) |
14
+ | `WORDPRESS_DB_NAME/_USER/_PASSWORD`, `MYSQL_ROOT_PASSWORD` | DB locale |
15
+ | `WP_SITE_TITLE`, `WP_ADMIN_USER/_PASSWORD/_EMAIL` | Provisioning (`wp-bootstrap`) |
16
+ | `VITE_*` | Bakées par Vite au `pnpm dev` (front) |
17
+ | `SESSION_SECRET` | Sceau du cookie de session (auth) |
18
+
19
+ > L'image WordPress de base est **privée** (GHCR). Avant le 1er `up` :
20
+ > `echo <PAT read:packages> | docker login ghcr.io -u <user> --password-stdin`
21
+
22
+ ## 2. GitHub (CI — `.github/workflows/deploy.yml`)
23
+
24
+ Repo → *Settings* → *Secrets and variables* → *Actions*.
25
+
26
+ **Secrets** (sensibles) :
27
+
28
+ | Secret | Rôle |
29
+ |---|---|
30
+ | `SESSION_SECRET` | Sceau cookie session (baké au build webapp) |
31
+ | `COOLIFY_URL`, `COOLIFY_TOKEN` | API Coolify (déclenche le redeploy) |
32
+ | `COOLIFY_WEBAPP_UUID`, `COOLIFY_WORDPRESS_UUID` | UUID des apps Coolify à redéployer |
33
+
34
+ > `GITHUB_TOKEN` est fourni automatiquement (push GHCR + lecture des packages
35
+ > `@wp-reactor/*` privés au build). Aucun PAT à créer côté CI.
36
+
37
+ **Variables** (non sensibles, `vars.*`, bakées au build Vite) :
38
+
39
+ | Variable | Rôle |
40
+ |---|---|
41
+ | `VITE_WORDPRESS_URL` | URL publique du WordPress (`https://${WP_HOST}`) |
42
+ | `VITE_GRAPHQL_PATH` | Chemin GraphQL (`/graphql`) |
43
+ | `VITE_FEATURE_EDITORIAL/_ECOMMERCE/_AUTH` | Flags de modules |
44
+
45
+ ## 3. Coolify (runtime des apps déployées)
46
+
47
+ Dans chaque application Coolify (webapp / WordPress), onglet *Environment Variables*.
48
+ Ce sont les variables **runtime** (lues à l'exécution), distinctes des `VITE_*`
49
+ qui, elles, sont figées au build.
50
+
51
+ **App WordPress** :
52
+
53
+ | Variable | Rôle |
54
+ |---|---|
55
+ | `WORDPRESS_DB_HOST/_NAME/_USER/_PASSWORD` | Connexion DB |
56
+ | `WP_HOME`, `WP_SITEURL` | `https://${WP_HOST}` |
57
+ | `FRONTEND_URL` | `https://${APP_HOST}` (redirection headless) |
58
+ | `WP_REDIS_HOST`, `WP_REDIS_PASSWORD` | Cache objet Redis |
59
+ | `WP_ENVIRONMENT_TYPE`, `WP_DEBUG` | Environnement |
60
+
61
+ > ⚠️ NE PAS définir `WORDPRESS_CONFIG_EXTRA` ni `WORDPRESS_DEBUG` côté Coolify :
62
+ > la config est bakée dans l'image base (`wp-reactor-config.php`). Utiliser `WP_DEBUG`.
63
+
64
+ **App Webapp** :
65
+
66
+ | Variable | Rôle |
67
+ |---|---|
68
+ | `WORDPRESS_INTERNAL_URL` | URL interne du WP en SSR (`http://__PROJECT_NAME__-wordpress-internal`) |
69
+ | `SESSION_SECRET` | Doit matcher celui du build |
70
+
71
+ ## TLS / routing
72
+
73
+ Les **labels Traefik** (host, TLS, cache, compression) ne sont pas des variables
74
+ d'env : ils se collent dans l'UI Coolify → voir [`traefik-labels.md`](./traefik-labels.md).
@@ -0,0 +1,78 @@
1
+ # Labels Traefik (à recopier dans Coolify)
2
+
3
+ La webapp **et** WordPress sont déployées comme **applications Coolify autonomes**
4
+ (images GHCR construites par `.github/workflows/deploy.yml`). Aucun runtime n'est
5
+ dans un compose de prod — les labels Traefik ci-dessous se configurent
6
+ **manuellement dans l'UI Coolify** de chaque application (onglet *Labels*).
7
+
8
+ - `${APP_HOST}` = host du front (ex. `__PROJECT_NAME__.example.com`)
9
+ - `${WP_HOST}` = host dédié WordPress (ex. `wp.__PROJECT_NAME__.example.com`)
10
+
11
+ > ⚠️ L'app WordPress doit aussi exposer l'**alias réseau** `__PROJECT_NAME__-wordpress-internal`
12
+ > sur le réseau `coolify` (Coolify → *Network Aliases*), sinon la webapp ne résout
13
+ > plus `WORDPRESS_INTERNAL_URL` en SSR.
14
+
15
+ ## WordPress (`__PROJECT_NAME__-wordpress`)
16
+
17
+ ```
18
+ traefik.enable=true
19
+ traefik.docker.network=coolify
20
+ traefik.http.services.__PROJECT_NAME__-wordpress.loadbalancer.server.port=80
21
+
22
+ # Médias uploads : cache revalidable (remplaçables à la même URL, non fingerprintés)
23
+ traefik.http.middlewares.__PROJECT_NAME__-wp-uploads-cache.headers.customResponseHeaders.Cache-Control=public, max-age=3600, stale-while-revalidate=86400
24
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.rule=Host(`${WP_HOST}`) && PathPrefix(`/wp-content/uploads`)
25
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.entrypoints=https
26
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.service=__PROJECT_NAME__-wordpress
27
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.priority=95
28
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.middlewares=__PROJECT_NAME__-wp-uploads-cache
29
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.tls=true
30
+ traefik.http.routers.__PROJECT_NAME__-wp-uploads.tls.certresolver=letsencrypt
31
+
32
+ # Assets fingerprintés (cache long immutable) — prioritaire sur le catch-all WP
33
+ traefik.http.middlewares.__PROJECT_NAME__-wp-assets-cache.headers.customResponseHeaders.Cache-Control=public, max-age=31536000, immutable
34
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.rule=Host(`${WP_HOST}`) && (PathPrefix(`/wp-includes`) || PathPrefix(`/wp-content`))
35
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.entrypoints=https
36
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.service=__PROJECT_NAME__-wordpress
37
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.priority=90
38
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.middlewares=__PROJECT_NAME__-wp-assets-cache
39
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.tls=true
40
+ traefik.http.routers.__PROJECT_NAME__-wp-assets.tls.certresolver=letsencrypt
41
+
42
+ # SEO (robots.txt + sitemaps) servis sur le host FRONT → WordPress
43
+ traefik.http.routers.__PROJECT_NAME__-wp-seo.rule=Host(`${APP_HOST}`) && (Path(`/robots.txt`) || Path(`/sitemap_index.xml`) || PathRegexp(`-sitemap\.(xml|xsl)$`))
44
+ traefik.http.routers.__PROJECT_NAME__-wp-seo.entrypoints=https
45
+ traefik.http.routers.__PROJECT_NAME__-wp-seo.service=__PROJECT_NAME__-wordpress
46
+ traefik.http.routers.__PROJECT_NAME__-wp-seo.priority=100
47
+ traefik.http.routers.__PROJECT_NAME__-wp-seo.tls=true
48
+ traefik.http.routers.__PROJECT_NAME__-wp-seo.tls.certresolver=letsencrypt
49
+
50
+ # Catch-all WordPress (admin, /graphql, /wp-json…) sur son host dédié
51
+ traefik.http.routers.__PROJECT_NAME__-wordpress.rule=Host(`${WP_HOST}`)
52
+ traefik.http.routers.__PROJECT_NAME__-wordpress.entrypoints=https
53
+ traefik.http.routers.__PROJECT_NAME__-wordpress.service=__PROJECT_NAME__-wordpress
54
+ traefik.http.routers.__PROJECT_NAME__-wordpress.priority=10
55
+ traefik.http.routers.__PROJECT_NAME__-wordpress.tls=true
56
+ traefik.http.routers.__PROJECT_NAME__-wordpress.tls.certresolver=letsencrypt
57
+ ```
58
+
59
+ ## Webapp (`__PROJECT_NAME__-webapp`)
60
+
61
+ ```
62
+ traefik.enable=true
63
+ traefik.docker.network=coolify
64
+ traefik.http.services.__PROJECT_NAME__-webapp.loadbalancer.server.port=3000
65
+
66
+ # Compression
67
+ traefik.http.middlewares.__PROJECT_NAME__-webapp-compress.compress=true
68
+ traefik.http.middlewares.__PROJECT_NAME__-webapp-compress.compress.encodings=br,gzip
69
+ traefik.http.middlewares.__PROJECT_NAME__-webapp-compress.compress.defaultEncoding=gzip
70
+
71
+ # Front (catch-all, priorité basse pour laisser passer les routes WP/SEO ci-dessus)
72
+ traefik.http.routers.__PROJECT_NAME__-webapp.rule=Host(`${APP_HOST}`)
73
+ traefik.http.routers.__PROJECT_NAME__-webapp.entrypoints=https
74
+ traefik.http.routers.__PROJECT_NAME__-webapp.priority=1
75
+ traefik.http.routers.__PROJECT_NAME__-webapp.middlewares=__PROJECT_NAME__-webapp-compress
76
+ traefik.http.routers.__PROJECT_NAME__-webapp.tls=true
77
+ traefik.http.routers.__PROJECT_NAME__-webapp.tls.certresolver=letsencrypt
78
+ ```
@@ -8,7 +8,11 @@
8
8
  "build": "turbo run build",
9
9
  "check-types": "turbo run check-types",
10
10
  "lint": "turbo run lint",
11
- "gen:block": "gen-block"
11
+ "gen:block": "gen-block",
12
+ "docker:up": "docker compose -f docker-compose.dev.yml up -d",
13
+ "docker:bootstrap": "docker compose -f docker-compose.dev.yml --profile tools run --rm wp-bootstrap",
14
+ "docker:down": "docker compose -f docker-compose.dev.yml down",
15
+ "docker:reset": "docker compose -f docker-compose.dev.yml down -v"
12
16
  },
13
17
  "devDependencies": {
14
18
  "@wp-reactor/cli": "^0.1.0",
package/dist/index.d.ts DELETED
@@ -1,18 +0,0 @@
1
- declare const TEMPLATES_DIR: string;
2
- interface ScaffoldOptions {
3
- /** Nom kebab du projet (ex. "client-2") → noms de packages, dossiers, thème. */
4
- projectName: string;
5
- /** Dossier cible (créé). */
6
- targetDir: string;
7
- /** Namespace des blocs gen-block. Défaut : projectName sans tirets. */
8
- namespace?: string;
9
- /** Override du dossier template (tests). */
10
- templatesDir?: string;
11
- }
12
- interface ScaffoldResult {
13
- targetDir: string;
14
- written: string[];
15
- }
16
- declare function scaffold(opts: ScaffoldOptions): ScaffoldResult;
17
-
18
- export { type ScaffoldOptions, type ScaffoldResult, TEMPLATES_DIR, scaffold };
package/dist/index.js DELETED
@@ -1,62 +0,0 @@
1
- // src/index.ts
2
- import {
3
- mkdirSync,
4
- readFileSync,
5
- readdirSync,
6
- statSync,
7
- writeFileSync
8
- } from "fs";
9
- import { dirname, join, resolve } from "path";
10
- import { fileURLToPath } from "url";
11
- var HERE = dirname(fileURLToPath(import.meta.url));
12
- var TEMPLATES_DIR = resolve(HERE, "../templates/client");
13
- function titleCase(kebab) {
14
- return kebab.split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
15
- }
16
- function applyTokens(input, tokens) {
17
- let out = input;
18
- for (const [key, value] of Object.entries(tokens)) {
19
- out = out.split(key).join(value);
20
- }
21
- return out;
22
- }
23
- var DOTFILES = {
24
- gitignore: ".gitignore",
25
- npmrc: ".npmrc"
26
- };
27
- function copyTemplateTree(srcDir, destDir, tokens, written) {
28
- for (const entry of readdirSync(srcDir)) {
29
- const srcPath = join(srcDir, entry);
30
- const destName = DOTFILES[entry] ?? applyTokens(entry, tokens);
31
- const destPath = join(destDir, destName);
32
- if (statSync(srcPath).isDirectory()) {
33
- mkdirSync(destPath, { recursive: true });
34
- copyTemplateTree(srcPath, destPath, tokens, written);
35
- } else {
36
- const content = applyTokens(readFileSync(srcPath, "utf8"), tokens);
37
- mkdirSync(dirname(destPath), { recursive: true });
38
- writeFileSync(destPath, content);
39
- written.push(destPath);
40
- }
41
- }
42
- }
43
- function scaffold(opts) {
44
- const projectName = opts.projectName;
45
- const namespace = opts.namespace ?? projectName.replace(/[-_]/g, "");
46
- const templatesDir = opts.templatesDir ?? TEMPLATES_DIR;
47
- const targetDir = resolve(opts.targetDir);
48
- const tokens = {
49
- __PROJECT_NAME__: projectName,
50
- __PROJECT_TITLE__: titleCase(projectName),
51
- __BLOCK_NAMESPACE__: namespace
52
- };
53
- const written = [];
54
- mkdirSync(targetDir, { recursive: true });
55
- copyTemplateTree(templatesDir, targetDir, tokens, written);
56
- return { targetDir, written };
57
- }
58
- export {
59
- TEMPLATES_DIR,
60
- scaffold
61
- };
62
- //# sourceMappingURL=index.js.map
package/dist/index.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// create-wp-reactor — scaffolder de repo client mince.\n//\n// Émet un monorepo client « 2 mondes » depuis un template BUNDLÉ (npx-friendly :\n// aucun accès au monorepo framework au runtime) :\n// • apps/webapp ← coque (starter synchronisé dans templates/, voir\n// scripts/sync-template.mjs)\n// • apps/wordpress ← thème ENFANT\n// • .github, docker, wp-reactor.config.json, workspace root\n//\n// Le runtime ne fait que copier templates/client/ en substituant les tokens.\n\nimport {\n\tmkdirSync,\n\treadFileSync,\n\treaddirSync,\n\tstatSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst HERE = dirname(fileURLToPath(import.meta.url));\n// src/ (dev via tsx) comme dist/ (build) sont à 1 niveau du package.\nconst TEMPLATES_DIR = resolve(HERE, \"../templates/client\");\n\nexport interface ScaffoldOptions {\n\t/** Nom kebab du projet (ex. \"client-2\") → noms de packages, dossiers, thème. */\n\tprojectName: string;\n\t/** Dossier cible (créé). */\n\ttargetDir: string;\n\t/** Namespace des blocs gen-block. Défaut : projectName sans tirets. */\n\tnamespace?: string;\n\t/** Override du dossier template (tests). */\n\ttemplatesDir?: string;\n}\n\nexport interface ScaffoldResult {\n\ttargetDir: string;\n\twritten: string[];\n}\n\nfunction titleCase(kebab: string): string {\n\treturn kebab\n\t\t.split(/[-_]/)\n\t\t.filter(Boolean)\n\t\t.map((w) => w[0].toUpperCase() + w.slice(1))\n\t\t.join(\" \");\n}\n\nfunction applyTokens(input: string, tokens: Record<string, string>): string {\n\tlet out = input;\n\tfor (const [key, value] of Object.entries(tokens)) {\n\t\tout = out.split(key).join(value);\n\t}\n\treturn out;\n}\n\n// npm strippe `.gitignore` et `.npmrc` des tarballs publiés : on les stocke\n// sans point dans le template et on les re-dotte à l'écriture.\nconst DOTFILES: Record<string, string> = {\n\tgitignore: \".gitignore\",\n\tnpmrc: \".npmrc\",\n};\n\n/** Copie récursive du template en substituant les tokens dans chemins + contenu. */\nfunction copyTemplateTree(\n\tsrcDir: string,\n\tdestDir: string,\n\ttokens: Record<string, string>,\n\twritten: string[],\n): void {\n\tfor (const entry of readdirSync(srcDir)) {\n\t\tconst srcPath = join(srcDir, entry);\n\t\tconst destName = DOTFILES[entry] ?? applyTokens(entry, tokens);\n\t\tconst destPath = join(destDir, destName);\n\t\tif (statSync(srcPath).isDirectory()) {\n\t\t\tmkdirSync(destPath, { recursive: true });\n\t\t\tcopyTemplateTree(srcPath, destPath, tokens, written);\n\t\t} else {\n\t\t\tconst content = applyTokens(readFileSync(srcPath, \"utf8\"), tokens);\n\t\t\tmkdirSync(dirname(destPath), { recursive: true });\n\t\t\twriteFileSync(destPath, content);\n\t\t\twritten.push(destPath);\n\t\t}\n\t}\n}\n\nexport function scaffold(opts: ScaffoldOptions): ScaffoldResult {\n\tconst projectName = opts.projectName;\n\tconst namespace = opts.namespace ?? projectName.replace(/[-_]/g, \"\");\n\tconst templatesDir = opts.templatesDir ?? TEMPLATES_DIR;\n\tconst targetDir = resolve(opts.targetDir);\n\tconst tokens: Record<string, string> = {\n\t\t__PROJECT_NAME__: projectName,\n\t\t__PROJECT_TITLE__: titleCase(projectName),\n\t\t__BLOCK_NAMESPACE__: namespace,\n\t};\n\tconst written: string[] = [];\n\n\tmkdirSync(targetDir, { recursive: true });\n\tcopyTemplateTree(templatesDir, targetDir, tokens, written);\n\n\treturn { targetDir, written };\n}\n\nexport { TEMPLATES_DIR };\n"],"mappings":";AAWA;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AACP,SAAS,SAAS,MAAM,eAAe;AACvC,SAAS,qBAAqB;AAE9B,IAAM,OAAO,QAAQ,cAAc,YAAY,GAAG,CAAC;AAEnD,IAAM,gBAAgB,QAAQ,MAAM,qBAAqB;AAkBzD,SAAS,UAAU,OAAuB;AACzC,SAAO,MACL,MAAM,MAAM,EACZ,OAAO,OAAO,EACd,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,YAAY,IAAI,EAAE,MAAM,CAAC,CAAC,EAC1C,KAAK,GAAG;AACX;AAEA,SAAS,YAAY,OAAe,QAAwC;AAC3E,MAAI,MAAM;AACV,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AAClD,UAAM,IAAI,MAAM,GAAG,EAAE,KAAK,KAAK;AAAA,EAChC;AACA,SAAO;AACR;AAIA,IAAM,WAAmC;AAAA,EACxC,WAAW;AAAA,EACX,OAAO;AACR;AAGA,SAAS,iBACR,QACA,SACA,QACA,SACO;AACP,aAAW,SAAS,YAAY,MAAM,GAAG;AACxC,UAAM,UAAU,KAAK,QAAQ,KAAK;AAClC,UAAM,WAAW,SAAS,KAAK,KAAK,YAAY,OAAO,MAAM;AAC7D,UAAM,WAAW,KAAK,SAAS,QAAQ;AACvC,QAAI,SAAS,OAAO,EAAE,YAAY,GAAG;AACpC,gBAAU,UAAU,EAAE,WAAW,KAAK,CAAC;AACvC,uBAAiB,SAAS,UAAU,QAAQ,OAAO;AAAA,IACpD,OAAO;AACN,YAAM,UAAU,YAAY,aAAa,SAAS,MAAM,GAAG,MAAM;AACjE,gBAAU,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;AAChD,oBAAc,UAAU,OAAO;AAC/B,cAAQ,KAAK,QAAQ;AAAA,IACtB;AAAA,EACD;AACD;AAEO,SAAS,SAAS,MAAuC;AAC/D,QAAM,cAAc,KAAK;AACzB,QAAM,YAAY,KAAK,aAAa,YAAY,QAAQ,SAAS,EAAE;AACnE,QAAM,eAAe,KAAK,gBAAgB;AAC1C,QAAM,YAAY,QAAQ,KAAK,SAAS;AACxC,QAAM,SAAiC;AAAA,IACtC,kBAAkB;AAAA,IAClB,mBAAmB,UAAU,WAAW;AAAA,IACxC,qBAAqB;AAAA,EACtB;AACA,QAAM,UAAoB,CAAC;AAE3B,YAAU,WAAW,EAAE,WAAW,KAAK,CAAC;AACxC,mBAAiB,cAAc,WAAW,QAAQ,OAAO;AAEzD,SAAO,EAAE,WAAW,QAAQ;AAC7B;","names":[]}