@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.
- package/CHANGELOG.md +180 -0
- package/LICENSE +21 -0
- package/README.md +169 -2
- package/THIRD-PARTY-NOTICES.md +11 -0
- package/bin/agents-block.md +61 -0
- package/bin/cli.js +197 -0
- package/bin/project.js +281 -0
- package/bin/version.js +2 -0
- package/changelog.json +577 -0
- package/package.json +42 -4
- package/templates/app/(admin)/admin/[...admin]/layout.js +3 -0
- package/templates/app/(admin)/admin/[...admin]/loading.js +3 -0
- package/templates/app/(admin)/admin/[...admin]/page.js +1 -0
- package/templates/app/(admin)/admin/error.js +4 -0
- package/templates/app/(admin)/admin/layout.js +17 -0
- package/templates/app/(admin)/admin/not-found.js +2 -0
- package/templates/app/(admin)/admin/page.js +5 -0
- package/templates/app/(auth)/auth/[...auth]/page.js +1 -0
- package/templates/app/(auth)/auth/page.js +5 -0
- package/templates/app/(public)/[...slug]/page.js +29 -0
- package/templates/app/(public)/error.js +6 -0
- package/templates/app/(public)/layout.js +17 -0
- package/templates/app/(public)/not-found.js +20 -0
- package/templates/app/(public)/page.js +248 -0
- package/templates/app/.fraza/blocks.client.js +8 -0
- package/templates/app/.fraza/blocks.server.js +4 -0
- package/templates/app/.fraza/modules.client.js +6 -0
- package/templates/app/.fraza/modules.generated.js +3 -0
- package/templates/app/.fraza/public-pages.server.js +4 -0
- package/templates/app/.fraza/sources.css +2 -0
- package/templates/app/.fraza/templates.server.js +5 -0
- package/templates/app/global-error.js +5 -0
- package/templates/app/icon.svg +1 -0
- package/templates/app/layout.js +31 -0
- package/templates/app/za/[...path]/not-found.js +6 -0
- package/templates/app/za/[...path]/page.js +8 -0
- package/templates/app/za/api/[...path]/route.js +1 -0
- package/templates/app/za/media/[filename]/route.js +1 -0
- package/templates/components/fraza/blocks/README.md +207 -0
- package/templates/components/fraza/blocks/accordion/fields.js +19 -0
- package/templates/components/fraza/blocks/accordion/render.js +47 -0
- package/templates/components/fraza/blocks/accordion/template.js +28 -0
- package/templates/components/fraza/blocks/callout/fields.js +15 -0
- package/templates/components/fraza/blocks/callout/render.js +28 -0
- package/templates/components/fraza/blocks/callout/template.js +37 -0
- package/templates/components/fraza/blocks/icon-list/fields.js +20 -0
- package/templates/components/fraza/blocks/icon-list/render.js +44 -0
- package/templates/components/fraza/blocks/icon-list/template.js +89 -0
- package/templates/components/fraza/blocks/three-cards/fields.js +19 -0
- package/templates/components/fraza/blocks/three-cards/render.js +33 -0
- package/templates/components/fraza/blocks/three-cards/template.js +57 -0
- package/templates/components/fraza/templates/README.md +293 -0
- package/templates/components/fraza/templates/content.js +271 -0
- package/templates/components/fraza/templates/default.js +18 -0
- package/templates/components/fraza/templates/post-blogue.js +97 -0
- package/templates/instrumentation.js +21 -0
- package/templates/jsconfig.json +9 -0
- package/templates/styles/globals.css +25 -0
- 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
|
+
}
|