@komekoo/astro-cms 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,7 @@ export default defineConfig({
72
72
  site: 'Salon de coiffure à Vichy', // présentation du site dans ai/manifest.json (obligatoire)
73
73
  contact: 'contact@exemple.fr', // contact proposé pour les demandes hors périmètre (obligatoire)
74
74
  url: 'https://exemple.fr', // adresse publique du site, https sans « / » final (obligatoire)
75
- admin: { accent: '#5DEFB7', logo: '/uploads/logo-2400.webp', origine: 'https://admin.exemple.fr' }, // facultatif
75
+ admin: { accent: '#5DEFB7', logo: '/uploads/logo-2400.webp', origine: 'https://admin.exemple.fr', apercu: 'https://apercu.exemple.pages.dev' }, // facultatif
76
76
  horsPerimetre: ['Modifier le menu de navigation'], // ajouts à la liste socle (facultatif)
77
77
  }),
78
78
  ],
@@ -100,6 +100,22 @@ guillemets), `MOTIF_LIEN` et `MOTIF_SRC_IMAGE`.
100
100
  Options récentes : `url` (obligatoire) et `admin` de `cms()`, `onglet` sur les champs, `adresse` sur les
101
101
  singletons, et le bloc `x-cms-site` de `cms.schema.json`.
102
102
 
103
+ ## Aperçu des pages : `cms()`
104
+
105
+ ```astro
106
+ ---
107
+ import { cms } from '@komekoo/astro-cms';
108
+ ---
109
+ <h1 {...cms('accueil.titre')}>{page.titre}</h1>
110
+ ```
111
+
112
+ `cms('<page>.<champ>')` marque l'élément comme éditable : l'admin le met à jour pendant la saisie et le relie au champ. Chemin : `<page>.<champ>(.<sous-champ>|.<indice>)*`, pages (singletons) uniquement. Au build, chaque marqueur est vérifié contre `cms.schema.json` ; erreur `page HTML — chemin — motif` si le chemin est inconnu, si un champ image n'est pas sur un `<img>` ou si un champ lien n'est pas sur un `<a>`.
113
+
114
+ - `defineSingleton(id, { …, apercu: '/contact' })` : page à montrer pour un singleton sans `adresse` (par ex. les Réglages).
115
+ - `cms({ …, admin: { apercu: 'https://apercu.<projet>.pages.dev' } })` : adresse fixe de la branche d'aperçu ; active « Prévisualiser ».
116
+ - Le build ajoute au site `/_cms/chargeur.js` (balise `<script>` dans chaque page), `/_cms/apercu/` et `/_cms/motifs.js`, et un `_headers` avec `Content-Security-Policy: frame-ancestors 'self' <origine admin>` ; `X-Robots-Tag: noindex` si `CF_PAGES_BRANCH` n'est pas `main`. Un `X-Frame-Options` `DENY` ou `SAMEORIGIN` déjà présent déclenche un avertissement : le retirer. Une CSP propre au site doit autoriser `script-src 'self'`.
117
+ - Sous-chemins sans dépendance à Node : `@komekoo/astro-cms/apercu` (protocole d'échange, sans analyseur Markdown), `@komekoo/astro-cms/apercu/arbre` (`versArbre`, Markdown → arbre d'aperçu, à charger à la demande) et `@komekoo/astro-cms/navigateur`.
118
+
103
119
  ## Composant `<CmsImage>`
104
120
 
105
121
  ```astro
@@ -110,7 +126,8 @@ import CmsImage from '@komekoo/astro-cms/CmsImage.astro';
110
126
  ```
111
127
 
112
128
  Produit un `<img>` avec `srcset` sur les variantes 800 / 1600 / 2400, `width`, `height` et `alt`.
