@fraza/start 0.0.0-stage → 0.3.4

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -2
  4. package/THIRD-PARTY-NOTICES.md +11 -0
  5. package/bin/agents-block.md +61 -0
  6. package/bin/cli.js +197 -0
  7. package/bin/project.js +281 -0
  8. package/bin/version.js +2 -0
  9. package/changelog.json +577 -0
  10. package/package.json +42 -4
  11. package/templates/app/(admin)/admin/[...admin]/layout.js +3 -0
  12. package/templates/app/(admin)/admin/[...admin]/loading.js +3 -0
  13. package/templates/app/(admin)/admin/[...admin]/page.js +1 -0
  14. package/templates/app/(admin)/admin/error.js +4 -0
  15. package/templates/app/(admin)/admin/layout.js +17 -0
  16. package/templates/app/(admin)/admin/not-found.js +2 -0
  17. package/templates/app/(admin)/admin/page.js +5 -0
  18. package/templates/app/(auth)/auth/[...auth]/page.js +1 -0
  19. package/templates/app/(auth)/auth/page.js +5 -0
  20. package/templates/app/(public)/[...slug]/page.js +29 -0
  21. package/templates/app/(public)/error.js +6 -0
  22. package/templates/app/(public)/layout.js +17 -0
  23. package/templates/app/(public)/not-found.js +20 -0
  24. package/templates/app/(public)/page.js +248 -0
  25. package/templates/app/.fraza/blocks.client.js +8 -0
  26. package/templates/app/.fraza/blocks.server.js +4 -0
  27. package/templates/app/.fraza/modules.client.js +6 -0
  28. package/templates/app/.fraza/modules.generated.js +3 -0
  29. package/templates/app/.fraza/public-pages.server.js +4 -0
  30. package/templates/app/.fraza/sources.css +2 -0
  31. package/templates/app/.fraza/templates.server.js +5 -0
  32. package/templates/app/global-error.js +5 -0
  33. package/templates/app/icon.svg +1 -0
  34. package/templates/app/layout.js +31 -0
  35. package/templates/app/za/[...path]/not-found.js +6 -0
  36. package/templates/app/za/[...path]/page.js +8 -0
  37. package/templates/app/za/api/[...path]/route.js +1 -0
  38. package/templates/app/za/media/[filename]/route.js +1 -0
  39. package/templates/components/fraza/blocks/README.md +207 -0
  40. package/templates/components/fraza/blocks/accordion/fields.js +19 -0
  41. package/templates/components/fraza/blocks/accordion/render.js +47 -0
  42. package/templates/components/fraza/blocks/accordion/template.js +28 -0
  43. package/templates/components/fraza/blocks/callout/fields.js +15 -0
  44. package/templates/components/fraza/blocks/callout/render.js +28 -0
  45. package/templates/components/fraza/blocks/callout/template.js +37 -0
  46. package/templates/components/fraza/blocks/icon-list/fields.js +20 -0
  47. package/templates/components/fraza/blocks/icon-list/render.js +44 -0
  48. package/templates/components/fraza/blocks/icon-list/template.js +89 -0
  49. package/templates/components/fraza/blocks/three-cards/fields.js +19 -0
  50. package/templates/components/fraza/blocks/three-cards/render.js +33 -0
  51. package/templates/components/fraza/blocks/three-cards/template.js +57 -0
  52. package/templates/components/fraza/templates/README.md +293 -0
  53. package/templates/components/fraza/templates/content.js +271 -0
  54. package/templates/components/fraza/templates/default.js +18 -0
  55. package/templates/components/fraza/templates/post-blogue.js +97 -0
  56. package/templates/instrumentation.js +21 -0
  57. package/templates/jsconfig.json +9 -0
  58. package/templates/styles/globals.css +25 -0
  59. package/templates/theme.js +25 -0
