@mostajs/landing 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog — @mostajs/landing
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
+
5
+ ## [0.1.0] — 2026-10-31
6
+ ### Ajouté
7
+ - Modèle en sept blocs et sa validation ; sept langues, arabe de droite à gauche ; public « adultes » ou « écoles ».
8
+ - Rendu HTML sans script, < 50 Ko, première image immédiate puis différées, pied légal de la marque toujours rendu ;
9
+ UTM et identifiants de clic recopiés dans le bouton ; aperçu en fragment ; `decrirePage` pour @mostajs/ad-platforms.
10
+ - Signature HMAC-SHA256 horodatée, envoi (`envoyer`), récepteur (`creerRecepteur`) et dépôts en mémoire ou en fichiers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ @mostajs/landing
2
+ Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ The complete text of the GNU Affero General Public License version 3 is
20
+ available at the URL above and must accompany any distribution of this
21
+ software.
package/README.md CHANGED
@@ -1,3 +1,65 @@
1
- # Temporary Holding Version
1
+ # @mostajs/landing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
4
+
5
+ Les landing pages de pub, de l'éditeur au produit qui les sert :
6
+
7
+ - un **modèle en sept blocs** (accroche, image, bénéfices, étapes, questions, bouton, mentions), validé sans dépendance ;
8
+ - un **rendu HTML sans aucun script**, sous 50 Ko hors images, lisible de 320 à 1 280 px, en sept langues (l'arabe de droite à gauche), avec un **pied légal toujours présent** ;
9
+ - un **envoi signé** (HMAC-SHA256, horodaté) ;
10
+ - un **récepteur** à monter dans le produit, pour que la page soit servie **sur le domaine du produit**. C'est ce qu'exigent les régies : Google refuse une annonce dont la page est sur un autre domaine (*Destination mismatch*).
11
+
12
+ Zéro dépendance. Node ≥ 18 (le récepteur et la signature emploient `node:crypto`).
13
+
14
+ ## Installer
15
+
16
+ ```bash
17
+ npm i @mostajs/landing
18
+ ```
19
+
20
+ ## Côté éditeur : valider, rendre, publier
21
+
22
+ ```js
23
+ import { validerPage, rendre, decrirePage, envoyer } from '@mostajs/landing';
24
+
25
+ const page = {
26
+ slug: 'anglais-au-travail', langue: 'fr', public: 'adultes',
27
+ blocs: [
28
+ { type: 'accroche', titre: 'Parle anglais avec assurance au travail' },
29
+ { type: 'bouton', libelle: 'Commencer', href: '/inscription?plan=essai' },
30
+ ],
31
+ };
32
+ validerPage(page); // { ok: true, erreurs: [] }
33
+ const html = rendre(page, marque); // page complète, sans script
34
+ const apercu = rendre(page, marque, { fragment: true }); // pour l'aperçu d'un éditeur
35
+ // Ce que lit verifierPage de @mostajs/ad-platforms :
36
+ const description = decrirePage(page, marque, { adresse: 'https://speaking.mostajs.dev/p/anglais-au-travail', domaineProduit: 'speaking.mostajs.dev' });
37
+ await envoyer({ origine: 'https://speaking.mostajs.dev', cle: CLE_DU_PRODUIT, page }); // { ok, status, corps }
38
+ ```
39
+
40
+ ## Côté produit : le récepteur
41
+
42
+ ```js
43
+ import { creerRecepteur, depotFichiers } from '@mostajs/landing';
44
+
45
+ const recepteur = creerRecepteur({
46
+ cle: () => getEnv('LANDING_CLE'), // relue à chaque envoi : renouvelable sans redémarrer
47
+ depot: depotFichiers('data/landing'), // hors du code : survit au déploiement
48
+ marque: { nom: 'Speaking', logo: '/static/logo.png', pied: { confidentialite: '/confidentialite', mentions: '/mentions-legales', editeur: '…' } },
49
+ origine: 'https://speaking.mostajs.dev',
50
+ });
51
+ // dans le routeur : if (await recepteur(req, res)) return;
52
+ ```
53
+
54
+ | Adresse | Effet |
55
+ |---|---|
56
+ | `POST /p/_publier` | corps `{ page }` signé → 201 `{ slug, version, adresse }` ; 401 si la signature est fausse, absente, périmée ou le corps altéré ; 422 si la page est invalide |
57
+ | `POST /p/_retirer` | corps `{ slug }` signé → la page n'est plus servie (ses versions restent) |
58
+ | `GET /p/_marque` | la marque publique du produit (nom, logo, couleurs, pied légal) : l'éditeur y voit que le récepteur est installé |
59
+ | `GET /p/<slug>` | la dernière version, rendue avec la **marque du produit** ; `Cache-Control: public, max-age=60` ; 404 sinon |
60
+
61
+ Le bouton de la page recopie dans son lien les UTM et identifiants de clic (`fbclid`, `gclid`, `gbraid`, `wbraid`, `ttclid`, `ScCid`) de l'adresse reçue : la source arrive jusqu'à l'inscription sans cookie ni script.
62
+
63
+ ## Ce que le module ne fait pas
64
+
65
+ Il ne vérifie pas les règles des régies (c'est `@mostajs/ad-platforms`, qui lit `decrirePage`), ne raccourcit pas les liens (`@mostajs/short-link`), et n'héberge pas les images : une image est un chemin du site du produit ou une adresse https.
package/llms.txt ADDED
@@ -0,0 +1,28 @@
1
+ # @mostajs/landing — fiche LLM
2
+ > Landing pages de pub : modèle en 7 blocs, rendu HTML sans script (< 50 Ko, pied légal, 7 langues, arabe rtl), envoi signé HMAC, récepteur monté dans le produit (page servie sur SON domaine). Zéro dépendance.
3
+
4
+ - Version : 0.1.0 · Licence : AGPL-3.0-or-later · Auteur : Dr Hamid MADANI <drmdh@msn.com>
5
+ - Chemin : mostajs/mosta-ads-stack/mosta-landing · ESM · Node ≥ 18 (node:crypto, node:fs)
6
+ - Niveau §5.bis : N1 · technique · multi-domaine · depend_de []
7
+ - Consommateurs : LinksFly (Entreprise/ads, éditeur et publication), Speaking (récepteur /p/)
8
+
9
+ ## EXPORTS
10
+ - Modèle : `BLOCS`, `LANGUES` (fr ar en de es it zh), `RTL` (['ar']), `PUBLICS` (adultes, ecoles), `SLUG` (regex), `sensDe(l)`, `adresseValide(v)` (« /… » sauf « //… », ou https), `validerPage(page)` → { ok, erreurs[{chemin,message}] }, `titreDe(page)`
11
+ - Rendu : `rendre(page, marque, { requete?, fragment?, canonique?, sansImages? })` → HTML ; `decrirePage(page, marque, { adresse, domaineProduit, langueCampagne?, accrocheAnnonce?, html? })` → description lue par `verifierPage` d'ad-platforms ; `lienAvecSource(href, requete)` ; `A_CONSERVER` ; `esc`
12
+ - Textes : `TEXTES`, `textesDe(l)` (titres par défaut, pied légal)
13
+ - Signature : `signer(corps, cle)` → 't=<s>,v1=<hex>' ; `verifierSignature(brut, entete, cle, { tolerance=300 })` → { ok } | { ok:false, motif: absente|illisible|perimee|fausse } ; `ENTETE_SIGNATURE` = 'x-landing-signature'
14
+ - Récepteur : `creerRecepteur({ cle: string|fn, depot, marque: obj|fn, prefixe='/p/', maxOctets=256Ko, origine, journal })` → async (req, res, chemin?) → bool ; sert POST _publier, POST _retirer, GET _marque (marque publique), GET <slug>
15
+ - Dépôts : `depotMemoire({ garder=20 })`, `depotFichiers(dossier, { garder=20 })` — { enregistrer(page) → {slug,version}, derniere(slug), versions(slug), retirer(slug) }
16
+ - Envoi : `envoyer({ origine, cle, page? | retirer?, prefixe, fetch?, delai=10000 })` → { ok, status, corps }
17
+
18
+ ## PAGE
19
+ { slug, langue, public?, blocs: [ accroche{titre,sousTitre?} (exactement 1) | image{src,alt,legende?} | benefices{elements 1-3 {titre,texte?}} | etapes{titre?,elements 1-5 {titre}} | questions{titre?,elements 1-8 {q,r}} | bouton{libelle,href} | mentions{texte} ], faits?: string[] }
20
+
21
+ ## PIÈGES
22
+ - La MARQUE (pied légal, logo, couleurs) est celle du RÉCEPTEUR, jamais celle de l'expéditeur : l'éditeur ne peut pas changer le pied légal d'un produit.
23
+ - La signature porte sur le corps BRUT : ne pas re-sérialiser le JSON avant de vérifier.
24
+ - Pas d'image d'un autre site que le produit en pratique : la politique de sécurité du produit (img-src 'self') la bloquerait ; https est accepté par le modèle, à l'application de restreindre.
25
+ - `public: 'eleves'` est refusé : pas de pub vers les mineurs.
26
+
27
+ ## ESSAIS
28
+ test-scripts/unit/landing.test.mjs — T-LP-11a, 11b, 12a, 12b, 16a, 16b (10 essais) ; sabotages joués : signature ignorée, première image différée, accroche non échappée, « //hôte » accepté.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@mostajs/landing",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Landing pages de pub : modèle en sept blocs (accroche, image, bénéfices, étapes, questions, bouton, mentions), rendu HTML sans script sous 50 Ko avec pied légal, sept langues dont l'arabe de droite à gauche, envoi signé HMAC, récepteur monté dans le produit qui sert la page sur son propre domaine. Zéro dépendance.",
5
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
+ "license": "AGPL-3.0-or-later",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "scripts": {
13
+ "test": "bash test-scripts/run-tests.sh"
14
+ },
15
+ "devDependencies": {
16
+ "@mostajs/mjs-unit": "^0.11.2"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md",
21
+ "llms.txt",
22
+ "CHANGELOG.md",
23
+ "LICENSE"
24
+ ],
25
+ "keywords": ["landing-page", "ads", "utm", "hmac", "rtl", "mostajs"],
26
+ "mostajs": {
27
+ "niveau": "N1",
28
+ "nature": "technique",
29
+ "reutilisabilite": "multi-domaine",
30
+ "depend_de": []
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }
package/src/depots.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @mostajs/landing — où le RÉCEPTEUR garde les pages reçues : une interface, deux réalisations sans dépendance.
3
+ * enregistrer(page) → { slug, version } chaque envoi crée une version ; la dernière est servie
4
+ * derniere(slug) → { page, version, recueLe } | null
5
+ * versions(slug) → [{ version, recueLe }]
6
+ * retirer(slug) → boolean la page n'est plus servie (les versions restent, pour la trace)
7
+ * Une application peut fournir le sien (base de données) avec la même forme.
8
+ * @author Dr Hamid MADANI <drmdh@msn.com>
9
+ */
10
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { SLUG } from './modele.js'
13
+
14
+ /** En mémoire (essais). */
15
+ export function depotMemoire({ garder = 20 } = {}) {
16
+ const fiches = new Map()
17
+ return {
18
+ async enregistrer(page) {
19
+ const f = fiches.get(page.slug) ?? { retire: false, versions: [] }
20
+ const version = (f.versions.at(-1)?.version ?? 0) + 1
21
+ f.versions.push({ version, page, recueLe: new Date().toISOString() }); f.versions = f.versions.slice(-garder); f.retire = false
22
+ fiches.set(page.slug, f)
23
+ return { slug: page.slug, version }
24
+ },
25
+ async derniere(slug) { const f = fiches.get(slug); return f && !f.retire ? f.versions.at(-1) : null },
26
+ async versions(slug) { return (fiches.get(slug)?.versions ?? []).map(({ version, recueLe }) => ({ version, recueLe })) },
27
+ async retirer(slug) { const f = fiches.get(slug); if (!f) return false; f.retire = true; return true },
28
+ }
29
+ }
30
+
31
+ /** Dans un dossier (un fichier JSON par slug, écrit par renommage atomique) — survit au redéploiement s'il est hors du code. */
32
+ export function depotFichiers(dossier, { garder = 20 } = {}) {
33
+ mkdirSync(dossier, { recursive: true })
34
+ const chemin = (slug) => { if (!SLUG.test(slug)) throw new Error('landing : slug invalide'); return join(dossier, `${slug}.json`) }
35
+ const lire = (slug) => { const p = chemin(slug); return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null }
36
+ const ecrire = (slug, f) => { const p = chemin(slug); writeFileSync(`${p}.tmp`, JSON.stringify(f), { mode: 0o640 }); renameSync(`${p}.tmp`, p) }
37
+ return {
38
+ async enregistrer(page) {
39
+ const f = lire(page.slug) ?? { retire: false, versions: [] }
40
+ const version = (f.versions.at(-1)?.version ?? 0) + 1
41
+ f.versions.push({ version, page, recueLe: new Date().toISOString() }); f.versions = f.versions.slice(-garder); f.retire = false
42
+ ecrire(page.slug, f)
43
+ return { slug: page.slug, version }
44
+ },
45
+ async derniere(slug) { if (!SLUG.test(String(slug))) return null; const f = lire(slug); return f && !f.retire ? f.versions.at(-1) : null },
46
+ async versions(slug) { if (!SLUG.test(String(slug))) return []; return (lire(slug)?.versions ?? []).map(({ version, recueLe }) => ({ version, recueLe })) },
47
+ async retirer(slug) { if (!SLUG.test(String(slug))) return false; const f = lire(slug); if (!f) return false; f.retire = true; ecrire(slug, f); return true },
48
+ }
49
+ }
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @mostajs/landing — landing pages de pub : modèle en sept blocs, rendu sans script, envoi signé, récepteur.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ */
5
+ export { BLOCS, LANGUES, RTL, PUBLICS, SLUG, sensDe, adresseValide, validerPage, titreDe } from './modele.js'
6
+ export { TEXTES, textesDe } from './textes.js'
7
+ export { rendre, decrirePage, lienAvecSource, A_CONSERVER, esc } from './rendre.js'
8
+ export { signer, verifierSignature, ENTETE_SIGNATURE } from './signature.js'
9
+ export { creerRecepteur } from './recepteur.js'
10
+ export { depotMemoire, depotFichiers } from './depots.js'
11
+ export { envoyer } from './publier.js'
package/src/modele.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @mostajs/landing — le MODÈLE d'une landing page : sept blocs, une langue, un slug stable.
3
+ *
4
+ * accroche { titre, sousTitre? } une seule, obligatoire
5
+ * image { src, alt, legende? } src : chemin du site (/…) ou https://
6
+ * benefices { elements: [{ titre, texte? }] } 1 à 3
7
+ * etapes { titre?, elements: [{ titre }] } 1 à 5 « Comment ça marche ? »
8
+ * questions { titre?, elements: [{ q, r }] } 1 à 8
9
+ * bouton { libelle, href } href : chemin du site (/…) ou https://
10
+ * mentions { texte } texte libre au-dessus du pied légal
11
+ *
12
+ * Une page = UNE langue (fr, ar, en, de, es, it, zh) ; l'arabe s'écrit de droite à gauche. Le pied légal n'est PAS
13
+ * un bloc : il vient de la marque du produit et est toujours rendu (rendre.js). Validation sans dépendance.
14
+ * @author Dr Hamid MADANI <drmdh@msn.com>
15
+ */
16
+ export const BLOCS = Object.freeze(['accroche', 'image', 'benefices', 'etapes', 'questions', 'bouton', 'mentions'])
17
+ export const LANGUES = Object.freeze(['fr', 'ar', 'en', 'de', 'es', 'it', 'zh'])
18
+ export const RTL = Object.freeze(['ar'])
19
+ export const PUBLICS = Object.freeze(['adultes', 'ecoles'])
20
+ export const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/
21
+
22
+ /** Le sens d'écriture d'une langue. */
23
+ export const sensDe = (langue) => (RTL.includes(langue) ? 'rtl' : 'ltr')
24
+
25
+ /** Une adresse acceptée dans une page : un chemin du site (« /… », pas « //… ») ou une adresse https. */
26
+ export function adresseValide(v) {
27
+ if (typeof v !== 'string' || !v) return false
28
+ if (v.startsWith('/') && !v.startsWith('//')) return !/[\s<>"]/.test(v)
29
+ try { return new URL(v).protocol === 'https:' } catch { return false }
30
+ }
31
+
32
+ const texte = (v, max) => typeof v === 'string' && v.trim().length > 0 && v.length <= max
33
+ const facultatif = (v, max) => v === undefined || v === null || v === '' || (typeof v === 'string' && v.length <= max)
34
+
35
+ /** Les règles de chaque bloc : (bloc, erreur(champ, message)) → void. */
36
+ const REGLES = {
37
+ accroche(b, e) { if (!texte(b.titre, 120)) e('titre', 'titre requis (120 signes au plus)'); if (!facultatif(b.sousTitre, 240)) e('sousTitre', '240 signes au plus') },
38
+ image(b, e) { if (!adresseValide(b.src)) e('src', 'chemin du site (/…) ou adresse https requis'); if (!texte(b.alt, 160)) e('alt', 'texte de remplacement requis (160 signes au plus)'); if (!facultatif(b.legende, 160)) e('legende', '160 signes au plus') },
39
+ benefices(b, e) { liste(b, e, 1, 3, (x, f) => { if (!texte(x?.titre, 80)) f('titre', 'titre requis (80 signes au plus)'); if (!facultatif(x?.texte, 200)) f('texte', '200 signes au plus') }) },
40
+ etapes(b, e) { if (!facultatif(b.titre, 80)) e('titre', '80 signes au plus'); liste(b, e, 1, 5, (x, f) => { if (!texte(x?.titre, 80)) f('titre', 'titre requis (80 signes au plus)') }) },
41
+ questions(b, e) { if (!facultatif(b.titre, 80)) e('titre', '80 signes au plus'); liste(b, e, 1, 8, (x, f) => { if (!texte(x?.q, 160)) f('q', 'question requise (160 signes au plus)'); if (!texte(x?.r, 600)) f('r', 'réponse requise (600 signes au plus)') }) },
42
+ bouton(b, e) { if (!texte(b.libelle, 40)) e('libelle', 'libellé requis (40 signes au plus)'); if (!adresseValide(b.href)) e('href', 'chemin du site (/…) ou adresse https requis') },
43
+ mentions(b, e) { if (!texte(b.texte, 1000)) e('texte', 'texte requis (1 000 signes au plus)') },
44
+ }
45
+ function liste(b, e, min, max, regle) {
46
+ if (!Array.isArray(b.elements) || b.elements.length < min || b.elements.length > max) { e('elements', `${min} à ${max} éléments`); return }
47
+ b.elements.forEach((x, i) => regle(x, (champ, m) => e(`elements.${i}.${champ}`, m)))
48
+ }
49
+
50
+ /**
51
+ * Valide une page. @returns {{ ok: boolean, erreurs: { chemin: string, message: string }[] }}
52
+ * @param {{ slug: string, langue: string, public?: string, titre?: string, blocs: object[], faits?: string[] }} page
53
+ */
54
+ export function validerPage(page) {
55
+ const erreurs = []
56
+ const err = (chemin, message) => erreurs.push({ chemin, message })
57
+ if (!page || typeof page !== 'object') return { ok: false, erreurs: [{ chemin: '', message: 'page requise' }] }
58
+ if (!SLUG.test(String(page.slug ?? ''))) err('slug', 'minuscules, chiffres et tirets (2 à 80 signes)')
59
+ if (!LANGUES.includes(page.langue)) err('langue', `une de : ${LANGUES.join(', ')}`)
60
+ if (page.public !== undefined && !PUBLICS.includes(page.public)) err('public', `un de : ${PUBLICS.join(', ')}`)
61
+ if (!Array.isArray(page.blocs) || !page.blocs.length) { err('blocs', 'au moins un bloc'); return { ok: false, erreurs } }
62
+ if (page.blocs.length > 20) err('blocs', '20 blocs au plus')
63
+ page.blocs.forEach((b, i) => {
64
+ const type = b?.type
65
+ if (!BLOCS.includes(type)) { err(`blocs.${i}.type`, `bloc inconnu : ${String(type)}`); return }
66
+ REGLES[type](b, (champ, m) => err(`blocs.${i}.${champ}`, m))
67
+ })
68
+ const accroches = page.blocs.filter((b) => b?.type === 'accroche').length
69
+ if (accroches !== 1) err('blocs', 'une accroche, et une seule')
70
+ if (page.faits !== undefined && (!Array.isArray(page.faits) || page.faits.some((f) => typeof f !== 'string'))) err('faits', 'liste de textes')
71
+ return { ok: erreurs.length === 0, erreurs }
72
+ }
73
+
74
+ /** Le titre d'une page : celui de son accroche. */
75
+ export const titreDe = (page) => page?.blocs?.find((b) => b?.type === 'accroche')?.titre ?? ''
76
+
77
+ /** Les sept modèles de départ ne sont pas ici : ils appartiennent à l'application (textes de la marque). */
package/src/publier.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @mostajs/landing — l'ENVOI d'une page (ou de son retrait) au récepteur d'un produit, signé avec sa clé.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ */
5
+ import { signer, ENTETE_SIGNATURE } from './signature.js'
6
+
7
+ /**
8
+ * @param {{ origine: string, cle: string, page?: object, retirer?: string, prefixe?: string, fetch?: typeof fetch, delai?: number }} o
9
+ * origine : https://speaking.mostajs.dev (le produit) ; page : la page à publier ; retirer : le slug à retirer
10
+ * @returns {Promise<{ ok: boolean, status: number, corps: any }>}
11
+ */
12
+ export async function envoyer({ origine, cle, page, retirer, prefixe = '/p/', fetch: f = globalThis.fetch, delai = 10000 }) {
13
+ const corps = JSON.stringify(retirer ? { slug: retirer } : { page })
14
+ const adresse = `${String(origine).replace(/\/+$/, '')}${prefixe}${retirer ? '_retirer' : '_publier'}`
15
+ const r = await f(adresse, { method: 'POST', headers: { 'content-type': 'application/json', [ENTETE_SIGNATURE]: signer(corps, cle) }, body: corps, signal: AbortSignal.timeout(delai), redirect: 'manual' })
16
+ let lu = null; try { lu = await r.json() } catch { /* corps non JSON */ }
17
+ return { ok: r.status === 201 || r.status === 200, status: r.status, corps: lu }
18
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @mostajs/landing — le RÉCEPTEUR, monté dans le produit qui sert les pages (ex. Speaking), sur SON domaine :
3
+ * POST <prefixe>_publier corps JSON { page } signé (x-landing-signature) → 201 { slug, version, adresse }
4
+ * POST <prefixe>_retirer corps JSON { slug } signé → 200 ; la page n'est plus servie
5
+ * GET <prefixe>_marque la marque PUBLIQUE du produit (nom, logo, couleurs, pied légal) : l'éditeur s'en sert pour
6
+ * l'aperçu et la conformité, et y voit que le récepteur est installé
7
+ * GET <prefixe><slug> la dernière version, rendue avec la MARQUE du produit (pied légal compris) ; 404 sinon
8
+ * Signature fausse, absente, périmée ou corps altéré → 401, rien n'est gardé. Page invalide → 422. Corps trop gros → 413.
9
+ * @author Dr Hamid MADANI <drmdh@msn.com>
10
+ */
11
+ import { verifierSignature, ENTETE_SIGNATURE } from './signature.js'
12
+ import { validerPage, SLUG } from './modele.js'
13
+ import { rendre, esc } from './rendre.js'
14
+
15
+ async function lireBrut(req, max) {
16
+ const morceaux = []; let n = 0
17
+ for await (const m of req) { n += m.length; if (n > max) return null; morceaux.push(m) }
18
+ return Buffer.concat(morceaux).toString('utf8')
19
+ }
20
+ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); return true }
21
+
22
+ /**
23
+ * @param {{ cle: string|(() => string), depot: object, marque: object|(() => object), prefixe?: string, maxOctets?: number,
24
+ * origine?: string, journal?: Pick<Console,'warn'> }} o
25
+ * cle : la clé de publication du produit (lue à chaque envoi : elle peut être renouvelée sans redémarrer)
26
+ * origine : adresse publique du produit (https://speaking.mostajs.dev), pour l'adresse rendue et la canonique
27
+ * @returns {(req, res, chemin?: string) => Promise<boolean>} true si la requête est servie
28
+ */
29
+ export function creerRecepteur({ cle, depot, marque, prefixe = '/p/', maxOctets = 256 * 1024, origine = '', journal = console }) {
30
+ if (!depot) throw new Error('landing : un dépôt est requis')
31
+ const cleDe = () => (typeof cle === 'function' ? cle() : cle)
32
+ const marqueDe = () => (typeof marque === 'function' ? marque() : marque) ?? {}
33
+ return async function recepteur(req, res, chemin) {
34
+ const url = new URL(req.url, 'http://recepteur.local')
35
+ const p = chemin ?? url.pathname
36
+ if (!p.startsWith(prefixe)) return false
37
+ const suite = p.slice(prefixe.length)
38
+
39
+ if (req.method === 'POST' && (suite === '_publier' || suite === '_retirer')) {
40
+ const brut = await lireBrut(req, maxOctets)
41
+ if (brut === null) return json(res, 413, { error: 'corps trop gros' })
42
+ const v = verifierSignature(brut, req.headers[ENTETE_SIGNATURE], cleDe())
43
+ if (!v.ok) { journal.warn?.(`landing : envoi refusé (${v.motif})`); return json(res, 401, { error: 'signature refusée', motif: v.motif }) }
44
+ let corps
45
+ try { corps = JSON.parse(brut) } catch { return json(res, 400, { error: 'JSON illisible' }) }
46
+ if (suite === '_retirer') return json(res, 200, { retiree: await depot.retirer(String(corps?.slug ?? '')) })
47
+ const r = validerPage(corps?.page)
48
+ if (!r.ok) return json(res, 422, { error: 'page invalide', erreurs: r.erreurs })
49
+ const { slug, version } = await depot.enregistrer(corps.page)
50
+ return json(res, 201, { slug, version, adresse: `${origine}${prefixe}${slug}` })
51
+ }
52
+
53
+ if (req.method === 'GET' && suite === '_marque') {
54
+ const m = marqueDe()
55
+ return json(res, 200, { recepteur: '@mostajs/landing', nom: m.nom ?? '', logo: m.logo ?? '', couleurs: m.couleurs ?? {}, pied: m.pied ?? {}, prefixe })
56
+ }
57
+
58
+ if ((req.method === 'GET' || req.method === 'HEAD') && SLUG.test(suite)) {
59
+ const d = await depot.derniere(suite)
60
+ if (!d) { res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' }); res.end(`<!doctype html><meta charset="utf-8"><title>404</title><p>${esc('Page introuvable.')}</p>`); return true }
61
+ const html = rendre(d.page, marqueDe(), { requete: url.searchParams, canonique: origine ? `${origine}${prefixe}${suite}` : '' })
62
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60', 'x-landing-version': String(d.version) })
63
+ res.end(req.method === 'HEAD' ? undefined : html)
64
+ return true
65
+ }
66
+ return false
67
+ }
68
+ }
package/src/rendre.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @mostajs/landing — le RENDU d'une page : HTML complet, sans aucun script, sous 50 Ko hors images, lisible de 320 à
3
+ * 1 280 px. La première image se charge tout de suite, les autres en différé. Le pied légal (confidentialité, mentions,
4
+ * éditeur) vient de la MARQUE du produit qui sert la page, jamais de l'expéditeur, et il est toujours rendu.
5
+ *
6
+ * Le bouton recopie dans son lien les UTM et identifiants de clic de l'adresse reçue : la source arrive jusqu'à
7
+ * l'inscription sans cookie ni script.
8
+ * @author Dr Hamid MADANI <drmdh@msn.com>
9
+ */
10
+ import { sensDe, titreDe, adresseValide } from './modele.js'
11
+ import { textesDe } from './textes.js'
12
+
13
+ /** Les paramètres recopiés de l'adresse reçue vers le bouton (UTM, puis identifiants de clic des régies). */
14
+ export const A_CONSERVER = Object.freeze(['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'utm_id', 'fbclid', 'gclid', 'gbraid', 'wbraid', 'ttclid', 'ScCid'])
15
+
16
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
17
+ const couleur = (v, defaut) => (/^#[0-9a-fA-F]{3,8}$/.test(String(v ?? '')) ? v : defaut)
18
+
19
+ /** Le lien du bouton, augmenté des paramètres à conserver présents dans la requête (sans écraser ceux du lien). */
20
+ export function lienAvecSource(href, requete, conserver = A_CONSERVER) {
21
+ if (!adresseValide(href)) return '#'
22
+ const q = requete instanceof URLSearchParams ? requete : new URLSearchParams(String(requete ?? '').replace(/^\?/, ''))
23
+ const ajouts = conserver.filter((k) => q.has(k)).map((k) => [k, q.get(k)])
24
+ if (!ajouts.length) return href
25
+ const [base, ancre = ''] = href.split('#')
26
+ const [chemin, existant = ''] = base.split('?')
27
+ const p = new URLSearchParams(existant)
28
+ for (const [k, v] of ajouts) if (!p.has(k)) p.set(k, v)
29
+ return `${chemin}?${p.toString()}${ancre ? `#${ancre}` : ''}`
30
+ }
31
+
32
+ /** La feuille de style (préfixée par `.lp`, pour l'aperçu dans un éditeur comme pour la page). */
33
+ function feuille(c) {
34
+ return `.lp{--b:${c.brand};--l:${c.lien};--o:${c.ornement};color:#13233f;font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;max-width:40rem;margin:0 auto;padding:1rem 1.1rem 0;box-sizing:border-box;overflow-wrap:anywhere}
35
+ .lp *{box-sizing:border-box}.lp header{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem}
36
+ .lp header img{height:2.4rem;width:auto}.lp header b{font-size:1.1rem;color:var(--b)}
37
+ .lp h1{font-size:clamp(1.6rem,6vw,2.3rem);line-height:1.15;margin:.4rem 0;color:var(--b);text-align:center}
38
+ .lp .sous{text-align:center;color:#41506b;margin:.3rem 0 1rem}.lp figure{margin:1rem 0;text-align:center}
39
+ .lp figure img{max-width:100%;height:auto;border-radius:16px}.lp .img-place{border:2px dashed #c7d3e6;border-radius:16px;padding:2.5rem 1rem;color:#5b6b82;background:#f6f9fd}.lp figcaption{color:#5b6b82;font-size:.9rem}
40
+ .lp .bouton{display:block;text-align:center;background:var(--b);color:#fff;text-decoration:none;font-weight:700;border-radius:14px;padding:.95rem 1rem;margin:1.1rem 0;font-size:1.1rem}
41
+ .lp .benef{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.8rem;margin:1rem 0;padding:0;list-style:none}
42
+ .lp .benef li{background:#f3f7fc;border-radius:14px;padding:.8rem;text-align:center}.lp .benef b{display:block;color:var(--b)}
43
+ .lp h2{font-size:1.25rem;text-align:center;color:var(--b);margin:1.4rem 0 .6rem}
44
+ .lp ol{display:flex;flex-wrap:wrap;justify-content:center;gap:.6rem;padding:0;list-style:none;counter-reset:e}
45
+ .lp ol li{counter-increment:e;flex:1 1 6rem;text-align:center;font-weight:600}.lp ol li::before{content:counter(e);display:block;margin:0 auto .3rem;width:2.2rem;height:2.2rem;line-height:2.2rem;border-radius:50%;background:var(--l);color:#fff}
46
+ .lp details{border-bottom:1px solid #e3e8f2;padding:.6rem 0}.lp summary{font-weight:600;cursor:pointer}
47
+ .lp .mentions{color:#5b6b82;font-size:.9rem;margin-top:1.2rem}
48
+ .lp footer{border-top:1px solid #e3e8f2;margin-top:1.5rem;padding:1rem 0;text-align:center;font-size:.9rem;color:#5b6b82}
49
+ .lp footer a{color:var(--l);margin:0 .5rem}`
50
+ }
51
+
52
+ /** Le corps d'un bloc. `n` : rang de l'image (la première ne se charge pas en différé). */
53
+ function bloc(b, T, ctx) {
54
+ switch (b.type) {
55
+ case 'accroche': return `<h1>${esc(b.titre)}</h1>${b.sousTitre ? `<p class="sous">${esc(b.sousTitre)}</p>` : ''}`
56
+ case 'image': {
57
+ const premiere = ctx.images++ === 0
58
+ if (ctx.sansImages) return `<figure><div class="img-place" role="img" aria-label="${esc(b.alt)}">${esc(b.alt)}</div>${b.legende ? `<figcaption>${esc(b.legende)}</figcaption>` : ''}</figure>`
59
+ return `<figure><img src="${esc(b.src)}" alt="${esc(b.alt)}" decoding="async"${premiere ? ' fetchpriority="high"' : ' loading="lazy"'}>${b.legende ? `<figcaption>${esc(b.legende)}</figcaption>` : ''}</figure>`
60
+ }
61
+ case 'benefices': return `<ul class="benef">${b.elements.map((x) => `<li><b>${esc(x.titre)}</b>${x.texte ? esc(x.texte) : ''}</li>`).join('')}</ul>`
62
+ case 'etapes': return `<h2>${esc(b.titre || T.etapes)}</h2><ol>${b.elements.map((x) => `<li>${esc(x.titre)}</li>`).join('')}</ol>`
63
+ case 'questions': return `<h2>${esc(b.titre || T.questions)}</h2>${b.elements.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.r)}</p></details>`).join('')}`
64
+ case 'bouton': return `<a class="bouton" href="${esc(lienAvecSource(b.href, ctx.requete))}">${esc(b.libelle)}</a>`
65
+ case 'mentions': return `<p class="mentions">${esc(b.texte)}</p>`
66
+ default: return ''
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {object} page page validée (modele.js)
72
+ * @param {{ nom: string, logo?: string, couleurs?: { brand?, lien?, ornement? }, pied?: { confidentialite?, mentions?, editeur? } }} marque
73
+ * @param {{ requete?: string|URLSearchParams, fragment?: boolean, canonique?: string, sansImages?: boolean }} [o]
74
+ * fragment : rend le seul contenu (aperçu dans un éditeur), sans <html> ; canonique : adresse publique de la page ;
75
+ * sansImages : un cadre portant le texte de remplacement à la place de chaque image et du logo (aperçu servi par un
76
+ * autre domaine que le produit, dont la politique de sécurité refuserait les images)
77
+ * @returns {string}
78
+ */
79
+ export function rendre(page, marque = {}, { requete = '', fragment = false, canonique = '', sansImages = false } = {}) {
80
+ const T = textesDe(page.langue)
81
+ const c = { brand: couleur(marque.couleurs?.brand, '#0b2a5b'), lien: couleur(marque.couleurs?.lien, '#1565d8'), ornement: couleur(marque.couleurs?.ornement, '#f28c28') }
82
+ const ctx = { images: 0, requete, sansImages }
83
+ const pied = marque.pied ?? {}
84
+ const liens = [
85
+ adresseValide(pied.confidentialite) ? `<a href="${esc(pied.confidentialite)}">${esc(T.confidentialite)}</a>` : '',
86
+ adresseValide(pied.mentions) ? `<a href="${esc(pied.mentions)}">${esc(T.mentions)}</a>` : '',
87
+ ].join('')
88
+ const entete = `<header>${adresseValide(marque.logo) && !sansImages ? `<img src="${esc(marque.logo)}" alt="${esc(marque.nom)}">` : `<b>${esc(marque.nom)}</b>`}</header>`
89
+ const corps = `<div class="lp" lang="${esc(page.langue)}" dir="${sensDe(page.langue)}">${entete}${page.blocs.map((b) => bloc(b, T, ctx)).join('')}<footer>${liens}${pied.editeur ? `<p>${esc(T.editeur)} : ${esc(pied.editeur)}</p>` : ''}</footer></div>`
90
+ if (fragment) return `<style>${feuille(c)}</style>${corps}`
91
+ return `<!doctype html><html lang="${esc(page.langue)}" dir="${sensDe(page.langue)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(titreDe(page))} — ${esc(marque.nom)}</title>${canonique ? `<link rel="canonical" href="${esc(canonique)}">` : ''}<style>body{margin:0;background:#fff}${feuille(c)}</style></head><body>${corps}</body></html>`
92
+ }
93
+
94
+ /**
95
+ * La DESCRIPTION d'une page rendue, telle que la lit `verifierPage` de @mostajs/ad-platforms (ce module n'en dépend
96
+ * pas : il décrit, l'application vérifie).
97
+ * @param {object} page @param {object} marque
98
+ * @param {{ adresse: string, domaineProduit: string, langueCampagne?: string, accrocheAnnonce?: string, html?: string }} o
99
+ */
100
+ export function decrirePage(page, marque, { adresse, domaineProduit, langueCampagne, accrocheAnnonce, html } = {}) {
101
+ const rendu = html ?? rendre(page, marque)
102
+ const hrefs = page.blocs.filter((b) => b.type === 'bouton').map((b) => b.href)
103
+ const externes = hrefs.filter((h) => { try { return new URL(h).hostname !== domaineProduit } catch { return false } })
104
+ const textes = page.blocs.flatMap((b) => [b.titre, b.sousTitre, b.texte, b.legende, b.libelle, ...(b.elements ?? []).flatMap((x) => [x.titre, x.texte, x.q, x.r])]).filter(Boolean)
105
+ return {
106
+ adresse, domaineProduit, langue: page.langue, langueCampagne: langueCampagne ?? page.langue, public: page.public,
107
+ pied: { confidentialite: marque.pied?.confidentialite ?? '', mentions: marque.pied?.mentions ?? '', editeur: marque.pied?.editeur ?? '' },
108
+ boutons: hrefs.length, liensSortie: externes.length, scripts: [], consentement: false, surgissante: false,
109
+ htmlOctets: Buffer.byteLength(rendu, 'utf8'), titre: titreDe(page), accrocheAnnonce, texte: textes.join(' '), faits: page.faits ?? [],
110
+ }
111
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @mostajs/landing — la SIGNATURE d'un envoi de page (HMAC-SHA256), à la manière des webhooks : l'en-tête porte
3
+ * l'heure et l'empreinte de « <heure>.<corps> ». Le récepteur refuse une empreinte fausse, un corps altéré et un
4
+ * envoi trop ancien (rejeu). Comparaison à temps constant. La clé n'est jamais transmise.
5
+ * x-landing-signature: t=1761930000,v1=<64 hex>
6
+ * @author Dr Hamid MADANI <drmdh@msn.com>
7
+ */
8
+ import { createHmac, timingSafeEqual } from 'node:crypto'
9
+
10
+ export const ENTETE_SIGNATURE = 'x-landing-signature'
11
+
12
+ const empreinte = (cle, t, corps) => createHmac('sha256', String(cle)).update(`${t}.${corps}`).digest('hex')
13
+
14
+ /** @returns {string} la valeur de l'en-tête pour ce corps (chaîne exacte envoyée). */
15
+ export function signer(corps, cle, { maintenant = Date.now() } = {}) {
16
+ if (!cle) throw new Error('landing : clé de publication requise pour signer')
17
+ const t = Math.floor(maintenant / 1000)
18
+ return `t=${t},v1=${empreinte(cle, t, corps)}`
19
+ }
20
+
21
+ /**
22
+ * @param {string|Buffer} corpsBrut le corps reçu, tel quel (pas re-sérialisé)
23
+ * @param {string} entete @param {string} cle
24
+ * @returns {{ ok: true } | { ok: false, motif: 'absente'|'illisible'|'perimee'|'fausse' }}
25
+ */
26
+ export function verifierSignature(corpsBrut, entete, cle, { tolerance = 300, maintenant = Date.now() } = {}) {
27
+ if (!entete || !cle) return { ok: false, motif: 'absente' }
28
+ const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(String(entete).trim())
29
+ if (!m) return { ok: false, motif: 'illisible' }
30
+ const t = Number(m[1])
31
+ if (Math.abs(Math.floor(maintenant / 1000) - t) > tolerance) return { ok: false, motif: 'perimee' }
32
+ const attendu = Buffer.from(empreinte(cle, t, Buffer.isBuffer(corpsBrut) ? corpsBrut.toString('utf8') : String(corpsBrut)), 'hex')
33
+ const recu = Buffer.from(m[2], 'hex')
34
+ return attendu.length === recu.length && timingSafeEqual(attendu, recu) ? { ok: true } : { ok: false, motif: 'fausse' }
35
+ }
package/src/textes.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @mostajs/landing — les libellés fixes d'une page (titres par défaut, pied légal), dans les sept langues.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ */
5
+ export const TEXTES = Object.freeze({
6
+ fr: { etapes: 'Comment ça marche ?', questions: 'Questions fréquentes', confidentialite: 'Confidentialité', mentions: 'Mentions légales', editeur: 'Éditeur' },
7
+ ar: { etapes: 'كيف يعمل؟', questions: 'أسئلة شائعة', confidentialite: 'الخصوصية', mentions: 'الإشعارات القانونية', editeur: 'الناشر' },
8
+ en: { etapes: 'How it works', questions: 'Frequently asked questions', confidentialite: 'Privacy', mentions: 'Legal notice', editeur: 'Publisher' },
9
+ de: { etapes: 'So funktioniert es', questions: 'Häufige Fragen', confidentialite: 'Datenschutz', mentions: 'Impressum', editeur: 'Herausgeber' },
10
+ es: { etapes: '¿Cómo funciona?', questions: 'Preguntas frecuentes', confidentialite: 'Privacidad', mentions: 'Aviso legal', editeur: 'Editor' },
11
+ it: { etapes: 'Come funziona', questions: 'Domande frequenti', confidentialite: 'Privacy', mentions: 'Note legali', editeur: 'Editore' },
12
+ zh: { etapes: '如何使用', questions: '常见问题', confidentialite: '隐私', mentions: '法律声明', editeur: '发布者' },
13
+ })
14
+ export const textesDe = (langue) => TEXTES[langue] ?? TEXTES.fr