113
- Props : `image` (obligatoire), `sizes` (`100vw` par défaut), `loading` (`lazy` par défaut), `class`.
129
+ Props : `image` (obligatoire), `sizes` (`100vw` par défaut), `loading` (`lazy` par défaut), `class`, `cms`
130
+ (chemin du marqueur d'aperçu, par ex. `cms="accueil.image"`).
114
131
 
115
132
  ## Limites connues
116
133
 
package/dist/ajv.js CHANGED
@@ -7,6 +7,7 @@ export const MOTS_CLES_CMS = [
7
7
  'x-cms-libelle',
8
8
  'x-cms-fichier',
9
9
  'x-cms-adresse',
10
+ 'x-cms-apercu',
10
11
  'x-cms-dossier',
11
12
  'x-cms-format',
12
13
  'x-cms-titre',
@@ -0,0 +1,3 @@
1
+ import type { NoeudRiche } from './protocole.js';
2
+ /** Convertit du Markdown en arbre restreint ; tout élément hors du sous-ensemble autorisé devient du texte brut. */
3
+ export declare function versArbre(markdown: string): NoeudRiche[];
@@ -0,0 +1,64 @@
1
+ import { analyseur } from '../noyau/markdown.js';
2
+ import { MOTIF_LIEN, MOTIF_SRC_IMAGE } from '../motifs.js';
3
+ /** Texte brut d'un nœud : sa valeur littérale, ou celle de ses descendants. */
4
+ function toString(noeud) {
5
+ if ('value' in noeud)
6
+ return noeud.value;
7
+ return enfants(noeud).map(toString).join('');
8
+ }
9
+ const enfants = (noeud) => ('children' in noeud ? noeud.children : []);
10
+ function convertirTous(liste) {
11
+ return liste.flatMap(convertir);
12
+ }
13
+ function balise(t, liste) {
14
+ return [{ t, c: convertirTous(liste) }];
15
+ }
16
+ function convertir(noeud) {
17
+ switch (noeud.type) {
18
+ case 'paragraph':
19
+ return balise('p', noeud.children);
20
+ case 'heading':
21
+ return noeud.depth === 2 || noeud.depth === 3
22
+ ? balise(noeud.depth === 2 ? 'h2' : 'h3', noeud.children)
23
+ : [{ t: 'texte', v: toString(noeud) }];
24
+ case 'strong':
25
+ return balise('strong', noeud.children);
26
+ case 'emphasis':
27
+ return balise('em', noeud.children);
28
+ case 'list':
29
+ return balise(noeud.ordered ? 'ol' : 'ul', noeud.children);
30
+ case 'listItem':
31
+ return balise('li', noeud.children);
32
+ case 'blockquote':
33
+ return balise('blockquote', noeud.children);
34
+ case 'table': {
35
+ const [entete, ...corps] = noeud.children;
36
+ const ligne = (l, cellule) => ({
37
+ t: 'tr',
38
+ c: l.children.map((c) => ({ t: cellule, c: convertirTous(c.children) })),
39
+ });
40
+ const c = [];
41
+ if (entete)
42
+ c.push({ t: 'thead', c: [ligne(entete, 'th')] });
43
+ c.push({ t: 'tbody', c: corps.map((l) => ligne(l, 'td')) });
44
+ return [{ t: 'table', c }];
45
+ }
46
+ case 'break':
47
+ return [{ t: 'br' }];
48
+ case 'text':
49
+ return [{ t: 'texte', v: noeud.value }];
50
+ case 'link':
51
+ return MOTIF_LIEN.test(noeud.url)
52
+ ? [{ t: 'a', href: noeud.url, c: convertirTous(noeud.children) }]
53
+ : convertirTous(noeud.children);
54
+ case 'image':
55
+ return MOTIF_SRC_IMAGE.test(noeud.url) ? [{ t: 'img', src: noeud.url, alt: noeud.alt ?? '' }] : [];
56
+ default:
57
+ return [{ t: 'texte', v: toString(noeud) }];
58
+ }
59
+ }
60
+ /** Convertit du Markdown en arbre restreint ; tout élément hors du sous-ensemble autorisé devient du texte brut. */
61
+ export function versArbre(markdown) {
62
+ const racine = analyseur.parse(markdown.replace(/\r\n/g, '\n'));
63
+ return convertirTous(enfants(racine));
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ // Point d'entrée de l'aperçu dans le site construit. L'intégration remplace la chaîne ci-dessous, guillemets compris,
2
+ // par l'origine de l'admin (chaîne JSON) en copiant ce fichier.
3
+ import { demarrerApercu } from './script.js';
4
+ const ORIGINE_ADMIN = '__KOMEKOO_ORIGINE_ADMIN__';
5
+ demarrerApercu(window, ORIGINE_ADMIN);
@@ -0,0 +1,2 @@
1
+ export { BALISES_RICHES, lireMessage } from './protocole.js';
2
+ export type { Message, NoeudRiche, ValeurApercu } from './protocole.js';
@@ -0,0 +1,2 @@
1
+ // Protocole seul : `versArbre` (analyseur Markdown) est servi à part par `@komekoo/astro-cms/apercu/arbre`.
2
+ export { BALISES_RICHES, lireMessage } from './protocole.js';
@@ -0,0 +1,67 @@
1
+ import { MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE } from '../motifs.js';
2
+ export { MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE };
3
+ export type NoeudRiche = {
4
+ t: 'texte';
5
+ v: string;
6
+ } | {
7
+ t: 'p' | 'h2' | 'h3' | 'strong' | 'em' | 'ul' | 'ol' | 'li' | 'blockquote' | 'table' | 'thead' | 'tbody' | 'tr' | 'th' | 'td' | 'br';
8
+ c?: NoeudRiche[];
9
+ } | {
10
+ t: 'a';
11
+ href: string;
12
+ c: NoeudRiche[];
13
+ } | {
14
+ t: 'img';
15
+ src: string;
16
+ alt: string;
17
+ };
18
+ export type ValeurApercu = {
19
+ type: 'texte';
20
+ texte: string;
21
+ } | {
22
+ type: 'image';
23
+ src: string;
24
+ srcset: string;
25
+ alt: string;
26
+ } | {
27
+ type: 'lien';
28
+ href: string;
29
+ texte?: string;
30
+ } | {
31
+ type: 'riche';
32
+ arbre: NoeudRiche[];
33
+ } | {
34
+ type: 'indisponible';
35
+ };
36
+ export type Message = {
37
+ cms: 1;
38
+ type: 'bonjour';
39
+ } | {
40
+ cms: 1;
41
+ type: 'pret';
42
+ chemins: string[];
43
+ } | {
44
+ cms: 1;
45
+ type: 'valeurs';
46
+ valeurs: Record<string, ValeurApercu>;
47
+ } | {
48
+ cms: 1;
49
+ type: 'actif';
50
+ chemin: string | null;
51
+ libelle?: string;
52
+ } | {
53
+ cms: 1;
54
+ type: 'clic';
55
+ chemin: string;
56
+ } | {
57
+ cms: 1;
58
+ type: 'lien';
59
+ adresse: string;
60
+ };
61
+ /** Balises de `NoeudRiche` qui contiennent des enfants, hors `texte`, `a` et `img`. */
62
+ export declare const BALISES_RICHES: ReadonlySet<string>;
63
+ export declare const MAX_CHEMINS = 500;
64
+ /** URL absolue http(s), sans espace ni caractère de contrôle. */
65
+ export declare function estUrlAbsolue(x: unknown): x is string;
66
+ /** Valide strictement un message reçu ; renvoie une copie neuve, ou `null` si quoi que ce soit est refusé. */
67
+ export declare function lireMessage(donnee: unknown): Message | null;
@@ -0,0 +1,176 @@
1
+ // Protocole d'échange entre l'admin et le site (aperçu). Ne dépend que de `../motifs.js` : ce fichier est aussi
2
+ // copié tel quel dans le site construit, où il est chargé comme module du navigateur.
3
+ import { MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE } from '../motifs.js';
4
+ // Réexportés pour `script.ts`, qui ne doit importer que ce fichier une fois copié dans le site.
5
+ export { MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE };
6
+ /** Balises de `NoeudRiche` qui contiennent des enfants, hors `texte`, `a` et `img`. */
7
+ export const BALISES_RICHES = new Set([
8
+ 'p', 'h2', 'h3', 'strong', 'em', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'br',
9
+ ]);
10
+ export const MAX_CHEMINS = 500;
11
+ const PROFONDEUR_MAX = 20;
12
+ /** Chemin interne : ni « // » ni « /\ » au début, aucun antislash, espace ni caractère de contrôle. */
13
+ const MOTIF_ADRESSE = /^\/(?![\/\\])[^\s\x00-\x1f\\]*$/;
14
+ const estObjet = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
15
+ const cles = (o, attendues, facultatives = []) => Object.keys(o).every((k) => attendues.includes(k) || facultatives.includes(k)) && attendues.every((k) => k in o);
16
+ const estChemin = (x) => typeof x === 'string' && MOTIF_CHEMIN_CMS.test(x);
17
+ const estLien = (x) => typeof x === 'string' && MOTIF_LIEN.test(x);
18
+ /** URL absolue http(s), sans espace ni caractère de contrôle. */
19
+ export function estUrlAbsolue(x) {
20
+ if (typeof x !== 'string' || /[\s\x00-\x1f]/.test(x))
21
+ return false;
22
+ try {
23
+ const protocole = new URL(x).protocol;
24
+ return protocole === 'https:' || protocole === 'http:';
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function srcsetValide(x) {
31
+ if (typeof x !== 'string')
32
+ return false;
33
+ if (x === '')
34
+ return true;
35
+ return x.split(',').every((entree) => {
36
+ const [url, ...reste] = entree.trim().split(/\s+/);
37
+ return estUrlAbsolue(url) && reste.length <= 1;
38
+ });
39
+ }
40
+ function lireNoeud(x, profondeur) {
41
+ if (profondeur > PROFONDEUR_MAX || !estObjet(x))
42
+ return null;
43
+ const t = x.t;
44
+ if (t === 'texte') {
45
+ return cles(x, ['t', 'v']) && typeof x.v === 'string' ? { t, v: x.v } : null;
46
+ }
47
+ if (t === 'img') {
48
+ if (!cles(x, ['t', 'src', 'alt']) || typeof x.alt !== 'string')
49
+ return null;
50
+ // Les arbres de `versArbre` portent des chemins relatifs /uploads/ ; l'admin peut aussi envoyer des URL absolues.
51
+ const ok = estUrlAbsolue(x.src) || (typeof x.src === 'string' && MOTIF_SRC_IMAGE.test(x.src));
52
+ return ok ? { t, src: x.src, alt: x.alt } : null;
53
+ }
54
+ if (t === 'a') {
55
+ if (!cles(x, ['t', 'href', 'c']) || !estLien(x.href))
56
+ return null;
57
+ const c = lireNoeuds(x.c, profondeur + 1);
58
+ return c ? { t, href: x.href, c } : null;
59
+ }
60
+ if (typeof t === 'string' && BALISES_RICHES.has(t)) {
61
+ if (!cles(x, ['t'], ['c']))
62
+ return null;
63
+ const balise = t;
64
+ if (x.c === undefined)
65
+ return { t: balise };
66
+ const c = lireNoeuds(x.c, profondeur + 1);
67
+ return c ? { t: balise, c } : null;
68
+ }
69
+ return null;
70
+ }
71
+ function lireNoeuds(x, profondeur) {
72
+ if (!Array.isArray(x))
73
+ return null;
74
+ const sortie = [];
75
+ for (const element of x) {
76
+ const noeud = lireNoeud(element, profondeur);
77
+ if (!noeud)
78
+ return null;
79
+ sortie.push(noeud);
80
+ }
81
+ return sortie;
82
+ }
83
+ function lireValeur(x) {
84
+ if (!estObjet(x))
85
+ return null;
86
+ switch (x.type) {
87
+ case 'texte':
88
+ return cles(x, ['type', 'texte']) && typeof x.texte === 'string' ? { type: 'texte', texte: x.texte } : null;
89
+ case 'image':
90
+ return cles(x, ['type', 'src', 'srcset', 'alt']) &&
91
+ estUrlAbsolue(x.src) &&
92
+ srcsetValide(x.srcset) &&
93
+ typeof x.alt === 'string'
94
+ ? { type: 'image', src: x.src, srcset: x.srcset, alt: x.alt }
95
+ : null;
96
+ case 'lien': {
97
+ if (!cles(x, ['type', 'href'], ['texte']) || !estLien(x.href))
98
+ return null;
99
+ if (x.texte === undefined)
100
+ return { type: 'lien', href: x.href };
101
+ return typeof x.texte === 'string' ? { type: 'lien', href: x.href, texte: x.texte } : null;
102
+ }
103
+ case 'riche': {
104
+ if (!cles(x, ['type', 'arbre']))
105
+ return null;
106
+ const arbre = lireNoeuds(x.arbre, 1);
107
+ return arbre ? { type: 'riche', arbre } : null;
108
+ }
109
+ case 'indisponible':
110
+ return cles(x, ['type']) ? { type: 'indisponible' } : null;
111
+ default:
112
+ return null;
113
+ }
114
+ }
115
+ /** Valide strictement un message reçu ; renvoie une copie neuve, ou `null` si quoi que ce soit est refusé. */
116
+ export function lireMessage(donnee) {
117
+ if (!estObjet(donnee) || donnee.cms !== 1)
118
+ return null;
119
+ switch (donnee.type) {
120
+ case 'bonjour':
121
+ return cles(donnee, ['cms', 'type']) ? { cms: 1, type: 'bonjour' } : null;
122
+ case 'pret': {
123
+ if (!cles(donnee, ['cms', 'type', 'chemins']))
124
+ return null;
125
+ const chemins = donnee.chemins;
126
+ if (!Array.isArray(chemins) || chemins.length > MAX_CHEMINS)
127
+ return null;
128
+ const copie = [];
129
+ for (const chemin of chemins) {
130
+ // for…of visite aussi les trous d'un tableau creux (valeur undefined), qui sont donc refusés.
131
+ if (!estChemin(chemin))
132
+ return null;
133
+ copie.push(chemin);
134
+ }
135
+ return { cms: 1, type: 'pret', chemins: copie };
136
+ }
137
+ case 'valeurs': {
138
+ if (!cles(donnee, ['cms', 'type', 'valeurs']) || !estObjet(donnee.valeurs))
139
+ return null;
140
+ const valeurs = {};
141
+ for (const [chemin, brut] of Object.entries(donnee.valeurs)) {
142
+ const valeur = lireValeur(brut);
143
+ if (!estChemin(chemin) || !valeur)
144
+ return null;
145
+ valeurs[chemin] = valeur;
146
+ }
147
+ return { cms: 1, type: 'valeurs', valeurs };
148
+ }
149
+ case 'actif': {
150
+ if (!cles(donnee, ['cms', 'type', 'chemin'], ['libelle']))
151
+ return null;
152
+ const chemin = donnee.chemin;
153
+ if (chemin !== null && !estChemin(chemin))
154
+ return null;
155
+ if (donnee.libelle === undefined)
156
+ return { cms: 1, type: 'actif', chemin };
157
+ return typeof donnee.libelle === 'string'
158
+ ? { cms: 1, type: 'actif', chemin, libelle: donnee.libelle }
159
+ : null;
160
+ }
161
+ case 'clic':
162
+ return cles(donnee, ['cms', 'type', 'chemin']) && estChemin(donnee.chemin)
163
+ ? { cms: 1, type: 'clic', chemin: donnee.chemin }
164
+ : null;
165
+ case 'lien': {
166
+ if (!cles(donnee, ['cms', 'type', 'adresse']))
167
+ return null;
168
+ const adresse = donnee.adresse;
169
+ return typeof adresse === 'string' && MOTIF_ADRESSE.test(adresse)
170
+ ? { cms: 1, type: 'lien', adresse }
171
+ : null;
172
+ }
173
+ default:
174
+ return null;
175
+ }
176
+ }
@@ -0,0 +1,8 @@
1
+ import { type NoeudRiche } from './protocole.js';
2
+ /** Remplace les enfants de `element` par l'arbre, en ne créant que les nœuds et attributs autorisés. */
3
+ export declare function appliquerArbre(element: Element, arbre: NoeudRiche[]): void;
4
+ /**
5
+ * Démarre l'aperçu dans `fenetre` : écoute les messages de l'admin (`origineAdmin`, fenêtre parente).
6
+ * Renvoie une fonction d'arrêt.
7
+ */
8
+ export declare function demarrerApercu(fenetre: Window, origineAdmin: string): () => void;
@@ -0,0 +1,321 @@
1
+ // Script d'aperçu exécuté dans le site du client, affiché dans l'admin. Frontière de sécurité :
2
+ // - seuls les messages de l'origine de l'admin, venant de la fenêtre parente et validés par `lireMessage`, sont lus ;
3
+ // - avant le premier `bonjour` valide, rien n'est modifié et aucun événement n'est capté ;
4
+ // - le contenu n'est construit qu'avec `createElement`, `createTextNode`, `setAttribute` et `textContent`.
5
+ // N'importe que `./protocole.js` : ce fichier est copié tel quel dans le site construit (module du navigateur).
6
+ import { BALISES_RICHES, MAX_CHEMINS, MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE, estUrlAbsolue, lireMessage, } from './protocole.js';
7
+ const CLASSE_ZONE = 'komekoo-zone';
8
+ const CLASSE_ACTIVE = 'komekoo-zone--active';
9
+ const CLASSE_ETIQUETTE = 'komekoo-zone__etiquette';
10
+ const CLASSE_INFO = 'komekoo-info';
11
+ const TEXTE_LIEN_DESACTIVE = "Les liens sont désactivés dans l'aperçu";
12
+ const TEXTE_INDISPONIBLE = 'Visible dans la prévisualisation';
13
+ const DUREE_INFO = 2000;
14
+ const PROFONDEUR_MAX = 20;
15
+ // Valeurs de la maquette 04 (`.zone-editable`, `--active`, `__etiquette`) recopiées en dur, préfixe `komekoo-`.
16
+ const STYLES = `
17
+ .${CLASSE_ZONE}{outline:1px dashed transparent!important;outline-offset:2px!important;cursor:pointer!important;transition:outline-color 120ms ease}
18
+ .${CLASSE_ZONE}:hover{outline-color:#52525B!important}
19
+ .${CLASSE_ACTIVE}{box-shadow:0 0 0 2px #5DEFB7!important}
20
+ .${CLASSE_ETIQUETTE},.${CLASSE_INFO}{position:fixed;z-index:2147483647;margin:0;pointer-events:none;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:0.8125rem;font-weight:500;line-height:1.5;letter-spacing:normal;text-transform:none;white-space:nowrap}
21
+ .${CLASSE_ETIQUETTE}{padding:0 0.5rem;border-radius:0.375rem;background:#5DEFB7;color:#0E2A1E}
22
+ .${CLASSE_INFO}{padding:0.25rem 0.75rem;border-radius:0.625rem;background:#18181B;color:#FFFFFF;box-shadow:0 12px 32px rgba(31,28,25,0.14),0 1px 2px rgba(31,28,25,0.06)}
23
+ @media (prefers-reduced-motion:reduce){.${CLASSE_ZONE}{transition:none}}
24
+ `;
25
+ const estObjet = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
26
+ const srcImageAutorisee = (x) => estUrlAbsolue(x) || (typeof x === 'string' && MOTIF_SRC_IMAGE.test(x));
27
+ function construire(document, noeuds, profondeur, parent) {
28
+ if (!Array.isArray(noeuds) || profondeur > PROFONDEUR_MAX)
29
+ return;
30
+ for (const noeud of noeuds) {
31
+ if (!estObjet(noeud))
32
+ continue;
33
+ const t = noeud.t;
34
+ if (t === 'texte') {
35
+ if (typeof noeud.v === 'string')
36
+ parent.appendChild(document.createTextNode(noeud.v));
37
+ }
38
+ else if (t === 'a') {
39
+ if (typeof noeud.href === 'string' && MOTIF_LIEN.test(noeud.href)) {
40
+ const a = document.createElement('a');
41
+ a.setAttribute('href', noeud.href);
42
+ construire(document, noeud.c, profondeur + 1, a);
43
+ parent.appendChild(a);
44
+ }
45
+ else {
46
+ // Adresse refusée : le texte du lien est gardé, sans lien.
47
+ construire(document, noeud.c, profondeur + 1, parent);
48
+ }
49
+ }
50
+ else if (t === 'img') {
51
+ if (srcImageAutorisee(noeud.src)) {
52
+ const img = document.createElement('img');
53
+ img.setAttribute('src', noeud.src);
54
+ img.setAttribute('alt', typeof noeud.alt === 'string' ? noeud.alt : '');
55
+ parent.appendChild(img);
56
+ }
57
+ }
58
+ else if (typeof t === 'string' && BALISES_RICHES.has(t)) {
59
+ const element = document.createElement(t);
60
+ if (t !== 'br')
61
+ construire(document, noeud.c, profondeur + 1, element);
62
+ parent.appendChild(element);
63
+ }
64
+ // Tout autre type est ignoré, avec ses enfants.
65
+ }
66
+ }
67
+ /** Remplace les enfants de `element` par l'arbre, en ne créant que les nœuds et attributs autorisés. */
68
+ export function appliquerArbre(element, arbre) {
69
+ const document = element.ownerDocument;
70
+ const fragment = document.createDocumentFragment();
71
+ construire(document, arbre, 1, fragment);
72
+ element.replaceChildren(fragment);
73
+ }
74
+ function appliquerValeur(element, valeur) {
75
+ switch (valeur.type) {
76
+ case 'texte':
77
+ element.textContent = valeur.texte;
78
+ return;
79
+ case 'image':
80
+ if (element.localName !== 'img' || !estUrlAbsolue(valeur.src))
81
+ return;
82
+ element.setAttribute('src', valeur.src);
83
+ if (valeur.srcset === '')
84
+ element.removeAttribute('srcset');
85
+ else
86
+ element.setAttribute('srcset', valeur.srcset);
87
+ element.setAttribute('alt', valeur.alt);
88
+ return;
89
+ case 'lien':
90
+ if (element.localName !== 'a' || !MOTIF_LIEN.test(valeur.href))
91
+ return;
92
+ element.setAttribute('href', valeur.href);
93
+ if (valeur.texte !== undefined && Array.from(element.childNodes).every((n) => n.nodeType === 3)) {
94
+ element.textContent = valeur.texte;
95
+ }
96
+ return;
97
+ case 'riche':
98
+ appliquerArbre(element, valeur.arbre);
99
+ return;
100
+ case 'indisponible':
101
+ return;
102
+ }
103
+ }
104
+ function elementDe(cible) {
105
+ const noeud = cible;
106
+ if (!noeud || typeof noeud.nodeType !== 'number')
107
+ return null;
108
+ return noeud.nodeType === 1 ? noeud : noeud.parentElement;
109
+ }
110
+ /**
111
+ * Démarre l'aperçu dans `fenetre` : écoute les messages de l'admin (`origineAdmin`, fenêtre parente).
112
+ * Renvoie une fonction d'arrêt.
113
+ */
114
+ export function demarrerApercu(fenetre, origineAdmin) {
115
+ const document = fenetre.document;
116
+ if (fenetre.parent === fenetre)
117
+ return () => { };
118
+ let actif = false;
119
+ let feuille = null;
120
+ let etiquette = null;
121
+ let info = null;
122
+ let minuterieInfo;
123
+ let zoneActive = null;
124
+ const indisponibles = new Set();
125
+ const marques = () => Array.from(document.querySelectorAll('[data-cms]'));
126
+ const marquesDe = (chemin) => marques().filter((e) => e.getAttribute('data-cms') === chemin);
127
+ function envoyer(message) {
128
+ fenetre.parent.postMessage(message, origineAdmin);
129
+ }
130
+ function installerStyles() {
131
+ try {
132
+ const Feuille = fenetre.CSSStyleSheet;
133
+ if (typeof Feuille !== 'function' || !Array.isArray(document.adoptedStyleSheets))
134
+ return;
135
+ const nouvelle = new Feuille();
136
+ nouvelle.replaceSync(STYLES);
137
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, nouvelle];
138
+ feuille = nouvelle;
139
+ }
140
+ catch {
141
+ feuille = null;
142
+ }
143
+ }
144
+ function marquerZones() {
145
+ for (const element of marques())
146
+ element.classList.add(CLASSE_ZONE);
147
+ }
148
+ function placer(boite, gauche, haut) {
149
+ const largeur = fenetre.innerWidth || 0;
150
+ boite.style.setProperty('left', `${Math.max(0, Math.min(gauche, largeur - boite.offsetWidth - 4))}px`);
151
+ boite.style.setProperty('top', `${Math.max(0, haut)}px`);
152
+ }
153
+ function placerEtiquette() {
154
+ if (!etiquette || !zoneActive)
155
+ return;
156
+ const cible = marquesDe(zoneActive.chemin)[0];
157
+ if (!cible)
158
+ return;
159
+ const rect = cible.getBoundingClientRect();
160
+ const haut = rect.top - etiquette.offsetHeight - 4;
161
+ placer(etiquette, rect.left, haut < 0 ? rect.bottom + 4 : haut);
162
+ }
163
+ function retirerActif() {
164
+ for (const element of document.querySelectorAll(`.${CLASSE_ACTIVE}`))
165
+ element.classList.remove(CLASSE_ACTIVE);
166
+ etiquette?.remove();
167
+ }
168
+ function afficherActif(defiler) {
169
+ retirerActif();
170
+ if (!zoneActive)
171
+ return;
172
+ const elements = marquesDe(zoneActive.chemin);
173
+ if (elements.length === 0)
174
+ return;
175
+ for (const element of elements)
176
+ element.classList.add(CLASSE_ACTIVE);
177
+ const morceaux = [zoneActive.libelle, indisponibles.has(zoneActive.chemin) ? TEXTE_INDISPONIBLE : ''].filter(Boolean);
178
+ if (morceaux.length > 0) {
179
+ etiquette ??= document.createElement('span');
180
+ etiquette.className = CLASSE_ETIQUETTE;
181
+ etiquette.setAttribute('aria-hidden', 'true');
182
+ etiquette.textContent = morceaux.join(' — ');
183
+ (document.body ?? document.documentElement).appendChild(etiquette);
184
+ placerEtiquette();
185
+ }
186
+ if (defiler) {
187
+ const premier = elements[0];
188
+ const reduit = typeof fenetre.matchMedia === 'function' && fenetre.matchMedia('(prefers-reduced-motion: reduce)').matches;
189
+ if (typeof premier.scrollIntoView === 'function') {
190
+ premier.scrollIntoView({ behavior: reduit ? 'auto' : 'smooth', block: 'nearest', inline: 'nearest' });
191
+ }
192
+ }
193
+ }
194
+ function retirerInfo() {
195
+ if (minuterieInfo !== undefined)
196
+ fenetre.clearTimeout(minuterieInfo);
197
+ minuterieInfo = undefined;
198
+ info?.remove();
199
+ info = null;
200
+ }
201
+ function afficherInfo(gauche, haut) {
202
+ retirerInfo();
203
+ const bulle = document.createElement('div');
204
+ bulle.className = CLASSE_INFO;
205
+ bulle.setAttribute('role', 'status');
206
+ bulle.style.setProperty('position', 'fixed');
207
+ bulle.style.setProperty('z-index', '2147483647');
208
+ bulle.style.setProperty('pointer-events', 'none');
209
+ bulle.textContent = TEXTE_LIEN_DESACTIVE;
210
+ (document.body ?? document.documentElement).appendChild(bulle);
211
+ placer(bulle, gauche + 12, haut + 12);
212
+ info = bulle;
213
+ minuterieInfo = fenetre.setTimeout(retirerInfo, DUREE_INFO);
214
+ }
215
+ function surClic(evenement) {
216
+ const element = elementDe(evenement.target);
217
+ if (!element)
218
+ return;
219
+ const zone = element.closest('[data-cms]');
220
+ const chemin = zone?.getAttribute('data-cms');
221
+ if (chemin && MOTIF_CHEMIN_CMS.test(chemin)) {
222
+ evenement.preventDefault();
223
+ envoyer({ cms: 1, type: 'clic', chemin });
224
+ return;
225
+ }
226
+ const lien = element.closest('a[href], area[href]');
227
+ if (!lien)
228
+ return;
229
+ evenement.preventDefault();
230
+ let adresse = null;
231
+ try {
232
+ adresse = new URL(lien.getAttribute('href') ?? '', document.baseURI);
233
+ }
234
+ catch {
235
+ adresse = null;
236
+ }
237
+ if (adresse && adresse.origin === fenetre.location.origin) {
238
+ const message = lireMessage({ cms: 1, type: 'lien', adresse: adresse.pathname });
239
+ if (message) {
240
+ envoyer(message);
241
+ return;
242
+ }
243
+ }
244
+ afficherInfo(evenement.clientX, evenement.clientY);
245
+ }
246
+ function surAuxClic(evenement) {
247
+ // Clic du milieu sur un lien : ouvrirait la page hors de l'aperçu.
248
+ if (elementDe(evenement.target)?.closest('a[href], area[href]'))
249
+ evenement.preventDefault();
250
+ }
251
+ function surEnvoi(evenement) {
252
+ evenement.preventDefault();
253
+ const formulaire = elementDe(evenement.target);
254
+ const rect = formulaire?.getBoundingClientRect();
255
+ afficherInfo(rect ? rect.left : 0, rect ? rect.top : 0);
256
+ }
257
+ function surDefilement() {
258
+ placerEtiquette();
259
+ }
260
+ function activer() {
261
+ actif = true;
262
+ installerStyles();
263
+ fenetre.addEventListener('click', surClic, true);
264
+ fenetre.addEventListener('auxclick', surAuxClic, true);
265
+ fenetre.addEventListener('submit', surEnvoi, true);
266
+ fenetre.addEventListener('scroll', surDefilement, { capture: true, passive: true });
267
+ fenetre.addEventListener('resize', surDefilement, { passive: true });
268
+ }
269
+ function surMessage(evenement) {
270
+ if (evenement.origin !== origineAdmin || evenement.source !== fenetre.parent)
271
+ return;
272
+ const message = lireMessage(evenement.data);
273
+ if (!message)
274
+ return;
275
+ if (message.type === 'bonjour') {
276
+ if (!actif)
277
+ activer();
278
+ marquerZones();
279
+ const chemins = [...new Set(marques().map((e) => e.getAttribute('data-cms') ?? ''))]
280
+ .filter((chemin) => MOTIF_CHEMIN_CMS.test(chemin))
281
+ .slice(0, MAX_CHEMINS);
282
+ envoyer({ cms: 1, type: 'pret', chemins });
283
+ return;
284
+ }
285
+ if (!actif)
286
+ return;
287
+ if (message.type === 'valeurs') {
288
+ marquerZones();
289
+ for (const [chemin, valeur] of Object.entries(message.valeurs)) {
290
+ if (valeur.type === 'indisponible')
291
+ indisponibles.add(chemin);
292
+ else
293
+ indisponibles.delete(chemin);
294
+ for (const element of marquesDe(chemin))
295
+ appliquerValeur(element, valeur);
296
+ }
297
+ afficherActif(false);
298
+ }
299
+ else if (message.type === 'actif') {
300
+ marquerZones();
301
+ zoneActive = message.chemin === null ? null : { chemin: message.chemin, libelle: message.libelle ?? '' };
302
+ afficherActif(true);
303
+ }
304
+ }
305
+ fenetre.addEventListener('message', surMessage);
306
+ return () => {
307
+ fenetre.removeEventListener('message', surMessage);
308
+ fenetre.removeEventListener('click', surClic, true);
309
+ fenetre.removeEventListener('auxclick', surAuxClic, true);
310
+ fenetre.removeEventListener('submit', surEnvoi, true);
311
+ fenetre.removeEventListener('scroll', surDefilement, true);
312
+ fenetre.removeEventListener('resize', surDefilement);
313
+ retirerInfo();
314
+ retirerActif();
315
+ if (feuille) {
316
+ const retiree = feuille;
317
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((f) => f !== retiree);
318
+ }
319
+ actif = false;
320
+ };
321
+ }
@@ -15,6 +15,7 @@ declare const TYPE_ASTRO = "content_layer";
15
15
  export declare function defineSingleton<S extends Forme>(id: string, o: {
16
16
  libelle: string;
17
17
  adresse?: string;
18
+ apercu?: string;
18
19
  schema: S;
19
20
  }): {
20
21
  type: typeof TYPE_ASTRO;
@@ -17,9 +17,14 @@ export function defineSingleton(id, o) {
17
17
  if (o.adresse !== undefined && !RE_ADRESSE.test(o.adresse)) {
18
18
  throw new Error(`adresse « ${o.adresse} » invalide : attendu ${RE_ADRESSE.source}`);
19
19
  }
20
+ if (o.apercu !== undefined && !RE_ADRESSE.test(o.apercu)) {
21
+ throw new Error(`apercu « ${o.apercu} » invalide : attendu ${RE_ADRESSE.source}`);
22
+ }
20
23
  const decl = { type: 'singleton', id, libelle: o.libelle };
21
24
  if (o.adresse !== undefined)
22
25
  decl.adresse = o.adresse;
26
+ if (o.apercu !== undefined)
27
+ decl.apercu = o.apercu;
23
28
  return {
24
29
  type: TYPE_ASTRO,
25
30
  loader: glob({ pattern: `${id}.json`, base: './src/content/pages' }),
package/dist/images.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MOTIF_SRC_IMAGE } from './champs.js';
1
+ import { MOTIF_SRC_IMAGE } from './motifs.js';
2
2
  export const LARGEURS = [800, 1600, 2400];
3
3
  export function variantes(src) {
4
4
  if (!MOTIF_SRC_IMAGE.test(src)) {
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { lireMeta } from './types.js';
4
4
  export type { Editeur, MetaCms, OptionsBase, DeclSingleton, DeclCollection } from './types.js';
5
5
  export { ErreurCms } from './noyau/erreurs.js';
6
6
  export { defineSingleton, defineCollection, lireDecl, CMS } from './collections.js';
7
+ export { cms } from './marqueur.js';
7
8
  export { creerAjv, MOTS_CLES_CMS } from './ajv.js';
8
9
  export { versManifest } from './noyau/manifest.js';
9
10
  export type { OptionsCms } from './noyau/manifest.js';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { champ } from './champs.js';
3
3
  export { lireMeta } from './types.js';
4
4
  export { ErreurCms } from './noyau/erreurs.js';
5
5
  export { defineSingleton, defineCollection, lireDecl, CMS } from './collections.js';
6
+ export { cms } from './marqueur.js';
6
7
  export { creerAjv, MOTS_CLES_CMS } from './ajv.js';
7
8
  export { versManifest } from './noyau/manifest.js';
8
9
  export { HORS_PERIMETRE_SOCLE } from './socle/hors-perimetre.js';
@@ -1,3 +1,4 @@
1
+ import { promises as fsp } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { chargerConfigAstro } from './noyau/charger.js';
@@ -5,6 +6,8 @@ import { formaterViolations } from './noyau/contenus.js';
5
6
  import { MOTIF_SRC_IMAGE, MOTIF_URL_SITE } from './motifs.js';
6
7
  import { ErreurCms } from './noyau/erreurs.js';
7
8
  import { synchroniser, verifier } from './noyau/operations.js';
9
+ import { ecrireApercu } from './noyau/apercu-build.js';
10
+ import { verifierMarqueurs } from './noyau/marqueurs.js';
8
11
  import { ecrireVersion } from './noyau/version.js';
9
12
  const NOM = '@komekoo/astro-cms';
10
13
  /** `Symbol.for` : la même clé que l'intégration soit chargée par Node ou par Vite (`chargerOptions`). */
@@ -31,9 +34,9 @@ function verifierOptions(options) {
31
34
  if (admin === undefined)
32
35
  return;
33
36
  if (typeof admin !== 'object' || admin === null || Array.isArray(admin)) {
34
- return refuser('admin', 'doit être un objet { accent?, logo?, origine? }');
37
+ return refuser('admin', 'doit être un objet { accent?, logo?, origine?, apercu? }');
35
38
  }
36
- const { accent, logo, origine } = admin;
39
+ const { accent, logo, origine, apercu } = admin;
37
40
  if (accent !== undefined && !(typeof accent === 'string' && /^#[0-9A-Fa-f]{6}$/.test(accent))) {
38
41
  refuser('admin.accent', 'doit être une couleur au format #RRGGBB');
39
42
  }
@@ -43,15 +46,20 @@ function verifierOptions(options) {
43
46
  if (origine !== undefined && !(typeof origine === 'string' && /^https:\/\/[^\s/]+$/.test(origine))) {
44
47
  refuser('admin.origine', 'doit être une origine https sans chemin (ex. https://admin.exemple.fr)');
45
48
  }
49
+ if (apercu !== undefined && !(typeof apercu === 'string' && MOTIF_URL_SITE.test(apercu) && !apercu.endsWith('/'))) {
50
+ refuser('admin.apercu', 'doit être une adresse https sans « / » final (ex. https://apercu.exemple.pages.dev)');
51
+ }
46
52
  }
47
53
  export default function cms(options) {
48
54
  verifierOptions(options);
55
+ let racineProjet = '';
49
56
  return {
50
57
  name: NOM,
51
58
  [OPTIONS]: options,
52
59
  hooks: {
53
60
  'astro:config:setup': async ({ config, command, addWatchFile, logger }) => {
54
61
  const racine = fileURLToPath(config.root);
62
+ racineProjet = racine;
55
63
  if (command === 'dev' || command === 'sync')
56
64
  await synchroniser(racine, options);
57
65
  if (command === 'dev')
@@ -65,8 +73,22 @@ export default function cms(options) {
65
73
  throw new ErreurCms(`${violations.length} problème(s) de contenu`);
66
74
  }
67
75
  },
68
- 'astro:build:done': async ({ dir }) => {
69
- await ecrireVersion(fileURLToPath(dir), process.env.CF_PAGES_COMMIT_SHA ?? 'inconnu', options.admin?.origine ?? 'https://cms.komekoo.link');
76
+ 'astro:build:done': async ({ dir, logger }) => {
77
+ const dist = fileURLToPath(dir);
78
+ const schema = JSON.parse(await fsp.readFile(path.join(racineProjet, 'cms.schema.json'), 'utf8'));
79
+ const erreurs = await verifierMarqueurs(dist, schema.singletons ?? {});
80
+ if (erreurs.length > 0) {
81
+ for (const e of erreurs)
82
+ logger.error(`${e.page} — ${e.chemin} — ${e.motif}`);
83
+ throw new ErreurCms(`${erreurs.length} marqueur(s) d'aperçu invalide(s)`);
84
+ }
85
+ const origineAdmin = options.admin?.origine ?? 'https://cms.komekoo.link';
86
+ await ecrireVersion(dist, process.env.CF_PAGES_COMMIT_SHA ?? 'inconnu', origineAdmin);
87
+ await ecrireApercu(dist, {
88
+ origineAdmin,
89
+ branche: process.env.CF_PAGES_BRANCH,
90
+ avertir: (m) => logger.warn(m),
91
+ });
70
92
  },
71
93
  },
72
94
  };
@@ -0,0 +1,4 @@
1
+ /** Marqueur d'aperçu : `<h1 {...cms('accueil.titre')}>` pose `data-cms` pour que l'admin retrouve le champ. */
2
+ export declare function cms(chemin: string): {
3
+ 'data-cms': string;
4
+ };
@@ -0,0 +1,8 @@
1
+ import { MOTIF_CHEMIN_CMS } from './motifs.js';
2
+ /** Marqueur d'aperçu : `<h1 {...cms('accueil.titre')}>` pose `data-cms` pour que l'admin retrouve le champ. */
3
+ export function cms(chemin) {
4
+ if (!MOTIF_CHEMIN_CMS.test(chemin)) {
5
+ throw new Error(`chemin d'aperçu « ${chemin} » invalide : attendu ${MOTIF_CHEMIN_CMS.source}`);
6
+ }
7
+ return { 'data-cms': chemin };
8
+ }
package/dist/motifs.d.ts CHANGED
@@ -6,3 +6,5 @@ export declare const MOTIF_SRC_IMAGE: RegExp;
6
6
  export declare const MOTIF_LIEN: RegExp;
7
7
  /** Adresse publique du site : https, sans espace, sans « ? » ni « # » (le « / » final est refusé à part). */
8
8
  export declare const MOTIF_URL_SITE: RegExp;
9
+ /** Chemin d'un champ dans une page : id du singleton, puis noms de champ ou indices (`reglages.horaires.2.ouverture`). */
10
+ export declare const MOTIF_CHEMIN_CMS: RegExp;
package/dist/motifs.js CHANGED
@@ -7,3 +7,5 @@ export const MOTIF_SRC_IMAGE = /^\/uploads\/(?:[a-z0-9-]+\/)*[a-z0-9-]+-2400\.we
7
7
  export const MOTIF_LIEN = /^(https:\/\/[^\s\x00-\x1f]|\/(?![\/\\\s])|mailto:|tel:)[^\s\x00-\x1f]*$/;
8
8
  /** Adresse publique du site : https, sans espace, sans « ? » ni « # » (le « / » final est refusé à part). */
9
9
  export const MOTIF_URL_SITE = /^https:\/\/[^\s/?#]+(\/[^\s?#]*)?$/;
10
+ /** Chemin d'un champ dans une page : id du singleton, puis noms de champ ou indices (`reglages.horaires.2.ouverture`). */
11
+ export const MOTIF_CHEMIN_CMS = /^[a-z][a-z0-9-]*(\.([A-Za-z][A-Za-z0-9]*|\d+))+$/;
@@ -2,4 +2,5 @@ export { creerAjv, MOTS_CLES_CMS } from './ajv.js';
2
2
  export { validerMarkdown } from './noyau/markdown.js';
3
3
  export type { Violation } from './noyau/markdown.js';
4
4
  export { lireFrontmatter, ecrireFrontmatter } from './noyau/frontmatter.js';
5
- export { MOTIF_LIEN, MOTIF_SRC_IMAGE, MOTIF_URL_SITE } from './motifs.js';
5
+ export { MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE, MOTIF_URL_SITE } from './motifs.js';
6
+ export { srcsetDe, variantes } from './images.js';
@@ -2,4 +2,5 @@
2
2
  export { creerAjv, MOTS_CLES_CMS } from './ajv.js';
3
3
  export { validerMarkdown } from './noyau/markdown.js';
4
4
  export { lireFrontmatter, ecrireFrontmatter } from './noyau/frontmatter.js';
5
- export { MOTIF_LIEN, MOTIF_SRC_IMAGE, MOTIF_URL_SITE } from './motifs.js';
5
+ export { MOTIF_CHEMIN_CMS, MOTIF_LIEN, MOTIF_SRC_IMAGE, MOTIF_URL_SITE } from './motifs.js';
6
+ export { srcsetDe, variantes } from './images.js';
@@ -0,0 +1,12 @@
1
+ /** Contenu de `_cms/chargeur.js` : fichier externe (compatible avec une CSP sans `unsafe-inline`). */
2
+ export declare const CHARGEUR = "(function () {\n if (window.parent === window) return;\n if (!new URLSearchParams(location.search).has('cms-apercu')) return;\n var script = document.createElement('script');\n script.type = 'module';\n script.src = '/_cms/apercu/demarrage.js';\n document.head.appendChild(script);\n})();\n";
3
+ /**
4
+ * Ajoute au site construit le chargeur et le script d'aperçu (modules ES), la balise du chargeur dans
5
+ * chaque page HTML, et les en-têtes `_headers` qui autorisent l'affichage dans l'admin.
6
+ */
7
+ export declare function ecrireApercu(dossierDist: string, o: {
8
+ origineAdmin: string;
9
+ branche?: string;
10
+ sources?: string;
11
+ avertir?: (message: string) => void;
12
+ }): Promise<void>;
@@ -0,0 +1,74 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const PLACEHOLDER = "'__KOMEKOO_ORIGINE_ADMIN__'";
5
+ const BALISE = '<script src="/_cms/chargeur.js" async></script>';
6
+ const MARQUE_CSP = ' Content-Security-Policy: frame-ancestors';
7
+ const AVERTISSEMENT = "L'aperçu de l'admin ne pourra pas afficher ce site tant que X-Frame-Options est présent.";
8
+ /** Fichiers du script d'aperçu : chemin source (relatif à `dist/` du paquet) → chemin dans `<dist>/_cms/`. */
9
+ const MODULES = ['apercu/demarrage.js', 'apercu/script.js', 'apercu/protocole.js', 'motifs.js'];
10
+ /** Contenu de `_cms/chargeur.js` : fichier externe (compatible avec une CSP sans `unsafe-inline`). */
11
+ export const CHARGEUR = `(function () {
12
+ if (window.parent === window) return;
13
+ if (!new URLSearchParams(location.search).has('cms-apercu')) return;
14
+ var script = document.createElement('script');
15
+ script.type = 'module';
16
+ script.src = '/_cms/apercu/demarrage.js';
17
+ document.head.appendChild(script);
18
+ })();
19
+ `;
20
+ async function listerHtml(dossier) {
21
+ const entrees = await fsp.readdir(dossier, { withFileTypes: true });
22
+ const fichiers = await Promise.all(entrees.map(async (e) => {
23
+ const chemin = path.join(dossier, e.name);
24
+ if (e.isDirectory())
25
+ return listerHtml(chemin);
26
+ return e.name.endsWith('.html') ? [chemin] : [];
27
+ }));
28
+ return fichiers.flat();
29
+ }
30
+ async function injecterBalise(fichier) {
31
+ const html = await fsp.readFile(fichier, 'utf8');
32
+ if (html.includes(BALISE))
33
+ return;
34
+ const i = html.indexOf('</head>');
35
+ if (i === -1)
36
+ return;
37
+ await fsp.writeFile(fichier, html.slice(0, i) + BALISE + html.slice(i));
38
+ }
39
+ /**
40
+ * Ajoute au site construit le chargeur et le script d'aperçu (modules ES), la balise du chargeur dans
41
+ * chaque page HTML, et les en-têtes `_headers` qui autorisent l'affichage dans l'admin.
42
+ */
43
+ export async function ecrireApercu(dossierDist, o) {
44
+ const sources = o.sources ?? fileURLToPath(new URL('../../dist/', import.meta.url));
45
+ await fsp.mkdir(path.join(dossierDist, '_cms/apercu'), { recursive: true });
46
+ await fsp.writeFile(path.join(dossierDist, '_cms/chargeur.js'), CHARGEUR);
47
+ for (const module of MODULES) {
48
+ let contenu = await fsp.readFile(path.join(sources, module), 'utf8');
49
+ if (module === 'apercu/demarrage.js')
50
+ contenu = contenu.replaceAll(PLACEHOLDER, JSON.stringify(o.origineAdmin));
51
+ await fsp.writeFile(path.join(dossierDist, '_cms', module), contenu);
52
+ }
53
+ for (const fichier of await listerHtml(dossierDist))
54
+ await injecterBalise(fichier);
55
+ const fichier = path.join(dossierDist, '_headers');
56
+ let existant = '';
57
+ try {
58
+ existant = await fsp.readFile(fichier, 'utf8');
59
+ }
60
+ catch (e) {
61
+ if (e.code !== 'ENOENT')
62
+ throw e;
63
+ }
64
+ if (o.avertir && existant.split('\n').some((l) => /x-frame-options:.*\b(deny|sameorigin)\b/i.test(l))) {
65
+ o.avertir(AVERTISSEMENT);
66
+ }
67
+ if (existant.split('\n').some((l) => l.startsWith(MARQUE_CSP)))
68
+ return;
69
+ let regle = `/*\n${MARQUE_CSP} 'self' ${o.origineAdmin}\n`;
70
+ if (o.branche !== undefined && o.branche !== 'main')
71
+ regle += ' X-Robots-Tag: noindex\n';
72
+ const separateur = existant === '' || existant.endsWith('\n') ? '' : '\n';
73
+ await fsp.writeFile(fichier, existant + separateur + regle);
74
+ }
@@ -6,6 +6,7 @@ export interface ModeleSingleton {
6
6
  /** `src/content/pages/<id>.json` */
7
7
  fichier: string;
8
8
  adresse?: string;
9
+ apercu?: string;
9
10
  schema: z.ZodObject;
10
11
  }
11
12
  export interface ModeleCollection {
@@ -70,6 +70,7 @@ export function collecter(collections) {
70
70
  libelle: decl.libelle,
71
71
  fichier: `src/content/pages/${decl.id}.json`,
72
72
  ...(decl.adresse !== undefined && { adresse: decl.adresse }),
73
+ ...(decl.apercu !== undefined && { apercu: decl.apercu }),
73
74
  schema,
74
75
  });
75
76
  }
@@ -27,6 +27,7 @@ export function versJsonSchema(modele, options) {
27
27
  'x-cms-libelle': s.libelle,
28
28
  'x-cms-fichier': s.fichier,
29
29
  ...(s.adresse !== undefined && { 'x-cms-adresse': s.adresse }),
30
+ ...(s.apercu !== undefined && { 'x-cms-apercu': s.apercu }),
30
31
  ...convertir(s.schema),
31
32
  };
32
33
  }
@@ -47,6 +48,8 @@ export function versJsonSchema(modele, options) {
47
48
  const site = { nom: options.site, url: options.url, accent: options.admin?.accent ?? ACCENT_DEFAUT };
48
49
  if (options.admin?.logo !== undefined)
49
50
  site.logo = options.admin.logo;
51
+ if (options.admin?.apercu !== undefined)
52
+ site.apercu = options.admin.apercu;
50
53
  site.contact = options.contact;
51
54
  return {
52
55
  $schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -9,6 +9,7 @@ export interface OptionsCms {
9
9
  accent?: string;
10
10
  logo?: string;
11
11
  origine?: string;
12
+ apercu?: string;
12
13
  };
13
14
  }
14
15
  /** Vue du modèle pour l'IA : langage naturel, aucun chemin de fichier. */
@@ -3,6 +3,7 @@ export interface Violation {
3
3
  ligne?: number;
4
4
  motif: string;
5
5
  }
6
+ export declare const analyseur: import("unified").Processor<import("mdast").Root, undefined, undefined, undefined, undefined>;
6
7
  export declare function validerMarkdown(source: string, decalageLigne?: number): Violation[];
7
8
  /** Images (`![](url)`) du source, avec leur ligne (1-based) et leur URL brute. */
8
9
  export declare function extraireImages(source: string): {
@@ -2,7 +2,7 @@ import remarkGfm from 'remark-gfm';
2
2
  import remarkParse from 'remark-parse';
3
3
  import { unified } from 'unified';
4
4
  import { MOTIF_LIEN, MOTIF_SRC_IMAGE } from '../motifs.js';
5
- const analyseur = unified().use(remarkParse).use(remarkGfm);
5
+ export const analyseur = unified().use(remarkParse).use(remarkGfm);
6
6
  const AUTORISES = new Set([
7
7
  'root', 'paragraph', 'text', 'heading', 'strong', 'emphasis', 'link', 'list',
8
8
  'listItem', 'blockquote', 'image', 'table', 'tableRow', 'tableCell', 'break',
@@ -0,0 +1,11 @@
1
+ /** `singletons` de `cms.schema.json`. */
2
+ export type SchemaSingletons = Record<string, object>;
3
+ export interface ErreurMarqueur {
4
+ page: string;
5
+ chemin: string;
6
+ motif: string;
7
+ }
8
+ /** Contrôle les marqueurs `data-cms` d'une page HTML produite. */
9
+ export declare function verifierMarqueursHtml(html: string, page: string, schema: SchemaSingletons): ErreurMarqueur[];
10
+ /** Contrôle toutes les pages HTML de `dossierDist` ; `page` est relative à ce dossier (séparateur `/`). */
11
+ export declare function verifierMarqueurs(dossierDist: string, schema: SchemaSingletons): Promise<ErreurMarqueur[]>;
@@ -0,0 +1,57 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import path from 'node:path';
3
+ const MARQUEUR = /<(\w+)\b[^>]*\sdata-cms="([^"]*)"/g;
4
+ const NUMERIQUE = /^\d+$/;
5
+ function resoudre(chemin, schema) {
6
+ const [id = '', ...suite] = chemin.split('.');
7
+ if (!Object.hasOwn(schema, id))
8
+ return 'page inconnue';
9
+ let noeud = schema[id];
10
+ for (const segment of suite) {
11
+ const enfant = NUMERIQUE.test(segment) && noeud.items !== undefined
12
+ ? noeud.items
13
+ : noeud.properties !== undefined && Object.hasOwn(noeud.properties, segment)
14
+ ? noeud.properties[segment]
15
+ : undefined;
16
+ if (enfant === undefined)
17
+ return 'champ inconnu';
18
+ noeud = enfant;
19
+ }
20
+ return noeud;
21
+ }
22
+ /** Contrôle les marqueurs `data-cms` d'une page HTML produite. */
23
+ export function verifierMarqueursHtml(html, page, schema) {
24
+ const erreurs = [];
25
+ for (const [, balise, chemin] of html.matchAll(MARQUEUR)) {
26
+ const noeud = resoudre(chemin, schema);
27
+ const motif = typeof noeud === 'string'
28
+ ? noeud
29
+ : noeud['x-cms']?.editeur === 'image' && balise.toLowerCase() !== 'img'
30
+ ? "image hors d'une balise <img>"
31
+ : noeud['x-cms']?.editeur === 'lien' && balise.toLowerCase() !== 'a'
32
+ ? "lien hors d'une balise <a>"
33
+ : undefined;
34
+ if (motif !== undefined)
35
+ erreurs.push({ page, chemin: chemin, motif });
36
+ }
37
+ return erreurs;
38
+ }
39
+ async function listerHtml(dossier, prefixe = '') {
40
+ const entrees = await fsp.readdir(path.join(dossier, prefixe), { withFileTypes: true });
41
+ const fichiers = await Promise.all(entrees.map(async (e) => {
42
+ const relatif = prefixe === '' ? e.name : `${prefixe}/${e.name}`;
43
+ if (e.isDirectory())
44
+ return listerHtml(dossier, relatif);
45
+ return e.name.endsWith('.html') ? [relatif] : [];
46
+ }));
47
+ return fichiers.flat().sort();
48
+ }
49
+ /** Contrôle toutes les pages HTML de `dossierDist` ; `page` est relative à ce dossier (séparateur `/`). */
50
+ export async function verifierMarqueurs(dossierDist, schema) {
51
+ const erreurs = [];
52
+ for (const page of await listerHtml(dossierDist)) {
53
+ const html = await fsp.readFile(path.join(dossierDist, page), 'utf8');
54
+ erreurs.push(...verifierMarqueursHtml(html, page, schema));
55
+ }
56
+ return erreurs;
57
+ }
package/dist/types.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface DeclSingleton {
20
20
  id: string;
21
21
  libelle: string;
22
22
  adresse?: string;
23
+ apercu?: string;
23
24
  }
24
25
  export interface DeclCollection {
25
26
  type: 'collection';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@komekoo/astro-cms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -19,6 +19,14 @@
19
19
  "types": "./dist/navigateur.d.ts",
20
20
  "default": "./dist/navigateur.js"
21
21
  },
22
+ "./apercu": {
23
+ "types": "./dist/apercu/index.d.ts",
24
+ "default": "./dist/apercu/index.js"
25
+ },
26
+ "./apercu/arbre": {
27
+ "types": "./dist/apercu/arbre.d.ts",
28
+ "default": "./dist/apercu/arbre.js"
29
+ },
22
30
  "./CmsImage.astro": "./src/composants/CmsImage.astro"
23
31
  },
24
32
  "bin": {
@@ -36,6 +44,7 @@
36
44
  "@types/mdast": "4.0.4",
37
45
  "@types/node": "26.6.5",
38
46
  "astro": "7.4.2",
47
+ "happy-dom": "21.0.2",
39
48
  "typescript": "7.0.2",
40
49
  "vite": "8.3.5",
41
50
  "vitest": "5.0.5"
@@ -6,9 +6,10 @@ interface Props {
6
6
  sizes?: string;
7
7
  class?: string;
8
8
  loading?: 'lazy' | 'eager';
9
+ cms?: string;
9
10
  }
10
11
 
11
- const { image, sizes = '100vw', class: classe, loading = 'lazy' } = Astro.props;
12
+ const { image, sizes = '100vw', class: classe, loading = 'lazy', cms } = Astro.props;
12
13
  ---
13
14
 
14
15
  <img
@@ -21,4 +22,5 @@ const { image, sizes = '100vw', class: classe, loading = 'lazy' } = Astro.props;
21
22
  loading={loading}
22
23
  decoding="async"
23
24
  class={classe}
25
+ data-cms={cms}
24
26
  />