@komekoo/astro-cms 0.0.0-stage → 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 +144 -2
- package/dist/ajv.d.ts +4 -0
- package/dist/ajv.js +24 -0
- package/dist/apercu/arbre.d.ts +3 -0
- package/dist/apercu/arbre.js +64 -0
- package/dist/apercu/demarrage.d.ts +1 -0
- package/dist/apercu/demarrage.js +5 -0
- package/dist/apercu/index.d.ts +2 -0
- package/dist/apercu/index.js +2 -0
- package/dist/apercu/protocole.d.ts +67 -0
- package/dist/apercu/protocole.js +176 -0
- package/dist/apercu/script.d.ts +8 -0
- package/dist/apercu/script.js +321 -0
- package/dist/champs.d.ts +49 -0
- package/dist/champs.js +58 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +70 -0
- package/dist/collections.d.ts +39 -0
- package/dist/collections.js +57 -0
- package/dist/images.d.ts +8 -0
- package/dist/images.js +19 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/integration.d.ts +9 -0
- package/dist/integration.js +113 -0
- package/dist/marqueur.d.ts +4 -0
- package/dist/marqueur.js +8 -0
- package/dist/motifs.d.ts +10 -0
- package/dist/motifs.js +11 -0
- package/dist/navigateur.d.ts +6 -0
- package/dist/navigateur.js +6 -0
- package/dist/noyau/apercu-build.d.ts +12 -0
- package/dist/noyau/apercu-build.js +74 -0
- package/dist/noyau/charger.d.ts +4 -0
- package/dist/noyau/charger.js +48 -0
- package/dist/noyau/collecter.d.ts +27 -0
- package/dist/noyau/collecter.js +133 -0
- package/dist/noyau/contenus.d.ts +6 -0
- package/dist/noyau/contenus.js +218 -0
- package/dist/noyau/erreurs.d.ts +7 -0
- package/dist/noyau/erreurs.js +14 -0
- package/dist/noyau/frontmatter.d.ts +15 -0
- package/dist/noyau/frontmatter.js +70 -0
- package/dist/noyau/json-schema.d.ts +4 -0
- package/dist/noyau/json-schema.js +61 -0
- package/dist/noyau/manifest.d.ts +16 -0
- package/dist/noyau/manifest.js +136 -0
- package/dist/noyau/markdown.d.ts +12 -0
- package/dist/noyau/markdown.js +95 -0
- package/dist/noyau/marqueurs.d.ts +11 -0
- package/dist/noyau/marqueurs.js +57 -0
- package/dist/noyau/operations.d.ts +12 -0
- package/dist/noyau/operations.js +54 -0
- package/dist/noyau/serialiser.d.ts +2 -0
- package/dist/noyau/serialiser.js +25 -0
- package/dist/noyau/version.d.ts +5 -0
- package/dist/noyau/version.js +25 -0
- package/dist/socle/hors-perimetre.d.ts +2 -0
- package/dist/socle/hors-perimetre.js +9 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +10 -0
- package/package.json +62 -3
- package/src/composants/CmsImage.astro +26 -0
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @komekoo/astro-cms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CMS git pour sites Astro : les contenus sont des fichiers JSON et Markdown dans `src/content/`, décrits par des
|
|
4
|
+
champs typés (`champ.*`). Le paquet génère le schéma CMS (`cms.schema.json`) et le manifeste destiné aux agents IA
|
|
5
|
+
(`ai/manifest.json`), et vérifie les contenus avant chaque `astro build`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Prérequis : Node ≥ 22.23.4, Astro ^7.4.2.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @komekoo/astro-cms
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Déclarer les contenus : `src/content.config.ts`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// N'importe pas `astro:content` ici : komekoo-cms charge ce fichier hors d'Astro (sync / check).
|
|
19
|
+
import { champ, defineCollection, defineSingleton } from '@komekoo/astro-cms';
|
|
20
|
+
|
|
21
|
+
// Singleton : un seul fichier, src/content/pages/accueil.json
|
|
22
|
+
const accueil = defineSingleton('accueil', {
|
|
23
|
+
libelle: "Page d'accueil",
|
|
24
|
+
schema: {
|
|
25
|
+
titre: champ.texte({ libelle: 'Titre principal', max: 80 }),
|
|
26
|
+
image: champ.image({ libelle: 'Image principale' }),
|
|
27
|
+
presentation: champ.richtext({ libelle: 'Présentation' }),
|
|
28
|
+
reservation: champ.lien({ libelle: 'Lien de réservation', obligatoire: false }),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Collection : un fichier par élément, dans src/content/tarifs/
|
|
33
|
+
const tarifs = defineCollection('tarifs', {
|
|
34
|
+
libelle: 'Tarifs',
|
|
35
|
+
format: 'json', // ou 'markdown' (frontmatter YAML + corps)
|
|
36
|
+
titre: 'prestation', // champ texte servant de titre dans l'admin
|
|
37
|
+
ordonnable: true, // ajoute un champ `ordre` (entier ≥ 0)
|
|
38
|
+
schema: {
|
|
39
|
+
prestation: champ.texte({ libelle: 'Prestation', max: 80 }),
|
|
40
|
+
categorie: champ.choix({ libelle: 'Catégorie', options: ['Femme', 'Homme', 'Enfant'] }),
|
|
41
|
+
prix: champ.nombre({ libelle: 'Prix', unite: '€', min: 0 }),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const collections = { accueil, tarifs };
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Champs disponibles : `texte`, `paragraphe`, `richtext`, `image`, `date`, `lien`, `nombre`, `booleen`, `choix`,
|
|
49
|
+
`liste`, `groupe`. Chacun prend `libelle`, `aide` (facultatif) et `obligatoire` (`true` par défaut). Les types
|
|
50
|
+
TypeScript suivent la déclaration : un champ `obligatoire: false` est typé `T | undefined`, `choix` donne l'union
|
|
51
|
+
de ses options.
|
|
52
|
+
|
|
53
|
+
- `onglet` (facultatif, texte non vide) : range le champ dans un onglet du formulaire d'administration, par ex.
|
|
54
|
+
`onglet: 'Référencement Google'`.
|
|
55
|
+
- `adresse` (option de `defineSingleton`, facultative) : adresse publique de la page, par ex. `adresse: '/a-propos'`
|
|
56
|
+
(`/` suivi de minuscules, chiffres, `-` ou `/`). Elle permet à l'admin d'ouvrir l'aperçu de la page.
|
|
57
|
+
- Images : `src` de la forme `/uploads/…/nom-2400.webp`, avec les variantes `-800.webp`, `-1600.webp` et
|
|
58
|
+
`-2400.webp` présentes dans `public/uploads/`.
|
|
59
|
+
- Liens : `https://…`, chemin interne `/…`, `mailto:` ou `tel:`.
|
|
60
|
+
- Markdown (`richtext` et corps des articles) : titres 2 et 3, gras, italique, liens, listes, citations, images
|
|
61
|
+
`/uploads/`, tableaux GFM simples. HTML, code, barré, cases à cocher, notes et lignes horizontales sont refusés.
|
|
62
|
+
|
|
63
|
+
## Intégration : `astro.config.mjs`
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import { defineConfig } from 'astro/config';
|
|
67
|
+
import cms from '@komekoo/astro-cms/integration';
|
|
68
|
+
|
|
69
|
+
export default defineConfig({
|
|
70
|
+
integrations: [
|
|
71
|
+
cms({
|
|
72
|
+
site: 'Salon de coiffure à Vichy', // présentation du site dans ai/manifest.json (obligatoire)
|
|
73
|
+
contact: 'contact@exemple.fr', // contact proposé pour les demandes hors périmètre (obligatoire)
|
|
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', apercu: 'https://apercu.exemple.pages.dev' }, // facultatif
|
|
76
|
+
horsPerimetre: ['Modifier le menu de navigation'], // ajouts à la liste socle (facultatif)
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `astro dev` et `astro sync` régénèrent `cms.schema.json` et `ai/manifest.json`.
|
|
83
|
+
- `astro build` vérifie les fichiers générés et tous les contenus, puis échoue avec une ligne
|
|
84
|
+
`fichier:ligne — motif` par problème.
|
|
85
|
+
|
|
86
|
+
## CLI
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
komekoo-cms sync # génère cms.schema.json et ai/manifest.json
|
|
90
|
+
komekoo-cms check # vérifie les fichiers générés et valide les contenus (code 1 si problème)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Point d'entrée navigateur : `@komekoo/astro-cms/navigateur`
|
|
94
|
+
|
|
95
|
+
Pour les outils qui tournent dans un navigateur (ex. l'interface d'administration) : n'importe rien de `node:*`,
|
|
96
|
+
`astro` ni `vite`. Exporte `creerAjv` et `MOTS_CLES_CMS` (validation du schéma CMS), `validerMarkdown` (et le
|
|
97
|
+
type `Violation`), `lireFrontmatter`, `ecrireFrontmatter` (écrit `---`, YAML, `---`, corps ; dates entre
|
|
98
|
+
guillemets), `MOTIF_LIEN` et `MOTIF_SRC_IMAGE`.
|
|
99
|
+
|
|
100
|
+
Options récentes : `url` (obligatoire) et `admin` de `cms()`, `onglet` sur les champs, `adresse` sur les
|
|
101
|
+
singletons, et le bloc `x-cms-site` de `cms.schema.json`.
|
|
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
|
+
|
|
119
|
+
## Composant `<CmsImage>`
|
|
120
|
+
|
|
121
|
+
```astro
|
|
122
|
+
---
|
|
123
|
+
import CmsImage from '@komekoo/astro-cms/CmsImage.astro';
|
|
124
|
+
---
|
|
125
|
+
<CmsImage image={page.image} sizes="(min-width: 60rem) 50vw, 100vw" loading="eager" class="heros" />
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Produit un `<img>` avec `srcset` sur les variantes 800 / 1600 / 2400, `width`, `height` et `alt`.
|
|
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"`).
|
|
131
|
+
|
|
132
|
+
## Limites connues
|
|
133
|
+
|
|
134
|
+
- `src/content.config.ts` ne doit pas importer `astro:content` : il est chargé hors d'Astro par la CLI.
|
|
135
|
+
- Frontmatter TOML (`+++`) refusé : utiliser YAML (`---`). Mettre les dates entre guillemets
|
|
136
|
+
(`date: "2026-10-01"`) ; une date nue est lue comme un horodatage YAML et refusée.
|
|
137
|
+
- Node ≥ 22.23.4 (exigence d'Astro 7.4.2).
|
|
138
|
+
- Les fichiers générés (`cms.schema.json`, `ai/manifest.json`) doivent être committés ; un fichier périmé fait
|
|
139
|
+
échouer `astro build` et `komekoo-cms check`. Pour éviter les conversions CRLF sous Windows, ajouter à
|
|
140
|
+
`.gitattributes` :
|
|
141
|
+
|
|
142
|
+
```gitattributes
|
|
143
|
+
**/cms.schema.json text eol=lf
|
|
144
|
+
**/ai/manifest.json text eol=lf
|
|
145
|
+
```
|
package/dist/ajv.d.ts
ADDED
package/dist/ajv.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
export const MOTS_CLES_CMS = [
|
|
4
|
+
'x-cms',
|
|
5
|
+
'x-cms-version',
|
|
6
|
+
'x-cms-site',
|
|
7
|
+
'x-cms-libelle',
|
|
8
|
+
'x-cms-fichier',
|
|
9
|
+
'x-cms-adresse',
|
|
10
|
+
'x-cms-apercu',
|
|
11
|
+
'x-cms-dossier',
|
|
12
|
+
'x-cms-format',
|
|
13
|
+
'x-cms-titre',
|
|
14
|
+
'x-cms-corps',
|
|
15
|
+
'x-cms-ordonnable',
|
|
16
|
+
];
|
|
17
|
+
/** Ajv 2020-12 strict, formats activés, mots-clés `x-cms*` déclarés. */
|
|
18
|
+
export function creerAjv() {
|
|
19
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
20
|
+
addFormats(ajv);
|
|
21
|
+
for (const mot of MOTS_CLES_CMS)
|
|
22
|
+
ajv.addKeyword(mot);
|
|
23
|
+
return ajv;
|
|
24
|
+
}
|
|
@@ -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,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;
|