@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.
Files changed (63) hide show
  1. package/README.md +144 -2
  2. package/dist/ajv.d.ts +4 -0
  3. package/dist/ajv.js +24 -0
  4. package/dist/apercu/arbre.d.ts +3 -0
  5. package/dist/apercu/arbre.js +64 -0
  6. package/dist/apercu/demarrage.d.ts +1 -0
  7. package/dist/apercu/demarrage.js +5 -0
  8. package/dist/apercu/index.d.ts +2 -0
  9. package/dist/apercu/index.js +2 -0
  10. package/dist/apercu/protocole.d.ts +67 -0
  11. package/dist/apercu/protocole.js +176 -0
  12. package/dist/apercu/script.d.ts +8 -0
  13. package/dist/apercu/script.js +321 -0
  14. package/dist/champs.d.ts +49 -0
  15. package/dist/champs.js +58 -0
  16. package/dist/cli.d.ts +3 -0
  17. package/dist/cli.js +70 -0
  18. package/dist/collections.d.ts +39 -0
  19. package/dist/collections.js +57 -0
  20. package/dist/images.d.ts +8 -0
  21. package/dist/images.js +19 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.js +10 -0
  24. package/dist/integration.d.ts +9 -0
  25. package/dist/integration.js +113 -0
  26. package/dist/marqueur.d.ts +4 -0
  27. package/dist/marqueur.js +8 -0
  28. package/dist/motifs.d.ts +10 -0
  29. package/dist/motifs.js +11 -0
  30. package/dist/navigateur.d.ts +6 -0
  31. package/dist/navigateur.js +6 -0
  32. package/dist/noyau/apercu-build.d.ts +12 -0
  33. package/dist/noyau/apercu-build.js +74 -0
  34. package/dist/noyau/charger.d.ts +4 -0
  35. package/dist/noyau/charger.js +48 -0
  36. package/dist/noyau/collecter.d.ts +27 -0
  37. package/dist/noyau/collecter.js +133 -0
  38. package/dist/noyau/contenus.d.ts +6 -0
  39. package/dist/noyau/contenus.js +218 -0
  40. package/dist/noyau/erreurs.d.ts +7 -0
  41. package/dist/noyau/erreurs.js +14 -0
  42. package/dist/noyau/frontmatter.d.ts +15 -0
  43. package/dist/noyau/frontmatter.js +70 -0
  44. package/dist/noyau/json-schema.d.ts +4 -0
  45. package/dist/noyau/json-schema.js +61 -0
  46. package/dist/noyau/manifest.d.ts +16 -0
  47. package/dist/noyau/manifest.js +136 -0
  48. package/dist/noyau/markdown.d.ts +12 -0
  49. package/dist/noyau/markdown.js +95 -0
  50. package/dist/noyau/marqueurs.d.ts +11 -0
  51. package/dist/noyau/marqueurs.js +57 -0
  52. package/dist/noyau/operations.d.ts +12 -0
  53. package/dist/noyau/operations.js +54 -0
  54. package/dist/noyau/serialiser.d.ts +2 -0
  55. package/dist/noyau/serialiser.js +25 -0
  56. package/dist/noyau/version.d.ts +5 -0
  57. package/dist/noyau/version.js +25 -0
  58. package/dist/socle/hors-perimetre.d.ts +2 -0
  59. package/dist/socle/hors-perimetre.js +9 -0
  60. package/dist/types.d.ts +34 -0
  61. package/dist/types.js +10 -0
  62. package/package.json +62 -3
  63. package/src/composants/CmsImage.astro +26 -0