@@ -0,0 +1,29 @@
1
+ // Catch-all CMS : toute route non interceptée par un fichier statique
2
+ // (admin, auth, /api/*, _next/*, et toute page écrite manuellement dans
3
+ // (public)/) est dispatchée au registre `@fraza/core/public-content`.
4
+ // Chaque module CMS s'enregistre avec une priorité : `module-posts` réserve
5
+ // les URLs `/<postType>/<slug>` (priorité 10), `module-pages` sert le reste
6
+ // (priorité 0, fallback hiérarchique).
7
+ //
8
+ // Pour ajouter un nouveau module CMS, il suffit qu'il appelle
9
+ // `registerPublicContentResolver` à son boot, sans toucher au starter.
10
+ // Pour fournir un template custom (avec ton branding), dépose un fichier
11
+ // dans `components/fraza/templates/` puis lance `npx fraza-modules sync`.
12
+
13
+ import { notFound } from 'next/navigation';
14
+ import { resolvePublicContent, resolvePublicMetadata } from '@fraza/core/public-content';
15
+
16
+ // `[...slug]` n'est pas optionnel : `/` est servi par `(public)/page.js`, et
17
+ // `slug` arrive donc toujours sous forme d'un tableau non vide.
18
+ export default async function PublicCatchAll({ params }) {
19
+ const { slug } = await params;
20
+ const match = await resolvePublicContent(slug);
21
+ if (!match) notFound();
22
+ const { Component, props } = match;
23
+ return <Component {...props} />;
24
+ }
25
+
26
+ export async function generateMetadata({ params }) {
27
+ const { slug } = await params;
28
+ return (await resolvePublicMetadata(slug)) || {};
29
+ }
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+
3
+ // Erreur au rendu d'une page du site public. Comme la page 404, c'est un
4
+ // gabarit : remplacer le réexport par un composant du projet pour lui donner
5
+ // l'allure du site.
6
+ export { default } from '@fraza/core/shared/public/error';
@@ -0,0 +1,17 @@
1
+ import { getPublicBrand } from "@fraza/core/shared/config";
2
+
3
+ // Titre par défaut des pages publiques : le nom du site, `FRAZA_NAME`. Une page
4
+ // qui déclare son propre titre le remplace. Sans `FRAZA_NAME`, aucun titre
5
+ // n'est inventé : un nom par défaut signerait le site de quelqu'un d'autre.
6
+ export function generateMetadata() {
7
+ const { name } = getPublicBrand();
8
+ return name ? { title: name } : {};
9
+ }
10
+
11
+ export default function layout({ children }) {
12
+ return (
13
+ <>
14
+ {children}
15
+ </>
16
+ );
17
+ }
@@ -0,0 +1,20 @@
1
+ import Button from "@fraza/core/shared/components/Button";
2
+
3
+ // Page 404 du site public. Comme la page d'accueil, c'est un gabarit : elle se
4
+ // remplace dès que le site a sa propre allure.
5
+ export default function NotFound() {
6
+ return (
7
+ <main className="flex min-h-screen items-center justify-center bg-canvas px-4 font-ui text-fg">
8
+ <div className="flex max-w-sm flex-col items-center gap-1 text-center">
9
+ <p className="font-code text-[12.5px] text-fg-subtle">404</p>
10
+ <h1 className="text-2xl font-bold tracking-[-0.03em]">Page introuvable</h1>
11
+ <p className="text-[13px] leading-relaxed text-fg-muted">
12
+ Cette page n'existe pas ou n'est plus en ligne.
13
+ </p>
14
+ <Button href="/" variant="link" className="mt-3">
15
+ Retour à l'accueil →
16
+ </Button>
17
+ </div>
18
+ </main>
19
+ );
20
+ }
@@ -0,0 +1,248 @@
1
+ import { connection } from "next/server";
2
+ import Button from "@fraza/core/shared/components/Button";
3
+ import FrazaLogo from "@fraza/core/shared/components/FrazaLogo";
4
+ import { tableExists } from "@fraza/core/database";
5
+ import { countUsers } from "@fraza/core/users";
6
+
7
+ // Point de départ du site public. Cette page est un gabarit : elle se remplace
8
+ // entièrement dès que le site prend forme. Elle ne déclare pas de métadonnées :
9
+ // son titre est celui du layout public, le nom du site (`FRAZA_NAME`).
10
+ //
11
+ // Elle suit la mise en route du projet : chaque étape est lue à chaque
12
+ // chargement (variables, tables, premier compte), et seule la première étape
13
+ // non faite est détaillée.
14
+
15
+ const files = [
16
+ "app/(public)/page.js",
17
+ "app/(public)/layout.js",
18
+ "components/fraza/templates/",
19
+ "components/fraza/blocks/",
20
+ ];
21
+
22
+ // Code en ligne : ce qui se copie (commande, fichier, chemin) passe en mono.
23
+ const chip = "rounded-item bg-surface-muted px-2 py-0.5 font-code text-xs text-fg-secondary";
24
+
25
+ /**
26
+ * État de la mise en route, lu à chaque requête. La base n'est interrogée que
27
+ * si ses variables sont posées : sans elles, le pool lèverait une erreur.
28
+ */
29
+ async function readSetup() {
30
+ const env = {
31
+ database: Boolean(process.env.FRAZA_DATABASE_URL),
32
+ encryptionKey: Boolean(process.env.FRAZA_ENCRYPTION_KEY),
33
+ };
34
+ const state = { env, envReady: env.database && env.encryptionKey, tables: false, users: 0, unreachable: false };
35
+ if (!state.envReady) return state;
36
+
37
+ try {
38
+ state.tables = await tableExists("fraza_auth_users");
39
+ if (state.tables) state.users = await countUsers();
40
+ } catch {
41
+ state.unreachable = true;
42
+ }
43
+ return state;
44
+ }
45
+
46
+ function StepMarker({ status, number }) {
47
+ if (status === "done") {
48
+ return (
49
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-success-soft text-success">
50
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
51
+ <path d="M5 12.5l4.5 4.5L19 7.5" />
52
+ </svg>
53
+ </span>
54
+ );
55
+ }
56
+ if (status === "current") {
57
+ return (
58
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-accent text-[11px] font-semibold text-accent">
59
+ {number}
60
+ </span>
61
+ );
62
+ }
63
+ return (
64
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-line-strong text-[11px] font-medium text-fg-muted">
65
+ {number}
66
+ </span>
67
+ );
68
+ }
69
+
70
+ function EnvRow({ name, filled }) {
71
+ return (
72
+ <div className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2 last:border-b-0">
73
+ <span className="font-code text-[12.5px] text-fg">{name}</span>
74
+ <span className={`text-xs font-medium ${filled ? "text-success" : "text-fg-muted"}`}>
75
+ {filled ? "Renseignée" : "Vide"}
76
+ </span>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export default async function Page() {
82
+ // Lue à chaque requête : l'état change pendant que le développeur avance.
83
+ await connection();
84
+ const setup = await readSetup();
85
+
86
+ const steps = [
87
+ { title: "Créer le projet", done: true },
88
+ {
89
+ title: "Remplir le .env.local",
90
+ done: setup.envReady,
91
+ detail: (
92
+ <>
93
+ <p className="text-[12.5px] leading-relaxed text-fg-secondary">
94
+ Les valeurs restent sur cette machine : git ignore le fichier.
95
+ </p>
96
+ <div className="rounded-control border border-line bg-surface">
97
+ <EnvRow name="FRAZA_DATABASE_URL" filled={setup.env.database} />
98
+ <EnvRow name="FRAZA_ENCRYPTION_KEY" filled={setup.env.encryptionKey} />
99
+ </div>
100
+ </>
101
+ ),
102
+ },
103
+ {
104
+ title: "Initialiser la base",
105
+ done: setup.tables,
106
+ hint: <code className="font-code text-[12.5px]">npx fraza-db init</code>,
107
+ detail: setup.unreachable ? (
108
+ <p className="text-[12.5px] leading-relaxed text-fg-secondary">
109
+ La base ne répond pas. Vérifiez <code className={chip}>FRAZA_DATABASE_URL</code>, puis
110
+ lancez <code className={chip}>npx fraza-db init</code>.
111
+ </p>
112
+ ) : (
113
+ <p className="text-[12.5px] leading-relaxed text-fg-secondary">
114
+ Crée les tables du core et des modules installés :{" "}
115
+ <code className={chip}>npx fraza-db init</code>
116
+ </p>
117
+ ),
118
+ },
119
+ {
120
+ title: "Créer le premier compte",
121
+ done: setup.users > 0,
122
+ hint: "Sur /auth. Le premier compte reçoit le rôle admin.",
123
+ detail: (
124
+ <p className="text-[12.5px] leading-relaxed text-fg-secondary">
125
+ Sur /auth. Le premier compte reçoit le rôle admin et ouvre l'administration.
126
+ </p>
127
+ ),
128
+ },
129
+ ];
130
+
131
+ const currentIndex = steps.findIndex((step) => !step.done);
132
+ const doneCount = steps.filter((step) => step.done).length;
133
+ const remaining = steps.length - doneCount;
134
+ const ready = currentIndex === -1;
135
+
136
+ return (
137
+ <main className="flex min-h-screen items-center justify-center bg-canvas px-6 py-18 font-ui text-fg">
138
+ <div className="flex w-full max-w-[1040px] flex-wrap items-center gap-x-18 gap-y-12">
139
+
140
+ <section className="flex flex-[1_1_360px] flex-col gap-6">
141
+ <FrazaLogo className="h-5 w-auto self-start text-fg" />
142
+
143
+ <div className="flex flex-col gap-2.5">
144
+ {/* Titre d'écran de la charte, comme les pages publiques des modules. */}
145
+ <h1 className="text-[32px] leading-tight font-bold tracking-[-0.035em]">
146
+ Votre projet est prêt.
147
+ </h1>
148
+ <p className="text-sm leading-relaxed text-fg-muted">
149
+ {ready
150
+ ? "Tout est en place. Le site public part de cette page : remplacez-la quand il prend forme."
151
+ : `Encore ${remaining} ${remaining > 1 ? "étapes" : "étape"} avant d'ouvrir l'administration. Cette page se met à jour à mesure que vous avancez.`}
152
+ </p>
153
+ </div>
154
+
155
+ <div className="flex flex-col gap-2.5 border-t border-line pt-5">
156
+ <h2 className="text-[12.5px] font-semibold">Ensuite, le site public</h2>
157
+ <p className="text-[12.5px] leading-relaxed text-fg-muted">
158
+ Cette page est un gabarit. Remplacez-la quand le site prend forme.
159
+ </p>
160
+ <ul className="flex flex-wrap gap-1.5">
161
+ {files.map((file) => (
162
+ <li key={file}>
163
+ <code className={chip}>{file}</code>
164
+ </li>
165
+ ))}
166
+ </ul>
167
+ </div>
168
+
169
+ <p className="text-xs text-fg-muted">
170
+ Livrée par @fraza/start ·{" "}
171
+ <Button href="https://docs.fraza.ca" target="_blank" variant="link" size="sm">
172
+ docs.fraza.ca
173
+ </Button>
174
+ </p>
175
+ </section>
176
+
177
+ <section className="min-w-0 flex-[1_1_460px] overflow-hidden rounded-card border border-line bg-surface">
178
+ <div className="flex items-center gap-3 px-6 py-4">
179
+ <h2 className="text-[15px] font-semibold">Mise en route</h2>
180
+ <div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-muted">
181
+ <div
182
+ className="h-full rounded-full bg-accent"
183
+ style={{ width: `${(doneCount / steps.length) * 100}%` }}
184
+ />
185
+ </div>
186
+ <span className="text-xs text-fg-muted tabular-nums">
187
+ {doneCount} sur {steps.length}
188
+ </span>
189
+ </div>
190
+
191
+ <ol>
192
+ {steps.map((step, index) => {
193
+ const status = step.done ? "done" : index === currentIndex ? "current" : "todo";
194
+ return (
195
+ <li
196
+ key={step.title}
197
+ aria-current={status === "current" ? "step" : undefined}
198
+ className={`flex gap-3.5 border-t border-line-soft px-6 ${status === "current" ? "bg-accent-soft py-4" : "py-3.5"}`}
199
+ >
200
+ <StepMarker status={status} number={index + 1} />
201
+ <div className="flex min-w-0 flex-1 flex-col gap-2.5">
202
+ <div className="flex flex-col gap-0.5">
203
+ <span
204
+ className={`text-[13.5px] ${status === "current" ? "font-semibold" : "font-medium"} ${status === "done" ? "text-fg-muted" : "text-fg"}`}
205
+ >
206
+ {step.title}
207
+ </span>
208
+ {status === "todo" && step.hint && (
209
+ <span className="text-[12.5px] text-fg-muted">{step.hint}</span>
210
+ )}
211
+ </div>
212
+ {status === "current" && step.detail}
213
+ </div>
214
+ </li>
215
+ );
216
+ })}
217
+ </ol>
218
+
219
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t border-line px-6 py-3.5">
220
+ <Button
221
+ href="https://docs.fraza.ca/docs/demarrage/installation"
222
+ target="_blank"
223
+ variant="link"
224
+ size="sm"
225
+ >
226
+ Guide d'installation ↗
227
+ </Button>
228
+ <div className="flex gap-2">
229
+ {!ready && (
230
+ <Button href="/" variant="secondary">
231
+ Revérifier
232
+ </Button>
233
+ )}
234
+ {ready ? (
235
+ <Button href="/admin">Ouvrir l'administration</Button>
236
+ ) : currentIndex === steps.length - 1 ? (
237
+ <Button href="/auth">Créer le premier compte</Button>
238
+ ) : (
239
+ <Button disabled>Ouvrir l'administration</Button>
240
+ )}
241
+ </div>
242
+ </div>
243
+ </section>
244
+
245
+ </div>
246
+ </main>
247
+ );
248
+ }
@@ -0,0 +1,8 @@
1
+ // AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main.
2
+ 'use client';
3
+
4
+ // Aucun block custom détecté dans `components/fraza/blocks/`.
5
+
6
+ export default function FrazaBlocksClient() {
7
+ return null;
8
+ }
@@ -0,0 +1,4 @@
1
+ // AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main.
2
+
3
+ // Aucun block custom détecté dans `components/fraza/blocks/`.
4
+ export {};
@@ -0,0 +1,6 @@
1
+ // AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main.
2
+ 'use client';
3
+
4
+ export default function FrazaModulesClient() {
5
+ return null;
6
+ }
@@ -0,0 +1,3 @@
1
+ // AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main.
2
+
3
+ export const modules = [];
@@ -0,0 +1,4 @@
1
+ // AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main.
2
+
3
+ // Aucun module installé n'expose de sous-entrée `./public-page`.
4
+ export const publicPages = [];
@@ -0,0 +1,2 @@
1
+ /* AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main. */
2
+ /* Fichiers compilés des modules activés, scannés par le Tailwind du projet. */
@@ -0,0 +1,5 @@
1
+ // AUTO-GÉNÉRÉ par `npx fraza-modules sync` — ne pas modifier à la main.
2
+
3
+ // Aucun template détecté dans `components/fraza/templates/`.
4
+
5
+ export {};
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+
3
+ // Dernier recours, quand le layout racine lui-même échoue. Le composant du core
4
+ // rend la page entière, `<html>` et `<body>` compris, comme Next l'exige ici.
5
+ export { default } from '@fraza/core/shared/global-error';
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="11" fill="#0a0a0a"/><g transform="translate(9 9) scale(0.625)" fill="#fafafa"><path d="M17 9H40Q46 9 43.5 14.4L40 22H11Z"/><path d="M8 26H37L31 39H8Q2 39 4.5 33.6Z"/></g></svg>
@@ -0,0 +1,31 @@
1
+ import "@styles/globals.css";
2
+ // Side-effect : enregistre les blocs personnalisés dans le registre RSC serveur
3
+ // (composants `render.js`). Importé ici, et pas seulement via
4
+ // instrumentation.js, pour que les `render.js` marqués `'use client'`
5
+ // traversent le compilateur RSC et soient référencés dans le React Client
6
+ // Manifest. Sans cet import, un bloc au render client échoue avec
7
+ // « Could not find the module … in the React Client Manifest » au rendu.
8
+ import './.fraza/blocks.server.js';
9
+ // Side-effect : enregistre les templates publics dans le registre RSC.
10
+ // Importé ici (et non uniquement via instrumentation.js) pour que les
11
+ // fichiers `components/fraza/templates/*.js` traversent le compilateur RSC
12
+ // et que leurs `next/link` / `next/image` soient correctement référencés
13
+ // dans le React Client Manifest.
14
+ import './.fraza/templates.server.js';
15
+ import { FrazaProvider } from '@fraza/core/features/provider';
16
+ import { ThemeStyle } from '@fraza/core/themes/server';
17
+
18
+ export default function RootLayout({ children }) {
19
+ return (
20
+ <html lang="fr" suppressHydrationWarning>
21
+ <head>
22
+ <ThemeStyle />
23
+ </head>
24
+ <body className="antialiased">
25
+ <FrazaProvider>
26
+ {children}
27
+ </FrazaProvider>
28
+ </body>
29
+ </html>
30
+ );
31
+ }
@@ -0,0 +1,6 @@
1
+ // 404 des pages publiques des modules (lien de facture expiré, formulaire
2
+ // inactif, réservation fermée). Cette route vit hors du groupe `(public)`,
3
+ // dont la page 404 ne s'applique donc pas ici : sans ce fichier, Next.js
4
+ // afficherait sa page par défaut, en anglais et sans le thème. On reprend la
5
+ // 404 du site public pour qu'il n'y en ait qu'une à personnaliser.
6
+ export { default } from '../../(public)/not-found.js';
@@ -0,0 +1,8 @@
1
+ // Side-effect : fait entrer les pages publiques des modules dans le graphe
2
+ // RSC de cette route, pour que leurs composants `'use client'` soient
3
+ // référencés dans le React Client Manifest. Sans cet import, une page publique
4
+ // de module échoue avec « Could not find the module … in the React Client
5
+ // Manifest ».
6
+ import '../../.fraza/public-pages.server.js';
7
+
8
+ export { default, generateMetadata } from '@fraza/core/public-pages/server';
@@ -0,0 +1 @@
1
+ export { GET, POST, PUT, DELETE, PATCH } from '@fraza/core/api/handler';
@@ -0,0 +1 @@
1
+ export { GET } from '@fraza/core/media-registry/file-route';
@@ -0,0 +1,207 @@
1
+ # Blocs personnalisés : `components/fraza/blocks/`
2
+
3
+ Ce dossier contient les **blocs personnalisés** que tu ajoutes au
4
+ `BlockEditor` du CMS. Un sous-dossier = un bloc. `npx fraza-modules sync` les
5
+ trouve et les rend disponibles dans le menu `/` de tous les éditeurs (pages
6
+ CMS, posts, etc.).
7
+
8
+ ## Anatomie d'un bloc
9
+
10
+ Un bloc est un dossier avec **3 fichiers** :
11
+
12
+ ```
13
+ components/fraza/blocks/<id>/
14
+ ├── fields.js # métadonnées statiques (lues sans React)
15
+ ├── template.js # composant d'édition, affiché dans l'administration
16
+ └── render.js # composant public, affiché sur le site
17
+ ```
18
+
19
+ ### `fields.js` : métadonnées
20
+
21
+ Exporte deux objets : `meta` (description du bloc pour le menu `/`) et
22
+ `defaults` (valeurs initiales d'une nouvelle instance).
23
+
24
+ ```js
25
+ export const meta = {
26
+ id: 'mon-bloc', // unique dans le projet, [a-z0-9-] uniquement
27
+ label: 'Mon bloc', // libellé affiché dans le menu /
28
+ icon: 'LayersIcon', // nom d'une icône de @fraza/icons
29
+ keywords: ['custom', 'demo'], // termes de recherche
30
+ category: 'Contenu', // facultatif, regroupement
31
+ };
32
+
33
+ export const defaults = {
34
+ title: '',
35
+ // toutes les valeurs initiales du bloc...
36
+ };
37
+ ```
38
+
39
+ > **Pas d'import React ni de JSX dans `fields.js`** : ce fichier est lu côté
40
+ > serveur et côté client au démarrage, pour valider et configurer le
41
+ > registre. Les imports lourds y sont inutiles et ralentissent le démarrage.
42
+
43
+ ### `template.js` : éditeur de l'administration
44
+
45
+ Composant React qui rend le bloc **dans l'éditeur**. Il doit porter
46
+ `'use client'`. Il reçoit `{ block, onChange, disabled }` :
47
+
48
+ - `block` : l'instance courante (fusionnée avec `defaults`) ;
49
+ - `onChange(patch)` : applique une modification partielle. Passe **seulement
50
+ les champs modifiés**, pas le bloc complet ;
51
+ - `disabled` : `true` en lecture seule (aperçu).
52
+
53
+ `@fraza/core/blocks-registry/fields-ui` fournit des champs prêts à l'emploi,
54
+ pour garder le même aspect que le reste de l'administration :
55
+
56
+ | Composant | Usage |
57
+ |---|---|
58
+ | `<TextField>` | champ texte |
59
+ | `<TextareaField>` | texte sur plusieurs lignes |
60
+ | `<NumberField>` | champ numérique |
61
+ | `<SelectField>` | menu déroulant |
62
+ | `<BooleanField>` | case à cocher |
63
+ | `<ColorField>` | sélecteur de couleur Fraza |
64
+ | `<IconField>` | sélecteur d'icône parmi la liste passée par `icons` (fenêtre avec recherche ; enregistre le nom de l'icône) |
65
+ | `<ImageField>` | sélecteur d'image (avec `MediaPicker` si module-medias est installé) |
66
+ | `<FrazaBlockShell>` | conteneur recommandé (cadre pointillé neutre) |
67
+
68
+ Tu peux aussi tout écrire à la main si ces champs ne suffisent pas : le seul
69
+ contrat est `default export = composant React`.
70
+
71
+ > **Style** : volontairement **neutre** (gris, noir, blanc, aucune classe
72
+ > propre au site). L'administration reste ainsi homogène, quel que soit le
73
+ > style du site public.
74
+
75
+ ### `render.js` : rendu public
76
+
77
+ Composant React qui rend le bloc **sur le site public**, à la visite. Il
78
+ reçoit `{ block }`.
79
+
80
+ - Il utilise les classes et les styles **du site** (Tailwind, jetons du
81
+ thème…).
82
+ - Il n'a pas de `onChange` : ce composant est en lecture seule.
83
+
84
+ #### Ancre de bloc (`#slug`)
85
+
86
+ Comme les blocs internes, un bloc personnalisé peut recevoir une **ancre**
87
+ depuis l'éditeur (menu d'actions du bloc ▸ **Ancre**). C'est un identifiant
88
+ stable, enregistré dans `block.anchor`, cible d'un lien interne `#slug`. À toi
89
+ de la **rendre** : pose `id={block.anchor || undefined}` sur l'élément racine
90
+ de ton `render.js`.
91
+
92
+ ```jsx
93
+ export default function CalloutRender({ block }) {
94
+ return <div id={block.anchor || undefined} className="…">{block.text}</div>;
95
+ }
96
+ ```
97
+
98
+ > On l'écrit **directement** plutôt qu'avec `anchorProps` du core :
99
+ > `@fraza/core/blocks-registry/render` est réservé au serveur, et ne
100
+ > s'importe donc pas dans un `render.js` marqué `'use client'`. L'écriture
101
+ > directe marche côté serveur comme côté client. Les quatre exemples fournis
102
+ > la suivent.
103
+
104
+ **Server component par défaut** (recommandé) : pas de directive en tête de
105
+ fichier. Le composant peut lire des données et profite du rendu serveur
106
+ complet, référencement compris.
107
+
108
+ #### Liens saisis dans un bloc
109
+
110
+ Un lien saisi dans un bloc (`href`) passe par `sanitizeUrl` de
111
+ `@fraza/core/blocks-registry/url`, sous-entrée neutre, utilisable côté serveur
112
+ comme côté client. Elle rend `''` pour une adresse au schéma refusé
113
+ (`javascript:`, `data:`…) : n'écris alors aucun lien. La liste d'icônes
114
+ fournie le fait.
115
+
116
+ #### Lire des données dans un rendu serveur
117
+
118
+ Un bloc qui affiche du contenu (posts, pages…) lit la donnée **directement
119
+ côté serveur**, par les fonctions `./public` du module concerné. **Jamais** par
120
+ un `fetch` HTTP vers `/za/api/...`. Le bloc tourne déjà dans le même
121
+ processus que l'API. Un appel HTTP vers soi-même repasse par Next, refait le
122
+ routage, interroge de nouveau la base et sérialise du JSON pour rien. En
123
+ production, il ressort en plus par le proxy inverse ou le pare-feu
124
+ applicatif, qui bloque souvent les requêtes de serveur à serveur : la section
125
+ reste vide, sans erreur.
126
+
127
+ ```js
128
+ // render.js (serveur) : lecture directe, sans fetch HTTP
129
+ import { getPublicPosts } from '@fraza/module-posts/public';
130
+
131
+ export default async function MonRender({ block }) {
132
+ const { posts } = await getPublicPosts(block.postType || 'blogue', { limit: 6 });
133
+ return (
134
+ <ul id={block.anchor || undefined}>
135
+ {posts.map((p) => <li key={p.id}>{p.title}</li>)}
136
+ </ul>
137
+ );
138
+ }
139
+ ```
140
+
141
+ Fonctions disponibles : `getPublicPosts` / `getPublicPostBySlug` /
142
+ `getPublicTaxonomyTerms` (`@fraza/module-posts/public`), `listPublicChildPages` /
143
+ `getPublishedPageBySegments` (`@fraza/module-pages/public`). Les routes HTTP
144
+ `/za/api/...` sont réservées au **navigateur** (fetch côté client, même
145
+ origine) et à l'administration.
146
+
147
+ **Rendu interactif** (bascule, accordéon, carrousel…) : deux options.
148
+
149
+ 1. **Rendu entièrement client** : mettre `'use client'` en tête de
150
+ `render.js` et utiliser `useState` / `useEffect`. Le manifeste serveur
151
+ importe alors le bloc comme référence client, et React franchit la
152
+ frontière automatiquement. Voir l'exemple `accordion/`.
153
+
154
+ > **Branchement requis** : `app/layout.js` doit importer
155
+ > `./.fraza/blocks.server.js` (à côté de `templates.server.js`) pour que les
156
+ > `render.js` clients passent par le compilateur RSC et soient référencés
157
+ > dans le React Client Manifest. Les nouveaux projets l'ont **déjà**. Sur un
158
+ > projet plus ancien, ajoute la ligne à la main, sinon l'erreur « Could not
159
+ > find the module … in the React Client Manifest » apparaît.
160
+ 2. **Îlot client dans un rendu serveur** : garder `render.js` en server
161
+ component (rendu serveur du contenu statique) et isoler la partie
162
+ interactive dans un fichier enfant `*.client.js`, importé par `render.js`.
163
+ La découverte ignore les fichiers autres que `fields.js`, `template.js` et
164
+ `render.js`, mais ils sont inclus normalement par l'import.
165
+
166
+ ```js
167
+ // render.js (serveur)
168
+ import Interactif from './Interactif.client.js';
169
+ export default function MonRender({ block }) {
170
+ return <div><h2>{block.title}</h2><Interactif data={block} /></div>;
171
+ }
172
+ ```
173
+
174
+ ## Cycle de vie
175
+
176
+ 1. Tu crées un dossier `components/fraza/blocks/mon-bloc/` avec les 3 fichiers.
177
+ 2. Tu lances `npx fraza-modules sync`, qui génère :
178
+ - `app/.fraza/blocks.client.js`, qui appelle `registerBlock({ type: 'custom:mon-bloc', Template: ..., ... })` ;
179
+ - `app/.fraza/blocks.server.js`, qui appelle `registerCustomBlock({ type: 'custom:mon-bloc', RenderComponent: ... })`.
180
+ 3. `app/(admin)/admin/layout.js` rend `<FrazaBlocksClient />` côté client, et `instrumentation.js` importe `blocks.server.js` côté serveur. Le registre de l'éditeur est rempli dans l'administration, celui du rendu au démarrage.
181
+ 4. Le bloc apparaît dans le menu `/` de chaque `BlockEditor`.
182
+ 5. Sur le site public, les blocs de type `custom:*` sont rendus par le `RenderComponent` correspondant.
183
+
184
+ ## Espace de noms
185
+
186
+ Tous les blocs personnalisés reçoivent le préfixe `custom:` (`mon-bloc` →
187
+ `custom:mon-bloc`). Il évite toute collision avec les blocs internes
188
+ (`paragraph`, `heading_1`, `image`, etc.), qui n'utilisent jamais `:`.
189
+
190
+ ## Dépannage
191
+
192
+ Un bloc manque dans le menu `/` ? Vérifier dans l'ordre :
193
+
194
+ 1. `npx fraza-modules sync` a-t-il été lancé ? La commande affiche `◦ block:<id>` pour chaque bloc trouvé.
195
+ 2. Le dossier contient-il bien `fields.js` ? Sans lui, le dossier est ignoré sans message.
196
+ 3. Le `meta.id` est-il valide ? Seulement `[a-z0-9-]`, et différent d'un bloc interne. Deux dossiers sous le même `meta.id` font échouer le démarrage, avec les deux dossiers nommés dans l'erreur.
197
+ 4. Y a-t-il une erreur d'import dans `app/.fraza/blocks.client.js` ? Regarder la console du navigateur.
198
+ 5. `app/(admin)/admin/layout.js` rend-il bien `<FrazaBlocksClient />` ? Sans lui, le manifeste n'est pas exécuté dans le navigateur.
199
+
200
+ ## Exemples fournis
201
+
202
+ - **`callout/`** : encadré avec un message et 4 variantes de couleur. Montre `<TextareaField>` et `<SelectField>`. Rendu **serveur** statique.
203
+ - **`three-cards/`** : rangée de 3 cartes avec image, titre et texte. Montre `<ImageField>` (avec `MediaPicker`) et une structure imbriquée modifiée par `patchCard()`. Rendu **serveur** statique.
204
+ - **`accordion/`** : en-tête cliquable qui révèle un contenu. Montre un **rendu public interactif** (`render.js` marqué `'use client'`, avec `useState`).
205
+ - **`icon-list/`** : liste verticale d'éléments (icône, texte, lien facultatif) avec un titre et une couleur d'icône commune. Montre `<IconField>` et une **liste dynamique** (ajout et retrait d'éléments). L'éditeur propose toutes les icônes de `@fraza/icons` ; le rendu **serveur** affiche l'icône choisie avec `<Icon name>` de `@fraza/core/shared/components/Icon`, qui ne charge qu'elle.
206
+
207
+ Tu peux les supprimer ou t'en inspirer librement.