afkaar-gen 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/SKILL.md +80 -0
- package/bin/interactive.js +10 -0
- package/interactive.html +575 -0
- package/package.json +42 -0
- package/references/architecture.md +185 -0
- package/references/figma-mode.md +57 -0
- package/references/inspiration-mode.md +71 -0
- package/references/integrations.md +111 -0
- package/references/iteration-mode.md +60 -0
- package/references/questions.md +198 -0
- package/references/security-checklist.md +218 -0
- package/references/seo-guide.md +183 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Checklist sécurité pour sites générés
|
|
2
|
+
|
|
3
|
+
## S-1 : Content-Security-Policy (CSP)
|
|
4
|
+
|
|
5
|
+
Dans `.htaccess` :
|
|
6
|
+
```
|
|
7
|
+
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: https://maps.googleapis.com; connect-src 'self' https://maps.googleapis.com; frame-src 'self' https://www.googletagmanager.com"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## S-2 : Rate limiting formulaire contact
|
|
11
|
+
|
|
12
|
+
Dans `lead-handler.php`, avant traitement :
|
|
13
|
+
```php
|
|
14
|
+
$ip = $_SERVER['REMOTE_ADDR'] ?? '0';
|
|
15
|
+
$limits = json_read('contact-limits');
|
|
16
|
+
$rec = $limits[$ip] ?? ['count' => 0, 'time' => time()];
|
|
17
|
+
if (time() - $rec['time'] > 900) $rec = ['count' => 0, 'time' => time()];
|
|
18
|
+
if ($rec['count'] >= 5) respond(false, 'Trop de tentatives, réessayez plus tard.');
|
|
19
|
+
$rec['count']++;
|
|
20
|
+
$limits[$ip] = $rec;
|
|
21
|
+
// Nettoyage IP expirées
|
|
22
|
+
foreach ($limits as $k => $v) if (time() - ($v['time'] ?? 0) > 900) unset($limits[$k]);
|
|
23
|
+
json_write('contact-limits', $limits);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## S-3 : Logging échecs login
|
|
27
|
+
|
|
28
|
+
Dans `auth.php`, après échec :
|
|
29
|
+
```php
|
|
30
|
+
$log = date('Y-m-d H:i:s') . " | ÉCHEC | IP: $ip | User: $username\n";
|
|
31
|
+
file_put_contents(DATA_DIR . '/login-attempts.log', $log, FILE_APPEND | LOCK_EX);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## S-4 : Password policy
|
|
35
|
+
|
|
36
|
+
Dans `admin/settings.php`, changement mot de passe :
|
|
37
|
+
```php
|
|
38
|
+
if (!preg_match('/^(?=.*[A-Z])(?=.*[0-9]).{10,}$/', $new)) {
|
|
39
|
+
flash('Minimum 10 caractères, 1 majuscule, 1 chiffre.', 'err');
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## S-5 : Session timeout
|
|
44
|
+
|
|
45
|
+
Dans `auth.php`, au début de chaque page admin :
|
|
46
|
+
```php
|
|
47
|
+
if (($admin_user) && (time() - ($_SESSION['last_activity'] ?? 0)) > 1800) {
|
|
48
|
+
admin_logout(); redirect('/admin/login.php');
|
|
49
|
+
}
|
|
50
|
+
$_SESSION['last_activity'] = time();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## S-6 : Upload validation renforcée
|
|
54
|
+
|
|
55
|
+
Dans `core/functions.php` :
|
|
56
|
+
```php
|
|
57
|
+
function handle_image_upload(string $field, string $prefix = 'img'): ?string {
|
|
58
|
+
if (empty($_FILES[$field]['name']) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
|
|
59
|
+
// Sécurité : basename + regex pour éviter path traversal
|
|
60
|
+
$orig = basename((string)$_FILES[$field]['name']);
|
|
61
|
+
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $orig)) return null;
|
|
62
|
+
$tmp = $_FILES[$field]['tmp_name'];
|
|
63
|
+
if (($_FILES[$field]['size'] ?? 0) > 4 * 1024 * 1024) return null;
|
|
64
|
+
$info = @getimagesize($tmp);
|
|
65
|
+
$allowed = [IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp', IMAGETYPE_GIF => 'gif'];
|
|
66
|
+
if (!$info || !isset($allowed[$info[2]])) return null;
|
|
67
|
+
$ext = $allowed[$info[2]];
|
|
68
|
+
$name = $prefix . '-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . $ext;
|
|
69
|
+
if (!is_dir(UPLOADS_DIR)) @mkdir(UPLOADS_DIR, 0755, true);
|
|
70
|
+
if (!move_uploaded_file($tmp, UPLOADS_DIR . '/' . $name)) return null;
|
|
71
|
+
return UPLOADS_URL . '/' . $name;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## S-7 : Anti path traversal (général)
|
|
76
|
+
|
|
77
|
+
Tous les paramètres de fichier :
|
|
78
|
+
```php
|
|
79
|
+
basename($input); // + regex alphanum + tirets
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## S-8 : Cache-Control admin pages
|
|
83
|
+
|
|
84
|
+
Dans `auth.php` ou `layout-top.php` :
|
|
85
|
+
```php
|
|
86
|
+
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
|
87
|
+
header('Pragma: no-cache');
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## S-9 : Session cookie sécurisé
|
|
91
|
+
|
|
92
|
+
Dans `auth.php` (début) :
|
|
93
|
+
```php
|
|
94
|
+
session_name(SESSION_NAME);
|
|
95
|
+
session_set_cookie_params([
|
|
96
|
+
'lifetime' => 0,
|
|
97
|
+
'path' => '/',
|
|
98
|
+
'domain' => '',
|
|
99
|
+
'secure' => !empty($_SERVER['HTTPS']),
|
|
100
|
+
'httponly' => true,
|
|
101
|
+
'samesite' => 'Strict',
|
|
102
|
+
]);
|
|
103
|
+
session_start();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## S-10 : Protection brute-force login
|
|
107
|
+
|
|
108
|
+
Dans `auth.php` :
|
|
109
|
+
```php
|
|
110
|
+
// Session : 5 tentatives / 10 min
|
|
111
|
+
$attempts = $_SESSION['login_attempts'] ?? ['count' => 0, 'time' => time()];
|
|
112
|
+
if (time() - $attempts['time'] > 600) $attempts = ['count' => 0, 'time' => time()];
|
|
113
|
+
if ($attempts['count'] >= 5) return false;
|
|
114
|
+
|
|
115
|
+
// IP : 12 tentatives / 15 min
|
|
116
|
+
$ip = $_SERVER['REMOTE_ADDR'] ?? '0';
|
|
117
|
+
$limits = json_read('login-limits');
|
|
118
|
+
$rec = $limits[$ip] ?? ['count' => 0, 'time' => time()];
|
|
119
|
+
if (time() - $rec['time'] > 900) $rec = ['count' => 0, 'time' => time()];
|
|
120
|
+
if ($rec['count'] >= 12) return false;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## S-11 : XSS — helper e()
|
|
124
|
+
|
|
125
|
+
Toujours utiliser, jamais de `echo $_POST[...]` ou `<?= $_GET[...]` :
|
|
126
|
+
```php
|
|
127
|
+
function e(?string $s): string {
|
|
128
|
+
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## S-12 : CSRF sur tous les formulaires
|
|
133
|
+
|
|
134
|
+
```php
|
|
135
|
+
// Génération
|
|
136
|
+
function csrf_token(): string {
|
|
137
|
+
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
|
138
|
+
if (empty($_SESSION[CSRF_KEY]))
|
|
139
|
+
$_SESSION[CSRF_KEY] = bin2hex(random_bytes(32));
|
|
140
|
+
return $_SESSION[CSRF_KEY];
|
|
141
|
+
}
|
|
142
|
+
// Validation
|
|
143
|
+
function csrf_check(): bool {
|
|
144
|
+
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
|
145
|
+
return isset($_POST['csrf'], $_SESSION[CSRF_KEY])
|
|
146
|
+
&& hash_equals($_SESSION[CSRF_KEY], (string)$_POST['csrf']);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## S-13 : Honeypot anti-spam
|
|
151
|
+
|
|
152
|
+
Dans le formulaire contact :
|
|
153
|
+
```html
|
|
154
|
+
<input type="text" name="website" tabindex="-1" autocomplete="off"
|
|
155
|
+
style="position:absolute;left:-9999px" aria-hidden="true">
|
|
156
|
+
```
|
|
157
|
+
Dans `lead-handler.php` :
|
|
158
|
+
```php
|
|
159
|
+
if (!empty($_POST['website'])) respond(true, 'Merci !');
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## S-14 : Timer anti-spam (3s)
|
|
163
|
+
|
|
164
|
+
```html
|
|
165
|
+
<input type="hidden" name="_ts" value="<?= time() ?>">
|
|
166
|
+
```
|
|
167
|
+
```php
|
|
168
|
+
$ts = (int)($_POST['_ts'] ?? 0);
|
|
169
|
+
if ($ts > 0 && (time() - $ts) < 3) respond(false, 'Envoi trop rapide.');
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## S-15 : Validation email + téléphone
|
|
173
|
+
|
|
174
|
+
```php
|
|
175
|
+
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) respond(false, 'Email invalide.');
|
|
176
|
+
if (!preg_match('/^[0-9 +().-]{6,20}$/', $phone)) respond(false, 'Téléphone invalide.');
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## S-16 : Anti header injection email
|
|
180
|
+
|
|
181
|
+
```php
|
|
182
|
+
$safeName = preg_replace('/[\r\n]+/', ' ', $name);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## S-17 : Audit trail admin
|
|
186
|
+
|
|
187
|
+
Logger chaque action CRUD :
|
|
188
|
+
```php
|
|
189
|
+
$log = date('Y-m-d H:i:s') . " | ADMIN | {$_SESSION['admin_user']['username']} | Action: $action | Target: $id\n";
|
|
190
|
+
file_put_contents(DATA_DIR . '/audit.log', $log, FILE_APPEND | LOCK_EX);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## S-18 : Blocage exécution PHP dans uploads/
|
|
194
|
+
|
|
195
|
+
`assets/uploads/.htaccess` :
|
|
196
|
+
```
|
|
197
|
+
<FilesMatch "\.(?i:php|phtml|phar)$">
|
|
198
|
+
Require all denied
|
|
199
|
+
</FilesMatch>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## S-19 : Session regenerée après login
|
|
203
|
+
|
|
204
|
+
```php
|
|
205
|
+
session_regenerate_id(true);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## S-20 : Validation slug (anti injection JSON)
|
|
209
|
+
|
|
210
|
+
```php
|
|
211
|
+
function slugify(string $text): string {
|
|
212
|
+
$text = mb_strtolower(trim($text), 'UTF-8');
|
|
213
|
+
$map = ['à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e','î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c','œ'=>'oe'];
|
|
214
|
+
$text = strtr($text, $map);
|
|
215
|
+
$text = preg_replace('/[^a-z0-9]+/', '-', $text);
|
|
216
|
+
return trim((string)$text, '-') ?: 'item';
|
|
217
|
+
}
|
|
218
|
+
```
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# SEO guide pour sites générés
|
|
2
|
+
|
|
3
|
+
## Sitemap.xml dynamique
|
|
4
|
+
|
|
5
|
+
Route dans `index.php` :
|
|
6
|
+
```php
|
|
7
|
+
if ($path === '/sitemap.xml') {
|
|
8
|
+
header('Content-Type: application/xml; charset=utf-8');
|
|
9
|
+
$urls = [APP_URL . '/', APP_URL . '/references', APP_URL . '/blog', APP_URL . '/contact'];
|
|
10
|
+
foreach (json_read('services') as $s) if (!empty($s['active'])) $urls[] = APP_URL . '/services/' . $s['slug'];
|
|
11
|
+
foreach (json_read('references') as $r) if (!empty($r['active'])) $urls[] = APP_URL . '/references/' . $r['slug'];
|
|
12
|
+
foreach (json_read('posts') as $p) if (!empty($p['active'])) $urls[] = APP_URL . '/blog/' . $p['slug'];
|
|
13
|
+
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|
14
|
+
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
|
15
|
+
foreach ($urls as $u) echo " <url><loc>" . e($u) . "</loc></url>\n";
|
|
16
|
+
echo '</urlset>';
|
|
17
|
+
exit;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## robots.txt
|
|
22
|
+
|
|
23
|
+
```txt
|
|
24
|
+
User-agent: *
|
|
25
|
+
Allow: /
|
|
26
|
+
Sitemap: https://monsite.tn/sitemap.xml
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## JSON-LD Organization (header.php)
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<script type="application/ld+json">
|
|
33
|
+
{
|
|
34
|
+
"@context": "https://schema.org",
|
|
35
|
+
"@type": "ProfessionalService",
|
|
36
|
+
"name": "Mon Site",
|
|
37
|
+
"url": "https://monsite.tn",
|
|
38
|
+
"logo": "https://monsite.tn/assets/img/logo.svg",
|
|
39
|
+
"email": "contact@monsite.tn",
|
|
40
|
+
"telephone": "+216 XX XXX XXX",
|
|
41
|
+
"address": {
|
|
42
|
+
"@type": "PostalAddress",
|
|
43
|
+
"streetAddress": "Adresse",
|
|
44
|
+
"addressLocality": "Ville",
|
|
45
|
+
"addressRegion": "Région",
|
|
46
|
+
"addressCountry": "TN"
|
|
47
|
+
},
|
|
48
|
+
"sameAs": ["https://facebook.com/...", "https://instagram.com/..."]
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## JSON-LD Service (page service)
|
|
54
|
+
|
|
55
|
+
```php
|
|
56
|
+
$pageSchemas[] = [
|
|
57
|
+
'@context' => 'https://schema.org', '@type' => 'Service',
|
|
58
|
+
'name' => $found['title'],
|
|
59
|
+
'description' => strip_tags($found['excerpt'] ?? ''),
|
|
60
|
+
'url' => APP_URL . '/services/' . $found['slug'],
|
|
61
|
+
'areaServed' => ['@type' => 'Country', 'name' => 'Tunisie'],
|
|
62
|
+
'provider' => ['@type' => 'ProfessionalService', 'name' => APP_NAME, 'url' => APP_URL],
|
|
63
|
+
];
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## JSON-LD Article (page blog)
|
|
67
|
+
|
|
68
|
+
```php
|
|
69
|
+
$pageSchemas[] = [
|
|
70
|
+
'@context' => 'https://schema.org', '@type' => 'Article',
|
|
71
|
+
'headline' => $found['title'],
|
|
72
|
+
'description' => strip_tags($found['excerpt'] ?? ''),
|
|
73
|
+
'datePublished' => $found['date'] ?? '',
|
|
74
|
+
'inLanguage' => 'fr',
|
|
75
|
+
'image' => !empty($found['image']) ? APP_URL . $found['image'] : null,
|
|
76
|
+
'author' => ['@type' => 'Organization', 'name' => APP_NAME, 'url' => APP_URL],
|
|
77
|
+
'publisher' => ['@type' => 'Organization', 'name' => APP_NAME, 'url' => APP_URL],
|
|
78
|
+
'mainEntityOfPage' => APP_URL . '/blog/' . $found['slug'],
|
|
79
|
+
];
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## JSON-LD BreadcrumbList
|
|
83
|
+
|
|
84
|
+
```php
|
|
85
|
+
$pageSchemas[] = [
|
|
86
|
+
'@context' => 'https://schema.org', '@type' => 'BreadcrumbList',
|
|
87
|
+
'itemListElement' => [
|
|
88
|
+
['@type' => 'ListItem', 'position' => 1, 'name' => 'Accueil', 'item' => APP_URL . '/'],
|
|
89
|
+
['@type' => 'ListItem', 'position' => 2, 'name' => 'Catégorie', 'item' => APP_URL . '/blog'],
|
|
90
|
+
['@type' => 'ListItem', 'position' => 3, 'name' => $found['title']],
|
|
91
|
+
],
|
|
92
|
+
];
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## JSON-LD FAQPage (page accueil)
|
|
96
|
+
|
|
97
|
+
```php
|
|
98
|
+
$pageSchemas[] = [
|
|
99
|
+
'@context' => 'https://schema.org', '@type' => 'FAQPage',
|
|
100
|
+
'mainEntity' => array_map(fn($q) => [
|
|
101
|
+
'@type' => 'Question', 'name' => $q[0],
|
|
102
|
+
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $q[1]],
|
|
103
|
+
], $faq),
|
|
104
|
+
];
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Open Graph (header.php)
|
|
108
|
+
|
|
109
|
+
```html
|
|
110
|
+
<meta property="og:type" content="website">
|
|
111
|
+
<meta property="og:title" content="<?= e($seo['title']) ?>">
|
|
112
|
+
<meta property="og:description" content="<?= e($seo['description']) ?>">
|
|
113
|
+
<meta property="og:url" content="<?= e($seo['canonical']) ?>">
|
|
114
|
+
<meta property="og:image" content="https://monsite.tn/assets/img/og-cover.png">
|
|
115
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## SEO data management
|
|
119
|
+
|
|
120
|
+
Dans `data/seo.json` :
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"global": {
|
|
124
|
+
"default_title": "Titre par défaut",
|
|
125
|
+
"default_description": "Description par défaut",
|
|
126
|
+
"default_keywords": "mots, clés, SEO",
|
|
127
|
+
"og_image": "/assets/img/og-cover.png",
|
|
128
|
+
"gtm_id": "GTM-XXXXXXX",
|
|
129
|
+
"google_verification": ""
|
|
130
|
+
},
|
|
131
|
+
"pages": {
|
|
132
|
+
"home": { "title": "...", "description": "...", "noindex": false },
|
|
133
|
+
"contact": { "title": "...", "description": "...", "noindex": false },
|
|
134
|
+
"references": { "title": "...", "description": "...", "noindex": false },
|
|
135
|
+
"service:slug": { "title": "...", "description": "..." },
|
|
136
|
+
"post:slug": { "title": "...", "description": "..." },
|
|
137
|
+
"reference:slug": { "title": "...", "description": "..." },
|
|
138
|
+
"404": { "title": "Page introuvable", "noindex": true }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Fonction seo_for()
|
|
144
|
+
|
|
145
|
+
```php
|
|
146
|
+
function seo_for(string $pageKey, array $fallback = []): array {
|
|
147
|
+
$seo = json_read('seo');
|
|
148
|
+
$page = $seo['pages'][$pageKey] ?? [];
|
|
149
|
+
$global = $seo['global'] ?? [];
|
|
150
|
+
return [
|
|
151
|
+
'title' => ($page['title'] ?? '') ?: ($fallback['title'] ?? ($global['default_title'] ?? APP_NAME)),
|
|
152
|
+
'description' => ($page['description'] ?? '') ?: ($fallback['description'] ?? ($global['default_description'] ?? '')),
|
|
153
|
+
'keywords' => ($page['keywords'] ?? '') ?: ($global['default_keywords'] ?? ''),
|
|
154
|
+
'og_image' => ($page['og_image'] ?? '') ?: ($global['og_image'] ?? ''),
|
|
155
|
+
'canonical' => APP_URL . ($fallback['path'] ?? '/'),
|
|
156
|
+
'noindex' => !empty($page['noindex']),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Google Analytics (GTM)
|
|
162
|
+
|
|
163
|
+
Dans `header.php`, si `gtm_id` est défini :
|
|
164
|
+
```html
|
|
165
|
+
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
166
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
167
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
|
|
168
|
+
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
|
|
169
|
+
f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-XXXX');</script>
|
|
170
|
+
```
|
|
171
|
+
Google Search Console via balise meta :
|
|
172
|
+
```html
|
|
173
|
+
<meta name="google-site-verification" content="...">
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Lazy loading images
|
|
177
|
+
|
|
178
|
+
Toute balise `<img>` générée DOIT avoir :
|
|
179
|
+
```html
|
|
180
|
+
<img src="..." alt="..." loading="lazy" decoding="async">
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Exceptions : hero image principale (au-dessus-de-la-pli) peut avoir `loading="eager"`.
|