@@ -0,0 +1,218 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { MOTIF_SRC_IMAGE } from '../champs.js';
4
+ import { lireMeta } from '../types.js';
5
+ import { lireFrontmatter } from './frontmatter.js';
6
+ import { extraireImages, validerMarkdown } from './markdown.js';
7
+ const DOSSIER_PAGES = 'src/content/pages';
8
+ const TAILLES = ['800', '1600', '2400'];
9
+ const def = (s) => s._zod.def;
10
+ const ENVELOPPES = new Set(['optional', 'nullable', 'default', 'prefault', 'nonoptional', 'readonly']);
11
+ const estObjet = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
12
+ const posix = (p) => p.split(path.sep).join('/');
13
+ function formaterChemin(chemin) {
14
+ let s = '';
15
+ for (const p of chemin) {
16
+ if (typeof p === 'number')
17
+ s += `[${p}]`;
18
+ else
19
+ s += s === '' ? String(p) : `.${String(p)}`;
20
+ }
21
+ return s;
22
+ }
23
+ /**
24
+ * Fichiers d'un dossier (récursif), chemins POSIX relatifs à `dossier`, triés ; dossier absent → `[]`.
25
+ * Fichiers et dossiers cachés (`.DS_Store`, `.gitkeep`…) ignorés, comme le glob loader d'Astro (`dot: false`).
26
+ */
27
+ async function lister(dossier) {
28
+ let entrees;
29
+ try {
30
+ entrees = await fsp.readdir(dossier, { recursive: true, withFileTypes: true });
31
+ }
32
+ catch (e) {
33
+ if (e.code === 'ENOENT')
34
+ return [];
35
+ throw e;
36
+ }
37
+ return entrees
38
+ .filter((e) => e.isFile())
39
+ .map((e) => posix(path.relative(dossier, path.join(e.parentPath, e.name))))
40
+ .filter((rel) => !rel.split('/').some((segment) => segment.startsWith('.')))
41
+ .sort();
42
+ }
43
+ async function lireTexte(chemin) {
44
+ try {
45
+ return await fsp.readFile(chemin, 'utf8');
46
+ }
47
+ catch (e) {
48
+ if (e.code === 'ENOENT')
49
+ return undefined;
50
+ throw e;
51
+ }
52
+ }
53
+ async function existe(chemin) {
54
+ try {
55
+ await fsp.access(chemin);
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ class Collecteur {
63
+ racine;
64
+ fichier;
65
+ violations = [];
66
+ constructor(racine, fichier) {
67
+ this.racine = racine;
68
+ this.fichier = fichier;
69
+ }
70
+ ajouter(motif, ligne) {
71
+ this.violations.push(ligne === undefined ? { fichier: this.fichier, motif } : { fichier: this.fichier, ligne, motif });
72
+ }
73
+ /** Vérifie les trois variantes d'une image. Un `src` hors motif n'est jamais résolu sur le disque. */
74
+ async image(src, ligne) {
75
+ if (typeof src !== 'string' || !MOTIF_SRC_IMAGE.test(src))
76
+ return;
77
+ const uploads = path.resolve(this.racine, 'public', 'uploads');
78
+ for (const taille of TAILLES) {
79
+ const variante = path.resolve(this.racine, 'public', `${src.slice(1, -'2400.webp'.length)}${taille}.webp`);
80
+ if (!variante.startsWith(uploads + path.sep))
81
+ continue;
82
+ if (!(await existe(variante))) {
83
+ this.ajouter(`variante d'image absente : ${posix(path.relative(this.racine, variante))}`, ligne);
84
+ }
85
+ }
86
+ }
87
+ /** Markdown d'un champ `richtext` : violations préfixées du chemin du champ (ligne inconnue). */
88
+ async richtext(valeur, chemin) {
89
+ if (typeof valeur !== 'string')
90
+ return;
91
+ for (const v of validerMarkdown(valeur))
92
+ this.ajouter(`${chemin} : ${v.motif}`);
93
+ for (const { url } of extraireImages(valeur))
94
+ await this.image(url);
95
+ }
96
+ /** Parcourt données et schéma ensemble : images et champs richtext. */
97
+ async parcourir(schema, valeur, chemin) {
98
+ let noeud = schema;
99
+ for (;;) {
100
+ const editeur = lireMeta(noeud)?.editeur;
101
+ if (editeur === 'image') {
102
+ if (estObjet(valeur))
103
+ await this.image(valeur.src);
104
+ return;
105
+ }
106
+ if (editeur === 'richtext')
107
+ return this.richtext(valeur, chemin);
108
+ const d = def(noeud);
109
+ if (!ENVELOPPES.has(d.type) || !d.innerType)
110
+ break;
111
+ noeud = d.innerType;
112
+ }
113
+ const d = def(noeud);
114
+ if (d.type === 'object' && estObjet(valeur)) {
115
+ for (const [cle, sous] of Object.entries(d.shape ?? {})) {
116
+ if (cle in valeur)
117
+ await this.parcourir(sous, valeur[cle], chemin === '' ? cle : `${chemin}.${cle}`);
118
+ }
119
+ }
120
+ else if (d.type === 'array' && d.element && Array.isArray(valeur)) {
121
+ for (const [i, v] of valeur.entries())
122
+ await this.parcourir(d.element, v, `${chemin}[${i}]`);
123
+ }
124
+ else if (d.type === 'union' && d.discriminator !== undefined && estObjet(valeur)) {
125
+ const option = d.options?.find((o) => {
126
+ const champDisc = def(o).shape?.[d.discriminator];
127
+ return champDisc !== undefined && def(champDisc).values?.includes(valeur[d.discriminator]);
128
+ });
129
+ if (option)
130
+ await this.parcourir(option, valeur, chemin);
131
+ }
132
+ }
133
+ /** Validation Zod puis parcours des images et du richtext. */
134
+ async donnees(schema, donnees) {
135
+ const r = schema.safeParse(donnees);
136
+ if (!r.success) {
137
+ for (const issue of r.error.issues) {
138
+ const chemin = formaterChemin(issue.path);
139
+ this.ajouter(chemin === '' ? issue.message : `${chemin} : ${issue.message}`);
140
+ }
141
+ }
142
+ await this.parcourir(schema, donnees, '');
143
+ }
144
+ }
145
+ function ligneJson(message) {
146
+ const m = /\(line (\d+) column \d+\)/.exec(message);
147
+ return m ? Number(m[1]) : undefined;
148
+ }
149
+ async function validerJson(racine, fichier, texte, schema) {
150
+ const c = new Collecteur(racine, fichier);
151
+ let donnees;
152
+ try {
153
+ donnees = JSON.parse(texte);
154
+ }
155
+ catch (e) {
156
+ c.ajouter(`JSON illisible : ${e.message}`, ligneJson(e.message));
157
+ return c.violations;
158
+ }
159
+ await c.donnees(schema, donnees);
160
+ return c.violations;
161
+ }
162
+ async function validerMd(racine, fichier, texte, schema) {
163
+ const c = new Collecteur(racine, fichier);
164
+ let lu;
165
+ try {
166
+ lu = lireFrontmatter(texte);
167
+ }
168
+ catch (e) {
169
+ const err = e;
170
+ c.ajouter(`frontmatter illisible : ${err.message}`, err.linePos?.[0]?.line);
171
+ return c.violations;
172
+ }
173
+ await c.donnees(schema, lu.donnees);
174
+ for (const v of validerMarkdown(lu.corps, lu.ligneCorps - 1)) {
175
+ c.ajouter(v.motif, v.ligne);
176
+ }
177
+ for (const { url, ligne } of extraireImages(lu.corps)) {
178
+ await c.image(url, ligne === undefined ? undefined : ligne + lu.ligneCorps - 1);
179
+ }
180
+ return c.violations;
181
+ }
182
+ /** Valide tous les fichiers de contenu d'un site. Ordre : singletons par id, collections par id, fichiers triés. */
183
+ export async function validerContenus(racine, modele) {
184
+ const violations = [];
185
+ const declares = new Set(modele.singletons.map((s) => s.fichier));
186
+ for (const s of modele.singletons) {
187
+ const texte = await lireTexte(path.join(racine, s.fichier));
188
+ if (texte === undefined)
189
+ violations.push({ fichier: s.fichier, motif: 'fichier manquant' });
190
+ else
191
+ violations.push(...(await validerJson(racine, s.fichier, texte, s.schema)));
192
+ }
193
+ for (const f of await lister(path.join(racine, DOSSIER_PAGES))) {
194
+ const fichier = `${DOSSIER_PAGES}/${f}`;
195
+ if (!declares.has(fichier))
196
+ violations.push({ fichier, motif: 'fichier non déclaré comme singleton' });
197
+ }
198
+ for (const col of modele.collections) {
199
+ for (const f of await lister(path.join(racine, col.dossier))) {
200
+ const fichier = `${col.dossier}/${f}`;
201
+ const texte = await fsp.readFile(path.join(racine, fichier), 'utf8');
202
+ if (f.endsWith('.json'))
203
+ violations.push(...(await validerJson(racine, fichier, texte, col.schema)));
204
+ else if (f.endsWith('.md'))
205
+ violations.push(...(await validerMd(racine, fichier, texte, col.schema)));
206
+ }
207
+ }
208
+ return violations;
209
+ }
210
+ /** Une ligne par violation : `fichier:ligne — motif` (ordre d'entrée conservé). */
211
+ export function formaterViolations(v) {
212
+ return v
213
+ .map((x) => {
214
+ const lieu = x.fichier === undefined ? '' : x.ligne === undefined ? `${x.fichier} — ` : `${x.fichier}:${x.ligne} — `;
215
+ return `${lieu}${x.motif}`;
216
+ })
217
+ .join('\n');
218
+ }
@@ -0,0 +1,7 @@
1
+ /** Erreur destinée au développeur ; message au format `fichier:ligne — motif` (fichier/ligne omis si inconnus). */
2
+ export declare class ErreurCms extends Error {
3
+ readonly motif: string;
4
+ readonly fichier: string | undefined;
5
+ readonly ligne: number | undefined;
6
+ constructor(motif: string, fichier?: string, ligne?: number);
7
+ }
@@ -0,0 +1,14 @@
1
+ /** Erreur destinée au développeur ; message au format `fichier:ligne — motif` (fichier/ligne omis si inconnus). */
2
+ export class ErreurCms extends Error {
3
+ motif;
4
+ fichier;
5
+ ligne;
6
+ constructor(motif, fichier, ligne) {
7
+ const lieu = fichier === undefined ? '' : ligne === undefined ? `${fichier} — ` : `${fichier}:${ligne} — `;
8
+ super(`${lieu}${motif}`);
9
+ this.name = 'ErreurCms';
10
+ this.motif = motif;
11
+ this.fichier = fichier;
12
+ this.ligne = ligne;
13
+ }
14
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Sépare frontmatter et corps. `ligneCorps` est la ligne (1-based) du fichier où commence
3
+ * `corps` : la ligne `n` du corps est la ligne `ligneCorps + n - 1` du fichier.
4
+ * Lève l'erreur du parseur YAML (`YAMLParseError`, avec `linePos`) si le YAML est invalide.
5
+ */
6
+ export declare function lireFrontmatter(source: string): {
7
+ donnees: unknown;
8
+ corps: string;
9
+ ligneCorps: number;
10
+ };
11
+ /**
12
+ * Inverse de `lireFrontmatter` : `---`, YAML (clés dans l'ordre reçu, lignes non repliées), `---`, corps.
13
+ * Les chaînes qui ressemblent à une date sont entre guillemets doubles, pour être relues comme chaînes.
14
+ */
15
+ export declare function ecrireFrontmatter(donnees: Record<string, unknown>, corps: string): string;
@@ -0,0 +1,70 @@
1
+ import { Document, isScalar, parseDocument, visit } from 'yaml';
2
+ import { ErreurCms } from './erreurs.js';
3
+ // Extraction identique à `@astrojs/internal-helpers/frontmatter` (utilisée par Astro 7).
4
+ const RE_FRONTMATTER = /(?:^?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/;
5
+ const RE_TYPE = /(?:^?|^\s*\n)(---|\+\+\+)/;
6
+ /**
7
+ * Même lecture YAML qu'Astro 7 (`@astrojs/internal-helpers/yaml`) : paquet `yaml`,
8
+ * schéma `core` + tag `timestamp` + `merge`. Une date non entre guillemets devient donc
9
+ * un objet `Date`, que `z.iso.date()` refuse — au build comme ici.
10
+ */
11
+ function lireYaml(source) {
12
+ const document = parseDocument(source, { customTags: ['timestamp'], merge: true, schema: 'core' });
13
+ if (document.errors.length > 0)
14
+ throw document.errors[0];
15
+ if (document.contents === null)
16
+ return undefined;
17
+ return document.toJS();
18
+ }
19
+ const compterLignes = (s) => s.split('\n').length - 1;
20
+ /**
21
+ * Sépare frontmatter et corps. `ligneCorps` est la ligne (1-based) du fichier où commence
22
+ * `corps` : la ligne `n` du corps est la ligne `ligneCorps + n - 1` du fichier.
23
+ * Lève l'erreur du parseur YAML (`YAMLParseError`, avec `linePos`) si le YAML est invalide.
24
+ */
25
+ export function lireFrontmatter(source) {
26
+ const trouve = RE_FRONTMATTER.exec(source);
27
+ const brut = trouve?.[1];
28
+ if (trouve === null || brut === undefined)
29
+ return { donnees: {}, corps: source, ligneCorps: 1 };
30
+ if (RE_TYPE.exec(source)?.[1] === '+++') {
31
+ throw new ErreurCms('frontmatter TOML (+++) non pris en charge : utilise YAML (---)');
32
+ }
33
+ const analyse = lireYaml(brut);
34
+ const donnees = analyse !== null && typeof analyse === 'object' ? analyse : {};
35
+ const fin = trouve.index + trouve[0].length;
36
+ const reste = source.slice(fin);
37
+ // Le corps commence à la ligne qui suit la clôture `---`.
38
+ const saut = /^[^\S\n]*\r?\n/.exec(reste);
39
+ const corps = saut ? reste.slice(saut[0].length) : reste;
40
+ const ligneCloture = compterLignes(source.slice(0, fin)) + 1;
41
+ return { donnees, corps, ligneCorps: ligneCloture + (saut ? 1 : 0) };
42
+ }
43
+ /** Vrai si la chaîne, écrite sans guillemets, serait relue par `lireYaml` autrement que comme cette chaîne (ex. date). */
44
+ function sRelitAutrement(valeur) {
45
+ if (!/^\d/.test(valeur) || valeur.includes('\n'))
46
+ return false;
47
+ try {
48
+ const relu = lireYaml(`k: ${valeur}`);
49
+ return relu?.k !== valeur;
50
+ }
51
+ catch {
52
+ return true;
53
+ }
54
+ }
55
+ /**
56
+ * Inverse de `lireFrontmatter` : `---`, YAML (clés dans l'ordre reçu, lignes non repliées), `---`, corps.
57
+ * Les chaînes qui ressemblent à une date sont entre guillemets doubles, pour être relues comme chaînes.
58
+ */
59
+ export function ecrireFrontmatter(donnees, corps) {
60
+ if (Object.keys(donnees).length === 0)
61
+ return `---\n---\n${corps}`;
62
+ const document = new Document(donnees);
63
+ visit(document, {
64
+ Scalar(_cle, noeud) {
65
+ if (isScalar(noeud) && typeof noeud.value === 'string' && sRelitAutrement(noeud.value))
66
+ noeud.type = 'QUOTE_DOUBLE';
67
+ },
68
+ });
69
+ return `---\n${document.toString({ lineWidth: 0 })}---\n${corps}`;
70
+ }
@@ -0,0 +1,4 @@
1
+ import type { ModeleCms } from './collecter.js';
2
+ import type { OptionsCms } from './manifest.js';
3
+ /** Document `cms.schema.json` (voir spec, section 5). */
4
+ export declare function versJsonSchema(modele: ModeleCms, options: OptionsCms): object;
@@ -0,0 +1,61 @@
1
+ import { z } from 'astro/zod';
2
+ const ACCENT_DEFAUT = '#5DEFB7';
3
+ /** Renomme la méta `cms` en `x-cms` et impose `additionalProperties: false` sur les objets. */
4
+ function surcharger(ctx) {
5
+ const js = ctx.jsonSchema;
6
+ if ('cms' in js) {
7
+ js['x-cms'] = js.cms;
8
+ delete js.cms;
9
+ }
10
+ if (js.type === 'object')
11
+ js.additionalProperties = false;
12
+ }
13
+ function convertir(schema) {
14
+ const js = z.toJSONSchema(schema, {
15
+ target: 'draft-2020-12',
16
+ io: 'input',
17
+ override: surcharger,
18
+ });
19
+ delete js.$schema;
20
+ return js;
21
+ }
22
+ /** Document `cms.schema.json` (voir spec, section 5). */
23
+ export function versJsonSchema(modele, options) {
24
+ const singletons = {};
25
+ for (const s of modele.singletons) {
26
+ singletons[s.id] = {
27
+ 'x-cms-libelle': s.libelle,
28
+ 'x-cms-fichier': s.fichier,
29
+ ...(s.adresse !== undefined && { 'x-cms-adresse': s.adresse }),
30
+ ...(s.apercu !== undefined && { 'x-cms-apercu': s.apercu }),
31
+ ...convertir(s.schema),
32
+ };
33
+ }
34
+ const collections = {};
35
+ for (const c of modele.collections) {
36
+ const entree = {
37
+ 'x-cms-libelle': c.libelle,
38
+ 'x-cms-dossier': c.dossier,
39
+ 'x-cms-format': c.format,
40
+ 'x-cms-titre': c.titre,
41
+ 'x-cms-ordonnable': c.ordonnable,
42
+ };
43
+ if (c.format === 'markdown')
44
+ entree['x-cms-corps'] = { titres: [2, 3], tableaux: true };
45
+ entree.items = convertir(c.schema);
46
+ collections[c.id] = entree;
47
+ }
48
+ const site = { nom: options.site, url: options.url, accent: options.admin?.accent ?? ACCENT_DEFAUT };
49
+ if (options.admin?.logo !== undefined)
50
+ site.logo = options.admin.logo;
51
+ if (options.admin?.apercu !== undefined)
52
+ site.apercu = options.admin.apercu;
53
+ site.contact = options.contact;
54
+ return {
55
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
56
+ 'x-cms-version': 1,
57
+ 'x-cms-site': site,
58
+ singletons,
59
+ collections,
60
+ };
61
+ }
@@ -0,0 +1,16 @@
1
+ import type { ModeleCms } from './collecter.js';
2
+ export interface OptionsCms {
3
+ site: string;
4
+ contact: string;
5
+ horsPerimetre?: string[];
6
+ /** URL publique du site, en https, sans « / » final. */
7
+ url: string;
8
+ admin?: {
9
+ accent?: string;
10
+ logo?: string;
11
+ origine?: string;
12
+ apercu?: string;
13
+ };
14
+ }
15
+ /** Vue du modèle pour l'IA : langage naturel, aucun chemin de fichier. */
16
+ export declare function versManifest(modele: ModeleCms, options: OptionsCms): object;
@@ -0,0 +1,136 @@
1
+ import { HORS_PERIMETRE_SOCLE } from '../socle/hors-perimetre.js';
2
+ import { lireMeta } from '../types.js';
3
+ import { deduireEditeur } from './collecter.js';
4
+ const interne = (s) => s._zod;
5
+ const ENVELOPPES = new Set(['optional', 'nullable', 'default', 'prefault', 'nonoptional', 'readonly']);
6
+ function deballer(schema) {
7
+ let courant = schema;
8
+ while (ENVELOPPES.has(interne(courant).def.type) && interne(courant).def.innerType) {
9
+ courant = interne(courant).def.innerType;
10
+ }
11
+ return courant;
12
+ }
13
+ const estFacultatif = (s) => interne(s).def.type === 'optional';
14
+ const TYPES = {
15
+ texte: 'texte court',
16
+ paragraphe: 'texte long',
17
+ richtext: 'texte mis en forme',
18
+ image: 'image',
19
+ date: 'date',
20
+ lien: 'lien',
21
+ nombre: 'nombre',
22
+ booleen: 'oui/non',
23
+ choix: 'choix',
24
+ liste: 'liste',
25
+ groupe: 'groupe',
26
+ };
27
+ const CORPS_MARKDOWN = 'Markdown : intertitres niveaux 2 et 3, gras, italique, liens, listes, citations, images, tableaux simples. Pas de HTML.';
28
+ /** Lit une borne dans les vérifications déclarées (`.min()`/`.max()`), le `bag` étant vide après `.meta()`. */
29
+ function borne(schema, cle) {
30
+ const verifs = interne(schema).def.checks ?? [];
31
+ const noms = cle === 'minimum' ? ['min_length', 'greater_than'] : ['max_length', 'less_than'];
32
+ for (const v of verifs) {
33
+ const d = v._zod.def;
34
+ if (!noms.includes(d.check))
35
+ continue;
36
+ const valeur = d.check === 'min_length' || d.check === 'max_length' ? d.minimum ?? d.maximum : d.value;
37
+ if (typeof valeur === 'number')
38
+ return valeur;
39
+ }
40
+ return undefined;
41
+ }
42
+ function contraintes(schema, editeur) {
43
+ const bag = deballer(schema);
44
+ const parties = [estFacultatif(schema) ? 'facultatif' : 'obligatoire'];
45
+ if (editeur === 'texte' || editeur === 'paragraphe' || editeur === 'richtext') {
46
+ const max = borne(bag, 'maximum');
47
+ if (max !== undefined)
48
+ parties.push(`${max} caractères max`);
49
+ }
50
+ else if (editeur === 'nombre') {
51
+ const min = borne(bag, 'minimum');
52
+ const max = borne(bag, 'maximum');
53
+ if (min !== undefined && max !== undefined)
54
+ parties.push(`entre ${min} et ${max}`);
55
+ else if (min !== undefined)
56
+ parties.push(`${min} minimum`);
57
+ else if (max !== undefined)
58
+ parties.push(`${max} maximum`);
59
+ const unite = lireMeta(schema)?.unite;
60
+ if (unite !== undefined)
61
+ parties.push(`unité : ${unite}`);
62
+ }
63
+ else if (editeur === 'liste') {
64
+ const min = borne(bag, 'minimum');
65
+ const max = borne(bag, 'maximum');
66
+ if (min !== undefined)
67
+ parties.push(`${min} éléments min`);
68
+ if (max !== undefined)
69
+ parties.push(`${max} éléments max`);
70
+ }
71
+ return parties.join(', ');
72
+ }
73
+ function typeLisible(schema, editeur) {
74
+ if (editeur === 'choix') {
75
+ const options = deballer(schema).options ?? [];
76
+ return `choix parmi : ${options.join(', ')}`;
77
+ }
78
+ return TYPES[editeur];
79
+ }
80
+ function aplatir(cle, schema, chemin, sortie) {
81
+ const editeur = deduireEditeur(schema);
82
+ const entree = {
83
+ chemin,
84
+ libelle: lireMeta(schema)?.libelle ?? cle,
85
+ type: typeLisible(schema, editeur),
86
+ };
87
+ const c = contraintes(schema, editeur);
88
+ if (c !== '')
89
+ entree.contraintes = c;
90
+ sortie.push(entree);
91
+ // L'image est un champ atomique : sa structure interne (src, alt…) n'est pas exposée.
92
+ if (editeur !== 'groupe' && editeur !== 'liste')
93
+ return;
94
+ const d = interne(deballer(schema)).def;
95
+ if (d.type === 'object' && d.shape) {
96
+ for (const [sousCle, sous] of Object.entries(d.shape))
97
+ aplatir(sousCle, sous, `${chemin}.${sousCle}`, sortie);
98
+ }
99
+ else if (d.type === 'array' && d.element) {
100
+ const de = interne(deballer(d.element)).def;
101
+ if (de.type === 'object' && de.shape) {
102
+ for (const [sousCle, sous] of Object.entries(de.shape)) {
103
+ aplatir(sousCle, sous, `${chemin}[].${sousCle}`, sortie);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ function champs(schema, ignorer = []) {
109
+ const sortie = [];
110
+ for (const [cle, sous] of Object.entries(interne(schema).def.shape ?? {})) {
111
+ if (ignorer.includes(cle))
112
+ continue;
113
+ aplatir(cle, sous, cle, sortie);
114
+ }
115
+ return sortie;
116
+ }
117
+ /** Vue du modèle pour l'IA : langage naturel, aucun chemin de fichier. */
118
+ export function versManifest(modele, options) {
119
+ return {
120
+ version: 1,
121
+ site: options.site,
122
+ pages: modele.singletons.map((s) => ({ id: s.id, libelle: s.libelle, champs: champs(s.schema) })),
123
+ collections: modele.collections.map((c) => ({
124
+ id: c.id,
125
+ libelle: c.libelle,
126
+ actions: c.ordonnable
127
+ ? ['ajouter', 'modifier', 'supprimer', 'reordonner']
128
+ : ['ajouter', 'modifier', 'supprimer'],
129
+ // `ordre` est ajouté automatiquement aux collections ordonnables : couvert par `reordonner`.
130
+ champs: champs(c.schema, c.ordonnable ? ['ordre'] : []),
131
+ ...(c.format === 'markdown' ? { corps: CORPS_MARKDOWN } : {}),
132
+ })),
133
+ horsPerimetre: [...new Set([...HORS_PERIMETRE_SOCLE, ...(options.horsPerimetre ?? [])])],
134
+ contact: options.contact,
135
+ };
136
+ }
@@ -0,0 +1,12 @@
1
+ export interface Violation {
2
+ fichier?: string;
3
+ ligne?: number;
4
+ motif: string;
5
+ }
6
+ export declare const analyseur: import("unified").Processor<import("mdast").Root, undefined, undefined, undefined, undefined>;
7
+ export declare function validerMarkdown(source: string, decalageLigne?: number): Violation[];
8
+ /** Images (`![](url)`) du source, avec leur ligne (1-based) et leur URL brute. */
9
+ export declare function extraireImages(source: string): {
10
+ url: string;
11
+ ligne?: number;
12
+ }[];
@@ -0,0 +1,95 @@
1
+ import remarkGfm from 'remark-gfm';
2
+ import remarkParse from 'remark-parse';
3
+ import { unified } from 'unified';
4
+ import { MOTIF_LIEN, MOTIF_SRC_IMAGE } from '../motifs.js';
5
+ export const analyseur = unified().use(remarkParse).use(remarkGfm);
6
+ const AUTORISES = new Set([
7
+ 'root', 'paragraph', 'text', 'heading', 'strong', 'emphasis', 'link', 'list',
8
+ 'listItem', 'blockquote', 'image', 'table', 'tableRow', 'tableCell', 'break',
9
+ ]);
10
+ const DANS_CELLULE = new Set(['text', 'strong', 'emphasis', 'link']);
11
+ const MOTIFS_FIXES = {
12
+ html: 'HTML interdit',
13
+ code: 'bloc de code interdit',
14
+ inlineCode: 'code en ligne interdit',
15
+ delete: 'texte barré interdit',
16
+ footnoteReference: 'note de bas de page interdite',
17
+ footnoteDefinition: 'note de bas de page interdite',
18
+ thematicBreak: 'ligne horizontale interdite',
19
+ };
20
+ /**
21
+ * Autolien littéral GFM (`www.exemple.fr`, `http://exemple.fr` écrits nus) : un seul enfant texte égal à l'URL,
22
+ * ou à l'URL sans le préfixe `http://` que GFM ajoute aux `www.`. Un lien explicite `[x](http://…)` reste refusé.
23
+ */
24
+ function estAutolienLitteral(lien) {
25
+ if (!/^https?:\/\//.test(lien.url))
26
+ return false;
27
+ const [enfant, ...reste] = lien.children;
28
+ if (enfant?.type !== 'text' || reste.length > 0)
29
+ return false;
30
+ return enfant.value === lien.url || `http://${enfant.value}` === lien.url;
31
+ }
32
+ // Motif de refus propre au nœud, ou undefined s'il est autorisé hors tableau.
33
+ function motifRefus(noeud) {
34
+ const fixe = MOTIFS_FIXES[noeud.type];
35
+ if (fixe)
36
+ return fixe;
37
+ switch (noeud.type) {
38
+ case 'heading':
39
+ return noeud.depth === 2 || noeud.depth === 3
40
+ ? undefined
41
+ : `titre de niveau ${noeud.depth} interdit`;
42
+ case 'listItem':
43
+ return typeof noeud.checked === 'boolean' ? 'case à cocher interdite' : undefined;
44
+ case 'link':
45
+ return MOTIF_LIEN.test(noeud.url) || estAutolienLitteral(noeud) ? undefined : `lien non autorisé : ${noeud.url}`;
46
+ case 'image':
47
+ return MOTIF_SRC_IMAGE.test(noeud.url)
48
+ ? undefined
49
+ : `image hors de /uploads/ : ${noeud.url}`;
50
+ default:
51
+ return AUTORISES.has(noeud.type) ? undefined : `élément « ${noeud.type} » interdit`;
52
+ }
53
+ }
54
+ export function validerMarkdown(source, decalageLigne = 0) {
55
+ // Les fins de ligne CRLF sont normalisées : le nombre de lignes est inchangé.
56
+ const arbre = analyseur.parse(source.replace(/\r\n/g, '\n'));
57
+ const violations = [];
58
+ const visiter = (noeud, dansCellule) => {
59
+ let motif = motifRefus(noeud);
60
+ if (!motif && dansCellule && !DANS_CELLULE.has(noeud.type)) {
61
+ motif =
62
+ noeud.type === 'image'
63
+ ? 'image interdite dans un tableau'
64
+ : `élément « ${noeud.type} » interdit dans un tableau`;
65
+ }
66
+ if (motif) {
67
+ const ligne = noeud.position?.start.line;
68
+ violations.push(ligne === undefined ? { motif } : { ligne: ligne + decalageLigne, motif });
69
+ return;
70
+ }
71
+ if ('children' in noeud) {
72
+ const enfantsDansCellule = dansCellule || noeud.type === 'tableCell';
73
+ for (const enfant of noeud.children)
74
+ visiter(enfant, enfantsDansCellule);
75
+ }
76
+ };
77
+ visiter(arbre, false);
78
+ return violations;
79
+ }
80
+ /** Images (`![](url)`) du source, avec leur ligne (1-based) et leur URL brute. */
81
+ export function extraireImages(source) {
82
+ const arbre = analyseur.parse(source.replace(/\r\n/g, '\n'));
83
+ const images = [];
84
+ const visiter = (noeud) => {
85
+ if (noeud.type === 'image') {
86
+ const ligne = noeud.position?.start.line;
87
+ images.push(ligne === undefined ? { url: noeud.url } : { url: noeud.url, ligne });
88
+ }
89
+ if ('children' in noeud)
90
+ for (const enfant of noeud.children)
91
+ visiter(enfant);
92
+ };
93
+ visiter(arbre);
94
+ return images;
95
+ }
@@ -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[]>;