@fraza/start 0.0.0-stage → 0.3.5

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 +186 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -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 +602 -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,293 @@
1
+ # Templates de rendu public : `components/fraza/templates/`
2
+
3
+ Ce dossier contient les **templates** qui enveloppent le rendu public d'une
4
+ page CMS ou d'un post (en-tête, pied de page, mise en page latérale, image de
5
+ marque), **et** le composant `<Content>` (`content.js`) qui rend le contenu des
6
+ blocs.
7
+
8
+ `npx fraza-modules sync` trouve chaque template et l'enregistre dans le
9
+ registre `@fraza/core/templates`.
10
+
11
+ Convention : **un fichier = un template**. Les sous-dossiers ne sont pas
12
+ parcourus.
13
+
14
+ ```
15
+ components/fraza/templates/
16
+ ├── content.js # composant <Content>, rendu des blocs (pas un template)
17
+ ├── default.js # template de page : kind: 'page'
18
+ ├── rose.js # template de page : kind: 'page'
19
+ └── post-blogue.js # template de post : kind: 'post', postType: 'blogue'
20
+ ```
21
+
22
+ > **Fichiers ignorés** : `content.{js,jsx}` (composant de rendu, pas un
23
+ > template), `README.md`, et tout fichier qui commence par `_` (code partagé,
24
+ > par exemple `_styles.js`).
25
+
26
+ ## Les deux familles de blocs
27
+
28
+ Un projet Fraza rend deux types de blocs sur la page publique :
29
+
30
+ 1. **Les blocs internes du core** : `paragraph`, `heading_1`…`heading_6`,
31
+ `bullet_item`, `numbered_item`, `checklist`, `quote`, `code`, `divider`,
32
+ `spacer`, `image`. Leur JSX et leur style sont dans **`content.js`**, dans
33
+ ce dossier. Ce fichier t'appartient entièrement.
34
+ 2. **Les blocs personnalisés du site**, déposés dans
35
+ `components/fraza/blocks/<id>/` avec leurs trois fichiers (`fields.js`,
36
+ `template.js`, `render.js`). Chacun rend lui-même son JSX dans son
37
+ `render.js`. `<Content>` les confie automatiquement à `CustomBlockRender`
38
+ du core : tu n'as **rien** à faire dans `content.js` pour eux.
39
+
40
+ `content.js` est donc le seul fichier à modifier pour personnaliser le rendu
41
+ des blocs **internes**. Les marks inline (gras, italique, lien, code,
42
+ couleur, surlignage) sont sérialisées par `renderInline` du core. Leur format
43
+ est contraint par l'éditeur : inutile de les réécrire.
44
+
45
+ ## Anatomie d'un template
46
+
47
+ Chaque fichier exporte deux choses :
48
+
49
+ ```js
50
+ // components/fraza/templates/rose.js
51
+ import Content from './content.js';
52
+
53
+ export const meta = {
54
+ id: 'rose', // unique pour un même (kind, postType?), [a-z0-9-]
55
+ label: 'Rose', // libellé affiché dans le Select de l'administration
56
+ kind: 'page', // 'page' | 'post'
57
+ // postType: 'blogue', // requis si kind === 'post'
58
+ };
59
+
60
+ export default function RoseTemplate({ page, blocks, url, breadcrumb }) {
61
+ // - kind='page' → reçoit { page, blocks, url, breadcrumb }
62
+ // - kind='post' → reçoit { post, blocks, url, breadcrumb }
63
+ // `blocks` : tableau de blocs JSON déjà enrichi (médias résolus).
64
+ // `url` : URL canonique du contenu courant.
65
+ // `breadcrumb` : fil d'Ariane de la racine au contenu courant (pages et
66
+ // posts) ; tableau d'objets { id, slug, title, url },
67
+ // longueur ≥ 1. « Accueil » n'est pas inclus : à ajouter
68
+ // dans le template.
69
+ return (
70
+ <main className="bg-pink-50 min-h-screen px-4 py-12">
71
+ {page?.title && <h1 className="text-5xl text-pink-700 font-bold">{page.title}</h1>}
72
+ <Content blocks={blocks} />
73
+ </main>
74
+ );
75
+ }
76
+ ```
77
+
78
+ Le template reçoit `blocks` en prop et le passe à `<Content blocks={blocks} />`.
79
+ Il ne reçoit pas de `children`.
80
+
81
+ ## Personnaliser le style des blocs internes (`content.js`)
82
+
83
+ `content.js` définit **un composant React par type de bloc**
84
+ (`Paragraph`, `Heading1`…`Heading6`, `Quote`, `CodeBlock`, `Divider`,
85
+ `ImageBlock`, `BulletList`, `NumberedList`, `Checklist`). Modifie librement
86
+ chaque composant : c'est ta référence visuelle.
87
+
88
+ Les composants fournis utilisent les jetons du thème (`text-fg`,
89
+ `border-line`, `bg-surface-muted`…) : ils suivent `theme.js` et le mode
90
+ sombre. Les exemples ci-dessous utilisent des couleurs Tailwind fixes pour
91
+ montrer qu'un site peut aussi s'en écarter.
92
+
93
+ **Exemple : paragraphes en rose dans tout le site**
94
+
95
+ ```js
96
+ // content.js
97
+ function Paragraph({ block }) {
98
+ return <p className="my-4 leading-7 text-pink-700">{renderInline(block.content)}</p>;
99
+ }
100
+ ```
101
+
102
+ **Exemple : citations encadrées avec une icône**
103
+
104
+ ```js
105
+ function Quote({ block }) {
106
+ return (
107
+ <blockquote className="my-6 flex gap-3 rounded-xl bg-amber-50 p-4">
108
+ <span className="text-2xl">✦</span>
109
+ <div className="italic text-amber-900">{renderInline(block.content)}</div>
110
+ </blockquote>
111
+ );
112
+ }
113
+ ```
114
+
115
+ **Exemple : bloc de code avec un bouton de copie.** Importe ton propre
116
+ `<CopyButton>` dans `content.js` et enveloppe le `<pre>`. La structure JSX
117
+ t'appartient.
118
+
119
+ ### Fonctions du core utilisables dans `content.js`
120
+
121
+ ```js
122
+ import {
123
+ renderInline, // (InlineNode[]) => JSX : sérialise les marks inline
124
+ groupListItems, // (Block[]) => Group[] : regroupe les listes consécutives
125
+ isCustomBlockType, // (type) => boolean
126
+ isInternalHref, // (href) => boolean : vrai pour `/foo` et `#anchor`
127
+ anchorProps, // (block) => { id } | null : ancre du bloc, à étaler sur la racine
128
+ resolveImageBlock, // (block) => { src, alt, caption } : image d'un bloc, liée à la médiathèque ou non
129
+ sanitizeUrl, // (href) => string : l'adresse si son schéma est admis, sinon ''
130
+ CustomBlockRender, // ({ block }) => JSX : délègue au render.js du bloc personnalisé
131
+ } from '@fraza/core/blocks-registry/render';
132
+ ```
133
+
134
+ ### Taille du paragraphe
135
+
136
+ Le bloc `paragraph` porte un champ `block.size` (`sm` / `md` / `lg`, `md` par
137
+ défaut), choisi dans l'éditeur par le menu d'actions ▸ **Taille**. Le
138
+ `Paragraph` fourni associe cette valeur à une taille Tailwind (`text-sm` /
139
+ `text-base` / `text-lg`). Si tu réécris `Paragraph`, lis `block.size` pour
140
+ respecter ce choix :
141
+
142
+ ```jsx
143
+ const SIZE = { sm: 'text-sm leading-6', md: 'text-base leading-7', lg: 'text-lg leading-8' };
144
+ function Paragraph({ block }) {
145
+ return <p {...anchorProps(block)} className={`my-4 ${SIZE[block.size] || SIZE.md} text-fg`}>{renderInline(block.content)}</p>;
146
+ }
147
+ ```
148
+
149
+ ### Ancre de bloc
150
+
151
+ Un bloc peut porter une **ancre** (`block.anchor`), définie dans l'éditeur par
152
+ le menu d'actions ▸ **Ancre**. C'est un identifiant stable qui devient
153
+ l'attribut `id` HTML du bloc, cible d'un lien interne `#slug`. Étale
154
+ `anchorProps(block)` sur l'élément racine de **chaque** composant de bloc pour
155
+ l'appliquer. Sans ancre, il ne fait rien :
156
+
157
+ ```jsx
158
+ function Heading2({ block }) {
159
+ return <h2 {...anchorProps(block)} className="…">{renderInline(block.content)}</h2>;
160
+ }
161
+ ```
162
+
163
+ Pour une liste, l'ancre est portée par chaque élément : étale `anchorProps(it)`
164
+ sur chaque `<li>`. Le `content.js` fourni le fait déjà partout.
165
+
166
+ ### Liens internes et externes
167
+
168
+ `renderInline` détecte si une mark `link` pointe vers une URL **interne**
169
+ (chemin qui commence par `/`, ou ancre `#section`). Il utilise alors `<Link>`
170
+ de Next (préchargement et navigation côté client). Les URL absolues
171
+ (`http(s)://`, `mailto:`, `tel:`, etc.), les URL sans protocole
172
+ (`//cdn.example.com`) et les liens « ouvrir dans un nouvel onglet » donnent un
173
+ `<a>` classique avec `rel="noopener noreferrer"`.
174
+
175
+ Tu n'as **rien à faire** pour les liens dans le texte : tout passe par
176
+ `renderInline`. Pour appliquer la même logique à tes propres liens (par
177
+ exemple dans `ImageBlock` ou un bloc personnalisé), importe `isInternalHref`
178
+ et `sanitizeUrl`. Le lien d'un bloc vient d'une saisie, d'un collage ou de
179
+ l'API : `sanitizeUrl` ne garde que `http`, `https`, `mailto`, `tel` et les
180
+ adresses relatives, et rend `''` pour le reste (`javascript:`, `data:`,
181
+ `vbscript:`…). React ne neutralise que `javascript:`. Sans adresse admise, ne
182
+ rends aucun lien, comme le font `ImageBlock` et la liste d'icônes fournis :
183
+
184
+ ```js
185
+ import Link from 'next/link';
186
+ import { isInternalHref, sanitizeUrl } from '@fraza/core/blocks-registry/render';
187
+
188
+ function MyLink({ href: raw, newTab, children }) {
189
+ const href = sanitizeUrl(raw);
190
+ if (!href) return children;
191
+ if (!newTab && isInternalHref(href)) {
192
+ return <Link href={href}>{children}</Link>;
193
+ }
194
+ return <a href={href} target={newTab ? '_blank' : undefined} rel={newTab ? 'noopener noreferrer' : undefined}>{children}</a>;
195
+ }
196
+ ```
197
+
198
+ ### Style ponctuel par zone (prop `overrides`)
199
+
200
+ Un template peut afficher **plusieurs zones de contenu** dont une ou deux
201
+ seulement doivent changer de style, par exemple une 2e zone en texte plus
202
+ petit. Pas besoin d'un second `content.js` : `<Content>` accepte une prop
203
+ `overrides` facultative. C'est un objet `{ clé → Composant }` qui remplace le
204
+ composant par défaut **pour cette zone seulement**. Les autres `<Content>` ne
205
+ changent pas.
206
+
207
+ Clés acceptées :
208
+
209
+ - **types de blocs** : `paragraph`, `heading_1`…`heading_6`, `quote`, `code`,
210
+ `divider`, `spacer`, `image`
211
+ - **groupes de liste** : `bullet_item`, `numbered_item`, `checklist`
212
+
213
+ Définis et exporte tes variantes dans `content.js`, puis passe-les depuis le
214
+ template :
215
+
216
+ ```js
217
+ // content.js : variante compacte du paragraphe et de la liste à puces
218
+ export function ParagraphSm({ block }) {
219
+ return <p {...anchorProps(block)} className="my-4 leading-7 text-sm text-fg-secondary">{renderInline(block.content)}</p>;
220
+ }
221
+ export function BulletListSm({ items }) {
222
+ return (
223
+ <ul className="my-4 list-disc pl-6 space-y-1">
224
+ {items.map((it) => <li key={it.id} {...anchorProps(it)} className="text-sm leading-7">{renderInline(it.content)}</li>)}
225
+ </ul>
226
+ );
227
+ }
228
+ ```
229
+
230
+ ```jsx
231
+ // mon-template.js
232
+ import Content, { ParagraphSm, BulletListSm } from './content.js';
233
+
234
+ // 1re zone : style par défaut
235
+ <Content blocks={content1} />
236
+ // 2e zone : paragraphes et listes en version compacte
237
+ <Content blocks={content2} overrides={{ paragraph: ParagraphSm, bullet_item: BulletListSm }} />
238
+ ```
239
+
240
+ Sans `overrides`, le rendu ne change pas. Cette prop convient à un écart
241
+ ponctuel. Pour deux rendus très différents, préfère deux fichiers de contenu
242
+ (section suivante).
243
+
244
+ ### Plusieurs `<Content>` selon le contexte
245
+
246
+ Un seul `content.js` n'est pas obligatoire. Si ton site veut un rendu sobre
247
+ pour les pages produit et un rendu plus typographique pour le blogue, crée par
248
+ exemple `_content-sobre.js` et `_content-article.js` (le préfixe `_` les
249
+ exclut des templates), puis importe celui qui convient dans chaque template.
250
+
251
+ ## Choix du template à l'édition
252
+
253
+ - **Pages** (`@fraza/module-pages`) : le formulaire d'édition propose un
254
+ `<Select>` Template parmi tous les templates de `kind: 'page'`. Le choix est
255
+ enregistré dans la colonne `template` de `fraza_pages_page`.
256
+ - **Posts** (`@fraza/module-posts`) : le formulaire d'édition propose un
257
+ `<Select>` Template parmi les templates de `kind: 'post'` dont le
258
+ `postType` est celui du post courant. Le choix est enregistré dans
259
+ `data.template` du JSONB.
260
+
261
+ ## Rendu public
262
+
263
+ Le projet expose une seule route attrape-tout, `app/(public)/[...slug]/page.js`,
264
+ qui parcourt le registre `@fraza/core/public-content`. Chaque module CMS y
265
+ enregistre son resolver : aucune route à écrire à la main.
266
+
267
+ - `/<slug>` ou `/<parent>/<slug>` → page CMS
268
+ - `/<postType>/<slug>` → post (par exemple `/blogue/mon-article`)
269
+
270
+ Le module résout le contenu, choisit le template avec
271
+ `getTemplate({ kind, id, postType })`, puis rend
272
+ `<Template page|post={…} blocks={…} />`. Le template appelle ensuite
273
+ `<Content blocks={blocks} />` pour rendre le contenu.
274
+
275
+ ## Synchronisation
276
+
277
+ Lance `npx fraza-modules sync` après avoir ajouté ou supprimé un fichier de
278
+ template. La commande régénère `app/.fraza/templates.server.js`, importé pour
279
+ ses effets de bord à deux endroits :
280
+
281
+ - par `app/layout.js`, pour que les templates passent par le compilateur RSC
282
+ et que leurs `next/link` / `next/image` soient référencés dans le React
283
+ Client Manifest ;
284
+ - par `instrumentation.js`, pour les routes d'API de l'administration qui
285
+ listent les templates hors du graphe RSC.
286
+
287
+ Le registre des templates est uniquement côté serveur : l'administration
288
+ liste les templates par l'API REST des modules (`/za/api/...`), pas par un
289
+ manifeste client. Les templates peuvent donc importer librement `next/link`,
290
+ `next/image` ou tout autre composant client.
291
+
292
+ Le `postinstall` et les scripts `dev` et `build` du projet lancent déjà cette
293
+ commande.
@@ -0,0 +1,271 @@
1
+ // Composant <Content> : rendu public des blocs du BlockEditor.
2
+ //
3
+ // Ce fichier est la **source unique de vérité du style** pour les blocs
4
+ // internes du core (paragraph, heading_1..6, bullet_item, numbered_item,
5
+ // checklist, quote, code, divider, spacer, image). Modifie librement chaque
6
+ // composant ci-dessous (className, structure, wrappers) : c'est ton
7
+ // rendu public, tu en as le contrôle complet.
8
+ //
9
+ // Les blocs **custom du site** (`components/fraza/blocks/<id>/render.js`)
10
+ // ne sont PAS gérés ici : ils ont leur propre `render.js` et sont délégués
11
+ // automatiquement via `CustomBlockRender`.
12
+ //
13
+ // Les **marks inline** (gras, italique, lien, code, couleur, surlignage) sont
14
+ // délégués à `renderInline` du core : leur sérialisation est contrainte par
15
+ // le format `InlineNode[]` du BlockEditor.
16
+ //
17
+ // ─── Style ponctuel par zone (prop `overrides`) ────────────────────────────
18
+ // `<Content>` accepte une prop optionnelle `overrides` ({ clé → Composant })
19
+ // qui remplace, au cas par cas, le composant par défaut d'une zone donnée,
20
+ // sans toucher au rendu des autres `<Content>`. Clés acceptées :
21
+ // • types de blocs : paragraph, heading_1..6, quote, code, divider, spacer, image
22
+ // • groupes de liste : bullet_item, numbered_item, checklist
23
+ // Définis tes variantes dans ce fichier, exporte-les, puis passe-les en prop.
24
+ // Exemple : un paragraphe plus petit pour une 2e zone de contenu.
25
+ // export function ParagraphSm({ block }) {
26
+ // return <p {...anchorProps(block)} className="my-4 leading-7 text-sm text-fg-secondary">{renderInline(block.content)}</p>;
27
+ // }
28
+ // <Content blocks={content2} overrides={{ paragraph: ParagraphSm }} />
29
+
30
+ import Image from 'next/image';
31
+ import Link from 'next/link';
32
+ import {
33
+ renderInline,
34
+ groupListItems,
35
+ isCustomBlockType,
36
+ isInternalHref,
37
+ anchorProps,
38
+ CustomBlockRender,
39
+ resolveImageBlock,
40
+ sanitizeUrl,
41
+ } from '@fraza/core/blocks-registry/render';
42
+ import {
43
+ COLUMN_CLASS,
44
+ columnsContainerClass,
45
+ columnStyle,
46
+ } from '@fraza/core/blocks-registry/columns-layout';
47
+
48
+ // `{...anchorProps(block)}` pose l'`id` HTML quand le bloc porte une ancre
49
+ // (définie dans l'éditeur via le menu d'actions ▸ Ancre), cible d'un lien
50
+ // `#slug`. Spreader le résultat est un no-op quand il n'y a pas d'ancre.
51
+
52
+ // ─── Composants par type de bloc ──────────────────────────────────────────
53
+
54
+ // Échelle de taille du paragraphe, pilotée par `block.size` (choisie dans
55
+ // l'éditeur via le menu d'actions ▸ Taille). Un bloc sans `size` retombe sur
56
+ // `md`.
57
+ const PARAGRAPH_SIZE_CLASS = {
58
+ sm: 'text-sm leading-6',
59
+ md: 'text-base leading-7',
60
+ lg: 'text-lg leading-8',
61
+ };
62
+
63
+ function Paragraph({ block }) {
64
+ const sizeClass = PARAGRAPH_SIZE_CLASS[block.size] || PARAGRAPH_SIZE_CLASS.md;
65
+ return <p {...anchorProps(block)} className={`my-4 ${sizeClass} text-fg`}>{renderInline(block.content)}</p>;
66
+ }
67
+
68
+ // Échelle des titres de contenu : elle reste sous le titre de la page (32 px,
69
+ // titre d'écran de la charte), pour qu'un `heading_1` ne le concurrence pas.
70
+ function Heading1({ block }) {
71
+ return <h1 {...anchorProps(block)} className="text-[28px] font-bold leading-tight tracking-[-0.03em] text-fg mt-10 mb-6">{renderInline(block.content)}</h1>;
72
+ }
73
+ function Heading2({ block }) {
74
+ return <h2 {...anchorProps(block)} className="text-2xl font-bold leading-tight tracking-[-0.03em] text-fg mt-8 mb-4">{renderInline(block.content)}</h2>;
75
+ }
76
+ function Heading3({ block }) {
77
+ return <h3 {...anchorProps(block)} className="text-xl font-semibold leading-snug tracking-[-0.02em] text-fg mt-6 mb-3">{renderInline(block.content)}</h3>;
78
+ }
79
+ function Heading4({ block }) {
80
+ return <h4 {...anchorProps(block)} className="text-lg font-semibold leading-snug text-fg mt-5 mb-2">{renderInline(block.content)}</h4>;
81
+ }
82
+ function Heading5({ block }) {
83
+ return <h5 {...anchorProps(block)} className="text-base font-semibold text-fg mt-4 mb-2">{renderInline(block.content)}</h5>;
84
+ }
85
+ function Heading6({ block }) {
86
+ return <h6 {...anchorProps(block)} className="text-base font-semibold text-fg-muted mt-4 mb-2">{renderInline(block.content)}</h6>;
87
+ }
88
+
89
+ function Quote({ block }) {
90
+ return (
91
+ <blockquote {...anchorProps(block)} className="my-6 border-l border-line-strong pl-4 italic text-fg-secondary">
92
+ {renderInline(block.content)}
93
+ </blockquote>
94
+ );
95
+ }
96
+
97
+ function CodeBlock({ block }) {
98
+ return (
99
+ <pre {...anchorProps(block)} className="my-6 rounded-control border border-line bg-surface-muted text-fg p-4 overflow-x-auto text-sm">
100
+ <code className="font-code">{renderInline(block.content)}</code>
101
+ </pre>
102
+ );
103
+ }
104
+
105
+ function Divider({ block }) {
106
+ return <hr {...anchorProps(block)} className="my-10 border-0 border-t border-line" />;
107
+ }
108
+
109
+ // Espaceur : hauteur verticale vide configurable (block.height en px).
110
+ // La borne est appliquée à l'écriture côté éditeur ; ici coercition défensive.
111
+ function Spacer({ block }) {
112
+ const h = Math.round(Number(block.height));
113
+ const height = Number.isFinite(h) && h > 0 ? h : 24;
114
+ return <div {...anchorProps(block)} aria-hidden="true" style={{ height }} />;
115
+ }
116
+
117
+ // Largeur du conteneur selon block.size ('s'=25% | 'm'=50% | 'l'=75% | 'full'=100%).
118
+ // Ignoré quand align === 'full' (toujours pleine largeur).
119
+ const SIZE_CLASS = { s: 'w-1/4', m: 'w-1/2', l: 'w-3/4', full: 'w-full' };
120
+ // Positionnement horizontal du conteneur selon block.align.
121
+ const JUSTIFY_CLASS = { left: 'justify-start', center: 'justify-center', right: 'justify-end' };
122
+
123
+ function ImageBlock({ block }) {
124
+ // Adresse, alt et légende : ceux du média lié (posés au rendu par le module
125
+ // Médias), sinon ceux du bloc pour une image à l'URL externe.
126
+ const { src, alt, caption } = resolveImageBlock(block);
127
+ if (!src) return null;
128
+
129
+ // align : left | center | right | full (défaut center)
130
+ // size : s | m | l | full (défaut full)
131
+ // aspectRatio : auto | 1/1 | 4/3 | 3/2 | 16/9 | 3/4 | 9/16 (défaut auto)
132
+ // Quand align === 'full' → image toujours 100 %, size ignoré.
133
+ const align = block.align || 'center';
134
+ const isFullAlign = align === 'full';
135
+ const sizeClass = isFullAlign ? 'w-full' : (SIZE_CLASS[block.size] || 'w-full');
136
+ const justifyClass = isFullAlign ? '' : (JUSTIFY_CLASS[align] || 'justify-center');
137
+
138
+ // aspectRatio : auto → ratio naturel. Sinon, l'image est recadrée en
139
+ // `object-cover` dans un conteneur au ratio demandé (next/image en `fill`).
140
+ const ratioMap = { '1/1': '1 / 1', '4/3': '4 / 3', '3/2': '3 / 2', '16/9': '16 / 9', '3/4': '3 / 4', '9/16': '9 / 16' };
141
+ const aspectRatio = ratioMap[block.aspectRatio] || null;
142
+
143
+ const img = aspectRatio ? (
144
+ <div className="relative w-full overflow-hidden rounded-control" style={{ aspectRatio }}>
145
+ <Image src={src} alt={alt} fill className="object-cover" />
146
+ </div>
147
+ ) : (
148
+ <Image src={src} alt={alt} width={3840} height={3840} className="rounded-control" />
149
+ );
150
+
151
+ // Le lien vient d'une saisie ou de l'API : seuls http, https, mailto, tel et
152
+ // les adresses relatives passent, comme dans l'éditeur et le rendu du core.
153
+ // Une adresse refusée (`javascript:`, `data:`…) rend l'image sans lien.
154
+ const href = sanitizeUrl(block.href);
155
+
156
+ return (
157
+ <figure {...anchorProps(block)} className="my-8">
158
+ <div className={`flex ${justifyClass}`}>
159
+ <div className={sizeClass}>
160
+ {href ? (
161
+ !block.newTab && isInternalHref(href) ? (
162
+ <Link href={href} className="block">{img}</Link>
163
+ ) : (
164
+ <a href={href} target={block.newTab ? '_blank' : undefined} rel={block.newTab ? 'noopener noreferrer' : undefined} className="block">
165
+ {img}
166
+ </a>
167
+ )
168
+ ) : img}
169
+ </div>
170
+ </div>
171
+ {caption && <figcaption className="mt-2 text-center text-sm text-fg-muted">{caption}</figcaption>}
172
+ </figure>
173
+ );
174
+ }
175
+
176
+ // ─── Composants par groupe de liste ───────────────────────────────────────
177
+ // `groupListItems` du core regroupe les blocs `bullet_item`/`numbered_item`/
178
+ // `checklist` consécutifs pour qu'ils partagent un même <ul>/<ol>.
179
+
180
+ function BulletList({ items }) {
181
+ return (
182
+ <ul className="my-4 list-disc pl-6 space-y-1">
183
+ {items.map((it) => <li key={it.id} {...anchorProps(it)} className="leading-7">{renderInline(it.content)}</li>)}
184
+ </ul>
185
+ );
186
+ }
187
+
188
+ function NumberedList({ items }) {
189
+ return (
190
+ <ol className="my-4 list-decimal pl-6 space-y-1">
191
+ {items.map((it) => <li key={it.id} {...anchorProps(it)} className="leading-7">{renderInline(it.content)}</li>)}
192
+ </ol>
193
+ );
194
+ }
195
+
196
+ function Checklist({ items }) {
197
+ return (
198
+ <ul className="my-4 pl-0 list-none space-y-1">
199
+ {items.map((it) => (
200
+ <li key={it.id} {...anchorProps(it)} className="flex items-start gap-2 leading-7">
201
+ <input type="checkbox" checked={!!it.checked} readOnly className="mt-1.5 accent-accent" />
202
+ <span>{renderInline(it.content)}</span>
203
+ </li>
204
+ ))}
205
+ </ul>
206
+ );
207
+ }
208
+
209
+ // ─── Bloc Colonnes ────────────────────────────────────────────────────────
210
+ // Pose du contenu sur plusieurs colonnes horizontales. Empilé sur mobile, en
211
+ // ligne dès `md` (mise en page partagée avec l'éditeur via `columns-layout` du
212
+ // core). Chaque colonne rend récursivement son sous-tableau via `<Content>`,
213
+ // et les `overrides` sont propagés pour conserver le style par zone.
214
+
215
+ function ColumnsBlock({ block, overrides }) {
216
+ const columns = Array.isArray(block.columns) ? block.columns : [];
217
+ if (columns.length === 0) return null;
218
+ return (
219
+ <div {...anchorProps(block)} className={`my-8 ${columnsContainerClass(block.gap)}`}>
220
+ {columns.map((col, ci) => (
221
+ <div key={col.id ?? ci} style={columnStyle(col)} className={COLUMN_CLASS}>
222
+ <Content blocks={col.blocks} overrides={overrides} />
223
+ </div>
224
+ ))}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ // ─── Dispatch ─────────────────────────────────────────────────────────────
230
+
231
+ const BLOCK_COMPONENTS = {
232
+ paragraph: Paragraph,
233
+ heading_1: Heading1,
234
+ heading_2: Heading2,
235
+ heading_3: Heading3,
236
+ heading_4: Heading4,
237
+ heading_5: Heading5,
238
+ heading_6: Heading6,
239
+ quote: Quote,
240
+ code: CodeBlock,
241
+ divider: Divider,
242
+ spacer: Spacer,
243
+ image: ImageBlock,
244
+ };
245
+
246
+ function renderBlock(block, idx, overrides) {
247
+ const Component = overrides[block.type] || BLOCK_COMPONENTS[block.type];
248
+ if (!Component) return null;
249
+ return <Component key={block.id ?? idx} block={block} />;
250
+ }
251
+
252
+ function renderGroup(group, idx, overrides) {
253
+ if (group.kind === 'bullet_item') { const C = overrides.bullet_item || BulletList; return <C key={`ul-${idx}`} items={group.items} />; }
254
+ if (group.kind === 'numbered_item') { const C = overrides.numbered_item || NumberedList; return <C key={`ol-${idx}`} items={group.items} />; }
255
+ if (group.kind === 'checklist') { const C = overrides.checklist || Checklist; return <C key={`cl-${idx}`} items={group.items} />; }
256
+ // group.kind === 'block'
257
+ const b = group.block;
258
+ // Colonnes : traité avant le dispatch générique pour propager `overrides`
259
+ // dans le rendu récursif des sous-tableaux.
260
+ if (b.type === 'columns') return <ColumnsBlock key={b.id ?? idx} block={b} overrides={overrides} />;
261
+ if (isCustomBlockType(b.type)) return <CustomBlockRender key={b.id ?? idx} block={b} />;
262
+ return renderBlock(b, idx, overrides);
263
+ }
264
+
265
+ // ─── Composant public ─────────────────────────────────────────────────────
266
+
267
+ export default function Content({ blocks, overrides = {} }) {
268
+ if (!Array.isArray(blocks) || blocks.length === 0) return null;
269
+ const groups = groupListItems(blocks);
270
+ return <>{groups.map((group, i) => renderGroup(group, i, overrides))}</>;
271
+ }
@@ -0,0 +1,18 @@
1
+ import Content from './content.js';
2
+
3
+ export const meta = {
4
+ id: 'default',
5
+ label: 'Par défaut',
6
+ kind: 'page',
7
+ };
8
+
9
+ export default function DefaultPageTemplate({ page, blocks }) {
10
+ return (
11
+ <main className="max-w-3xl mx-auto px-4 py-12">
12
+ {/* Titre d'écran de la charte : 32 px, graisse 700, -0,035 em, comme
13
+ les pages publiques des modules. */}
14
+ {page?.title && <h1 className="mb-8 text-[28px] font-bold leading-tight tracking-[-0.035em] text-fg sm:text-[32px]">{page.title}</h1>}
15
+ <Content blocks={blocks} />
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,97 @@
1
+ import Content from './content.js';
2
+
3
+ export const meta = {
4
+ id: 'default',
5
+ label: 'Article de blogue',
6
+ kind: 'post',
7
+ postType: 'blogue',
8
+ };
9
+
10
+ function formatDate(value) {
11
+ if (!value) return null;
12
+ const d = new Date(value);
13
+ if (Number.isNaN(d.getTime())) return null;
14
+ return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' });
15
+ }
16
+
17
+ export default function BloguePostTemplate({ post, blocks }) {
18
+ // post.thumbnail est l'objet enrichi { id, filename, alt_text, caption, mime_type }
19
+ // (cf. enrichSinglePostMediaFields dans @fraza/module-posts/crud.server.js).
20
+ // L'URL publique d'un média se construit avec /za/media/<filename>.
21
+ const thumbnail = post?.thumbnail?.filename
22
+ ? `/za/media/${encodeURIComponent(post.thumbnail.filename)}`
23
+ : null;
24
+ const thumbnailAlt = post?.thumbnail?.alt_text || post?.title || '';
25
+ const categories = Array.isArray(post?.categories) ? post.categories : [];
26
+ const tags = Array.isArray(post?.tags) ? post.tags : [];
27
+ const published = formatDate(post?.created_at);
28
+ const updated = formatDate(post?.updated_at);
29
+
30
+ return (
31
+ <main className="mx-auto max-w-3xl px-4 py-12">
32
+ <article>
33
+ <header className="mb-10">
34
+ {thumbnail && (
35
+ <figure className="mb-8 overflow-hidden rounded-card">
36
+ {/* eslint-disable-next-line @next/next/no-img-element */}
37
+ <img
38
+ src={thumbnail}
39
+ alt={thumbnailAlt}
40
+ className="h-auto w-full object-cover"
41
+ />
42
+ </figure>
43
+ )}
44
+
45
+ {categories.length > 0 && (
46
+ <div className="mb-4 flex flex-wrap gap-2">
47
+ {categories.map((c) => (
48
+ <span
49
+ key={c.id}
50
+ className="inline-flex h-[22px] items-center rounded-full bg-surface-muted px-[9px] text-xs font-medium leading-none text-fg-secondary"
51
+ >
52
+ {c.title}
53
+ </span>
54
+ ))}
55
+ </div>
56
+ )}
57
+
58
+ {post?.title && (
59
+ <h1 className="text-[28px] font-bold leading-tight tracking-[-0.035em] text-fg sm:text-[32px]">
60
+ {post.title}
61
+ </h1>
62
+ )}
63
+
64
+ {(published || updated) && (
65
+ <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm text-fg-muted">
66
+ {published && (
67
+ <time dateTime={post.created_at}>Publié le {published}</time>
68
+ )}
69
+ {updated && updated !== published && (
70
+ <time dateTime={post.updated_at}>Mis à jour le {updated}</time>
71
+ )}
72
+ </div>
73
+ )}
74
+ </header>
75
+
76
+ <div className="prose-neutral">
77
+ <Content blocks={blocks} />
78
+ </div>
79
+
80
+ {tags.length > 0 && (
81
+ <footer className="mt-12 border-t border-line pt-6">
82
+ <ul className="flex flex-wrap gap-2">
83
+ {tags.map((t) => (
84
+ <li
85
+ key={t.id}
86
+ className="inline-flex h-[26px] items-center rounded-md border border-line px-2.5 text-[12.5px] text-fg-secondary"
87
+ >
88
+ #{t.title}
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ </footer>
93
+ )}
94
+ </article>
95
+ </main>
96
+ );
97
+ }