diff --git a/scripts/__tests__/challenge.schema.test.js b/scripts/__tests__/challenge.schema.test.js new file mode 100644 index 0000000..e6df0e2 --- /dev/null +++ b/scripts/__tests__/challenge.schema.test.js @@ -0,0 +1,299 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import matter from 'gray-matter'; +import { describe, expect, it } from 'vitest'; +import { validateChallenge } from '../schemas/challenge.schema.js'; +import { STAGE_IDS } from '../schemas/stage-ids.js'; + +// One golden fixture per challenge, built from the text of the six enjeux-*.html +// mockups in ocobo-revops/prototype, without the partner block and the agent +// window. +const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/challenges'); +const loadFixture = (file) => matter(readFileSync(join(FIXTURES_DIR, file), 'utf8')).data; + +const base = loadFixture('revenue-performance.md'); + +const withValue = (path, value) => { + const data = structuredClone(base); + const parent = path.slice(0, -1).reduce((node, key) => node[key], data); + parent[path.at(-1)] = value; + return data; +}; + +const without = (path) => { + const data = structuredClone(base); + const parent = path.slice(0, -1).reduce((node, key) => node[key], data); + delete parent[path.at(-1)]; + return data; +}; + +const issuesOf = (data) => { + const result = validateChallenge(data); + return result.success ? [] : result.error.issues.map((i) => ({ path: i.path, message: i.message })); +}; + +// ── golden fixtures ─────────────────────────────────────────────────────────── + +describe('challenge golden fixtures', () => { + const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith('.md')); + + it('has one fixture per challenge key', () => { + expect(files.map((f) => loadFixture(f).key).sort()).toEqual(['ai', 'cx', 'rp', 'sc', 'se', 'sy']); + }); + + // The slug is the English kebab-case of the name, the same in both languages. + it.each(files)('%s is named after its challenge', (file) => { + expect(file).toBe(`${loadFixture(file).name.toLowerCase().replaceAll(' ', '-')}.md`); + }); + + it.each(files)('%s passes', (file) => { + expect(issuesOf(loadFixture(file))).toEqual([]); + }); + + it('the Revenue AI fixture sets the heading overrides', () => { + const ai = loadFixture('revenue-ai.md'); + expect(ai.method.title).toBeDefined(); + expect(ai.method.steps.every((s) => s.title)).toBe(true); + expect(ai.workstreams.title).toBeDefined(); + }); +}); + +// ── caps ────────────────────────────────────────────────────────────────────── + +const CAPS = [ + [['name'], 30], + [['tagline'], 60], + [['hero', 'title', 0], 34], + [['hero', 'title', 1], 42], + [['hero', 'quote'], 80], + [['hero', 'lead'], 240], + [['hero', 'highlight'], 110], + [['hero', 'cta'], 48], + [['bowtie', 'caption'], 140], + [['symptoms', 'title', 0], 64], + [['symptoms', 'title', 1], 44], + [['symptoms', 'items', 0], 130], + [['audience', 0, 'team'], 20], + [['audience', 0, 'roles'], 30], + [['audience', 0, 'benefit'], 100], + [['workstreams', 'title'], 50], + [['workstreams', 'intro'], 120], + [['workstreams', 'items', 0, 'category'], 26], + [['workstreams', 'items', 0, 'title'], 64], + [['workstreams', 'items', 0, 'description'], 210], + [['workstreams', 'items', 0, 'deliverable'], 170], + [['workstreams', 'items', 0, 'note'], 100], + [['aiBridge', 'title'], 92], + [['aiBridge', 'bullets', 0], 125], + [['method', 'title'], 30], + [['method', 'subtitle'], 75], + [['method', 'steps', 0, 'title'], 28], + [['method', 'steps', 0, 'text'], 150], + [['method', 'note'], 170], + [['case', 'headline'], 120], + [['case', 'summary'], 220], + [['case', 'metrics', 0, 'value'], 12], + [['case', 'metrics', 0, 'label'], 90], + [['diagnostic', 'question'], 88], + [['diagnostic', 'subtitle'], 125], +]; + +describe('challenge caps', () => { + it.each(CAPS)('%j accepts %i characters', (path, cap) => { + expect(issuesOf(withValue(path, 'a'.repeat(cap)))).toEqual([]); + }); + + it.each(CAPS)('%j rejects one character over cap %i, stating length, cap and overage', (path, cap) => { + expect(issuesOf(withValue(path, 'a'.repeat(cap + 1)))).toEqual([ + { path, message: `${cap + 1} characters, cap ${cap} (1 over)` }, + ]); + }); + + it('measures the overage of a long text', () => { + expect(issuesOf(withValue(['hero', 'lead'], 'a'.repeat(263)))).toEqual([ + { path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }, + ]); + }); + + it('measures the length after trimming', () => { + expect(issuesOf(withValue(['name'], ` ${'a'.repeat(30)} `))).toEqual([]); + }); +}); + +// ── strings ─────────────────────────────────────────────────────────────────── + +describe('challenge strings', () => { + it('trims every string', () => { + const result = validateChallenge(withValue(['hero', 'quote'], ' Une citation. ')); + expect(result.data.hero.quote).toBe('Une citation.'); + }); + + it.each([ + [['name']], + [['symptoms', 'items', 2]], + [['audience', 3, 'benefit']], + [['method', 'tools', 0]], + ])('rejects a blank string at %j', (path) => { + expect(issuesOf(withValue(path, ' '))).toEqual([{ path, message: 'must not be empty' }]); + }); +}); + +// ── array counts ────────────────────────────────────────────────────────────── + +const COUNTS = [ + [['hero', 'title'], 2, 2], + [['symptoms', 'title'], 2, 2], + [['symptoms', 'items'], 5, 6], + [['audience'], 4, 4], + [['workstreams', 'items'], 4, 6], + [['aiBridge', 'bullets'], 3, 3], + [['method', 'steps'], 3, 3], + [['method', 'tools'], 0, 9], + [['case', 'metrics'], 2, 2], +]; + +const resize = (path, count) => { + const items = path.reduce((node, key) => node[key], base); + return Array.from({ length: count }, (_, i) => structuredClone(items[i % items.length])); +}; + +describe('challenge array counts', () => { + it.each(COUNTS)('%j accepts %i to %i items', (path, min, max) => { + expect(issuesOf(withValue(path, resize(path, min)))).toEqual([]); + expect(issuesOf(withValue(path, resize(path, max)))).toEqual([]); + }); + + it.each(COUNTS)('%j rejects fewer than %i or more than %i items', (path, min, max) => { + const error = [{ path, message: expect.any(String) }]; + if (min > 0) { + expect(issuesOf(withValue(path, resize(path, min - 1)))).toEqual(error); + } + expect(issuesOf(withValue(path, resize(path, max + 1)))).toEqual(error); + }); +}); + +// ── Stage ids ───────────────────────────────────────────────────────────────── + +describe('challenge Stage ids', () => { + it('lists the seven Stages in bow tie order', () => { + expect(STAGE_IDS).toEqual([ + 'demand-generation', + 'discovery', + 'negotiation', + 'closing', + 'onboarding', + 'adoption', + 'expansion', + ]); + expect(Object.isFrozen(STAGE_IDS)).toBe(true); + }); + + it('accepts every Stage id', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], [...STAGE_IDS]))).toEqual([]); + }); + + it('accepts a single Stage', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], ['closing']))).toEqual([]); + }); + + it('rejects no Stage', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], []))).toEqual([ + { path: ['bowtie', 'activeStages'], message: expect.any(String) }, + ]); + }); + + it('rejects an unknown Stage id', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], ['discovery', 'qualification']))).toEqual([ + { path: ['bowtie', 'activeStages', 1], message: expect.any(String) }, + ]); + }); + + it('rejects a duplicate Stage id', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], ['discovery', 'closing', 'discovery']))).toEqual([ + { path: ['bowtie', 'activeStages'], message: 'Stage ids must be unique' }, + ]); + }); +}); + +// ── key, order, step ────────────────────────────────────────────────────────── + +describe('challenge key, order and step', () => { + it.each(['rp', 'se', 'sc', 'cx', 'sy', 'ai'])('accepts key %s', (key) => { + expect(issuesOf(withValue(['key'], key))).toEqual([]); + }); + + it('rejects an unknown key', () => { + expect(issuesOf(withValue(['key'], 'ops'))).toEqual([{ path: ['key'], message: expect.any(String) }]); + }); + + it.each([1, 6])('accepts order %i', (order) => { + expect(issuesOf(withValue(['order'], order))).toEqual([]); + }); + + it.each([0, 7, 2.5, '3'])('rejects order %j', (order) => { + expect(issuesOf(withValue(['order'], order))).toEqual([{ path: ['order'], message: expect.any(String) }]); + }); + + it.each([1, 6])('accepts workstream step %i', (step) => { + expect(issuesOf(withValue(['workstreams', 'items', 0, 'step'], step))).toEqual([]); + }); + + it.each([0, 7, 1.5])('rejects workstream step %j', (step) => { + expect(issuesOf(withValue(['workstreams', 'items', 0, 'step'], step))).toEqual([ + { path: ['workstreams', 'items', 0, 'step'], message: expect.any(String) }, + ]); + }); +}); + +// ── optional and required fields ────────────────────────────────────────────── + +describe('challenge optional fields', () => { + it('accepts a case without a Story', () => { + expect(base.case.story).toBeUndefined(); + expect(issuesOf(base)).toEqual([]); + }); + + it('accepts a case citing a Story', () => { + expect(issuesOf(withValue(['case', 'story'], 'qonto'))).toEqual([]); + }); + + it.each([ + [['hero', 'lead']], + [['bowtie', 'activeStages']], + [['workstreams', 'intro']], + [['method', 'tools']], + [['diagnostic']], + ])('rejects a missing %j', (path) => { + expect(issuesOf(without(path))).toEqual([{ path, message: expect.any(String) }]); + }); +}); + +// ── unknown keys ────────────────────────────────────────────────────────────── + +describe('challenge unknown keys', () => { + it('rejects extraSections', () => { + expect(issuesOf({ ...base, extraSections: [{ type: 'stack' }] })).toEqual([ + { path: [], message: 'Unrecognized key: "extraSections"' }, + ]); + }); + + it.each([ + [['hero', 'subtitle']], + [['bowtie', 'legend']], + [['symptoms', 'intro']], + [['workstreams', 'cta']], + [['aiBridge', 'demo']], + [['method', 'cta']], + [['case', 'quote']], + [['diagnostic', 'cta']], + [['audience', 0, 'icon']], + [['workstreams', 'items', 0, 'image']], + [['method', 'steps', 1, 'icon']], + [['case', 'metrics', 0, 'trend']], + ])('rejects the unknown key %j', (path) => { + expect(issuesOf(withValue(path, 'x'))).toEqual([ + { path: path.slice(0, -1), message: `Unrecognized key: "${path.at(-1)}"` }, + ]); + }); +}); diff --git a/scripts/__tests__/fixtures/challenges/customer-experience.md b/scripts/__tests__/fixtures/challenges/customer-experience.md new file mode 100644 index 0000000..9c56f32 --- /dev/null +++ b/scripts/__tests__/fixtures/challenges/customer-experience.md @@ -0,0 +1,103 @@ +--- +key: cx +order: 2 +name: Customer Experience +tagline: Des clients qui restent et achètent plus +hero: + title: + - Des clients qui restent + - et achètent plus. + quote: On signe, puis on perd le fil. + lead: "Une grande part de votre chiffre de demain est déjà chez vos clients d'aujourd'hui. Nous construisons l'organisation, les processus, les outils et les indicateurs qui la sécurisent," + highlight: de la signature au renouvellement. + cta: Sécuriser mon revenu récurrent +bowtie: + activeStages: + - onboarding + - adoption + - expansion + caption: "Après la signature : démarrage, valeur délivrée, renouvellement et expansion." +symptoms: + title: + - Vous découvrez vos départs de clients + - quand ils sont déjà partis. + items: + - La passation entre la vente et la production se fait par mail, et chacun repose les mêmes questions au client. + - Les renouvellements se découvrent quelques semaines avant l'échéance. + - Personne ne sait quels clients sont à risque avant qu'ils ne le disent. + - Les opportunités de développement dépendent du réflexe de chaque chargé de compte. + - La vente, le support et la gestion de comptes ne partagent pas la même vue du client. +audience: + - team: Customer Success + roles: Head of CS, CCO + benefit: Une vision claire de la santé du parc et des renouvellements à venir. + - team: Customer Success + roles: CSM, Account Managers + benefit: Moins de pompiers, plus de rendez-vous qui préparent l'expansion. + - team: Direction + roles: CEO, DG + benefit: Un NRR qui progresse et une croissance qui ne repose plus que sur l'acquisition. + - team: Ventes + roles: Équipes commerciales + benefit: Une passation nette après la signature, sans perte d'information. +workstreams: + intro: Six chantiers, de la signature au développement du compte. + items: + - category: Organisation + title: Qui s'occupe de quel client, et comment + description: Segmentation du portefeuille, rôles entre gestion de comptes, succès client et support, niveaux de service par segment. + deliverable: Modèle d'organisation client et règles de couverture. + - category: Passation + step: 1 + title: Une passation sans perte d'information + description: "Ce que la vente a promis arrive à la production : fiche de passation dans le CRM, démarrage standardisé, jalons de mise en route." + deliverable: Processus de passation et de démarrage outillé. + - category: KPIs et pilotage + step: 3 + title: La santé de chaque client, lisible + description: Score de santé, signaux de risque, rétention et développement du revenu par segment, revues de portefeuille. + deliverable: Tableaux de bord client et rituels de revue. + - category: Renouvellement + step: 4 + title: Des renouvellements anticipés, un développement piloté + description: Échéances visibles des mois à l'avance, pipe de renouvellement et d'expansion, plans de compte pour les clients clés. + deliverable: Un pipe de renouvellement et d'expansion dans votre CRM. + - category: Processus et techno + title: Une seule vue du client, partagée + description: "CRM, support et facturation reliés : chaque équipe voit l'historique, les tickets, les contrats et les opportunités." + deliverable: Une fiche client unifiée et ses automatisations. + - category: Enablement + title: Des équipes client outillées + description: Playbooks de suivi, de renouvellement et de développement, intégrés aux outils des équipes. + deliverable: Playbooks client et parcours d'intégration des chargés de compte. +aiBridge: + title: Les signaux faibles remontent avant que le client ne parte. + bullets: + - Les tickets, mails et appels sont lus pour détecter un risque ou une opportunité. + - Chaque chargé de compte reçoit un résumé du compte avant son rendez-vous. + - Les revues de compte se préparent en quelques minutes. +method: + subtitle: De l'analyse du portefeuille à un revenu récurrent piloté. + steps: + - text: Le Design Sprint analyse votre portefeuille, vos départs et vos renouvellements, et identifie où le revenu se perd. + - text: La squad construit l'organisation, les processus et les indicateurs avec vos équipes vente, production et support. + - text: Vos équipes pilotent le portefeuille sur les indicateurs, et les renouvellements cessent d'être des surprises. + tools: + - hubspot + - salesforce + - planhat + - zendesk + - vitally + note: "Et ceux que vous avez déjà : nous partons de votre stack." +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour la direction.]" + metrics: + - value: "[X]" + label: "[Taux de renouvellement, avant et après]" + - value: "[X]" + label: "[Part du chiffre issue du développement des clients existants]" +diagnostic: + question: Quelle part de votre chiffre de l'an prochain est déjà chez vos clients ? + subtitle: Trente minutes avec un architecte du revenu pour le mesurer, et savoir par où commencer. +--- diff --git a/scripts/__tests__/fixtures/challenges/revenue-ai.md b/scripts/__tests__/fixtures/challenges/revenue-ai.md new file mode 100644 index 0000000..0b07af6 --- /dev/null +++ b/scripts/__tests__/fixtures/challenges/revenue-ai.md @@ -0,0 +1,116 @@ +--- +key: ai +order: 6 +name: Revenue AI +tagline: Une IA qui travaille pour vos équipes Revenue +hero: + title: + - Vos équipes testent l'IA. + - Nous la faisons passer à l'échelle. + quote: Tout le monde utilise ChatGPT, personne ne vend mieux. + lead: "Un agent IA ne sait pas ce qu'est une opportunité qualifiée. Quelqu'un doit le lui avoir appris. Nous choisissons les usages qui comptent pour vos ventes, nous remettons d'aplomb" + highlight: les données et les processus sur lesquels ils tournent, et nous mesurons ce qu'ils rapportent. + cta: Lancer un Revenue AI Sprint +bowtie: + activeStages: + - demand-generation + - discovery + - negotiation + - closing + - onboarding + - adoption + - expansion + caption: À chaque étape, l'IA capte l'information et la restitue au bon moment. Pourvu que l'étape soit bien définie. +symptoms: + title: + - Beaucoup d'essais. + - Peu d'effet sur les ventes. + items: + - Chaque commercial a ses propres outils IA, et personne ne sait lesquels. + - Vous avez payé des licences que la moitié de l'équipe n'ouvre plus. + - Le pilote IA a plu en démonstration, puis s'est arrêté. + - Vos données clients partent dans des outils que personne n'a validés. + - Personne ne peut dire combien l'IA a rapporté ce trimestre. +audience: + - team: Direction + roles: CEO, COMEX + benefit: Une feuille de route IA qui produit des résultats mesurables, pas des POC. + - team: Ventes + roles: CRO, Directeur commercial + benefit: Des équipes qui préparent, relancent et vendent plus vite. + - team: Ops + roles: RevOps, Ops + benefit: Des agents branchés sur une donnée fiable, gouvernés et maintenus. + - team: Terrain + roles: Commerciaux et CSM + benefit: Moins de tâches administratives, plus de temps avec les clients. +workstreams: + title: Six cas d'usage Revenue, prêts à déployer + intro: Chacun se livre en un Revenue AI Sprint, avec ses prérequis RevOps vérifiés d'abord. + items: + - category: Cibler + title: Les bons comptes, enrichis et priorisés + description: Identification des comptes qui correspondent à votre cible, enrichissement automatique, puis priorisation selon les signaux d'achat. Vos commerciaux savent qui appeler, et pourquoi. + deliverable: "Un agent de scoring branché sur le CRM : chaque matin, la liste des comptes à appeler, enrichis et classés, avec le signal qui justifie l'appel." + note: "Prérequis : un ICP explicite et un CRM qui sert de référence." + - category: Capter + title: Les comptes rendus d'appel remplissent le CRM + description: Qualification, objections et prochaines étapes rattachées au bon deal, sans saisie. + deliverable: "Un agent post-appel connecté à votre téléphonie : résumé, champs de qualification et prochaine étape écrits dans le deal, à valider en un clic." + note: "Prérequis : des étapes de vente et des champs de qualification définis." + - category: Restituer + title: La préparation de rendez-vous en une minute + description: Historique du compte, derniers échanges, enjeux probables, questions à poser. + deliverable: "Un brief automatique envoyé avant chaque rendez-vous dans Slack ou par mail : contexte du compte, interlocuteurs, enjeux et questions à poser." + note: "Prérequis : une fiche compte et un historique d'activité fiables." + - category: Piloter + title: La revue de pipe assistée + description: Les deals à risque signalés avant la réunion, avec la raison du risque. + deliverable: "Une alerte hebdomadaire dans le canal de l'équipe : les deals à risque, la raison du risque et une relance préparée pour chacun." + note: "Prérequis : des critères de sortie d'étape et des dates de closing tenues." + - category: Piloter + title: Le prévisionnel en langage naturel + description: Le dirigeant pose sa question en français et obtient le chiffre, avec son détail. + deliverable: "Un assistant de prévision interrogeable en français, relié au CRM : le chiffre, son détail par deal et l'écart avec la semaine précédente." + note: "Prérequis : une méthode de prévision et des catégories partagées." + - category: Faire progresser + title: Le playbook dans l'outil du commercial + description: La bonne ressource au bon moment du cycle, sans chercher dans un drive. + deliverable: "Un assistant de vente branché sur votre playbook : la bonne ressource, l'objection type ou le script poussés au commercial selon l'étape du deal." + note: "Prérequis : un playbook à jour, rangé dans Notion ou votre base de connaissance." +aiBridge: + title: L'IA accélère un système qui fonctionne. Ailleurs, elle amplifie le désordre. + bullets: + - Un assistant branché sur un CRM mal rempli produit des réponses fausses avec assurance. + - "C'est pourquoi chaque sprint commence par vérifier les fondations : données, processus de vente, outils." + - Quand elles tiennent, on déploie. Quand elles manquent, on vous le dit, et on commence par là. +method: + title: Le Revenue AI Sprint + subtitle: Trois semaines pour passer du test à la décision. + steps: + - title: Vérifier les fondations + text: "Données, processus de vente, outils : ce qui est prêt pour l'IA et ce qui ne l'est pas." + - title: Prouver sur un cas réel + text: Un premier usage branché sur vos outils, utilisé par une vraie équipe, mesuré. + - title: Décider la suite + text: Une feuille de route chiffrée en impact. Si les fondations manquent, on commence par elles. + tools: + - hubspot + - salesforce + - claude + - notion + - modjo + - claap + note: Les données dans votre CRM, l'intelligence avec Claude, le savoir dans Notion. Déployable aussi sur la plateforme d'agents que vous avez déjà. +case: + headline: Ocobo fonctionne avec l'IA au quotidien, de la préparation des rendez-vous au coaching commercial. + summary: "[Deux lignes sur l'usage interne, puis un premier cas client issu d'une mission.]" + metrics: + - value: "[X]" + label: "[Temps gagné par commercial]" + - value: "[X]" + label: "[Effet mesuré sur le pipe]" +diagnostic: + question: Votre organisation Revenue est-elle prête pour l'IA ? + subtitle: Trente minutes avec un architecte du revenu pour le savoir, et choisir le premier usage à déployer. +--- diff --git a/scripts/__tests__/fixtures/challenges/revenue-performance.md b/scripts/__tests__/fixtures/challenges/revenue-performance.md new file mode 100644 index 0000000..075ea75 --- /dev/null +++ b/scripts/__tests__/fixtures/challenges/revenue-performance.md @@ -0,0 +1,92 @@ +--- +key: rp +order: 3 +name: Revenue Performance +tagline: Un chiffre que vous voyez venir +hero: + title: + - Un chiffre + - que vous voyez venir. + quote: Je découvre mon trimestre à la fin du trimestre. + lead: "Votre board, votre fonds ou votre banque vous demandent un prévisionnel. Vos équipes vous donnent une intuition. Nous construisons le système qui transforme" + highlight: votre pipe en chiffre fiable, mois après mois. + cta: Fiabiliser mon prévisionnel +bowtie: + activeStages: + - discovery + - negotiation + - closing + - expansion + caption: "Du premier rendez-vous à la signature, puis l'expansion de vos comptes : partout où se construit votre prévisionnel." +symptoms: + title: + - Le chiffre existe dans la tête de quelques personnes. + - Nulle part ailleurs. + items: + - Votre prévisionnel se refait à la main chaque fin de mois, dans un tableur. + - Les étapes du pipe veulent dire une chose différente pour chaque commercial. + - Les gros deals glissent d'un trimestre à l'autre sans que personne ne l'ait vu venir. + - Le CRM et la finance donnent deux chiffres différents pour le même mois. + - Quand un commercial part, la visibilité sur ses comptes part avec lui. +audience: + - team: Direction + roles: CEO, DG + benefit: Un chiffre sur lequel s'engager devant le board, sans surprise en fin de trimestre. + - team: Finance + roles: CFO, DAF + benefit: Un prévisionnel commercial aligné sur le budget et la trésorerie. + - team: Ventes + roles: Directeur commercial, CRO + benefit: Un pipe lisible, des écarts repérés tôt, des décisions plus rapides. + - team: Ventes + roles: Managers commerciaux + benefit: Des revues de pipe utiles, centrées sur les deals qui comptent. +workstreams: + intro: Quatre chantiers, priorisés selon votre point de départ. + items: + - category: Processus et techno + title: Un pipe qui veut dire la même chose pour tous + description: Étapes de vente définies par des critères de sortie, qualification commune, règles de probabilité. + deliverable: Processus de vente documenté et paramétré dans votre CRM. + - category: Données + title: Des données sur lesquelles on peut compter + description: Champs obligatoires au bon moment, nettoyage, réconciliation entre CRM et finance. + deliverable: Un modèle de données unique et ses règles de qualité. + - category: KPIs et pilotage + title: Un prévisionnel qui se construit seul + description: Méthode de prévision par catégories, engagement des commerciaux, écart prévu contre réalisé. + deliverable: Prévisionnel automatisé, consolidé chaque semaine. + - category: Organisation + title: Un rituel de pilotage qui tient + description: Revue de pipe hebdomadaire, tableau de bord dirigeant, reporting board ou fonds. + deliverable: Tableaux de bord et rituels animés par vos managers. +aiBridge: + title: Une fois le système posé, l'IA le fait tourner plus vite. + bullets: + - Les comptes rendus d'appel mettent à jour le CRM sans saisie. + - Les deals à risque sont signalés avant la revue de pipe. + - Le dirigeant pose sa question en français et obtient le chiffre. +method: + subtitle: Du diagnostic à un chiffre que vos équipes tiennent seules. + steps: + - text: Le Design Sprint mesure l'écart entre votre prévisionnel et votre réalisé, et identifie d'où il vient. + - text: La squad livre les chantiers prioritaires dans votre CRM, avec vos managers commerciaux et votre direction financière. + - text: Vos équipes animent les rituels. Nous restons jusqu'à ce que l'écart prévu contre réalisé soit tenu. + tools: + - hubspot + - salesforce + - modjo + - notion + note: "Et ceux que vous avez déjà : nous partons de votre stack." +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour le dirigeant.]" + metrics: + - value: "[X]" + label: "[Écart prévu contre réalisé, avant et après]" + - value: "[X]" + label: "[Temps gagné sur le reporting]" +diagnostic: + question: Quel est l'écart entre votre prévisionnel et votre réalisé ? + subtitle: Trente minutes avec un architecte du revenu pour le mesurer, et savoir par où commencer. +--- diff --git a/scripts/__tests__/fixtures/challenges/revenue-systems.md b/scripts/__tests__/fixtures/challenges/revenue-systems.md new file mode 100644 index 0000000..91caa8b --- /dev/null +++ b/scripts/__tests__/fixtures/challenges/revenue-systems.md @@ -0,0 +1,108 @@ +--- +key: sy +order: 5 +name: Revenue Systems +tagline: Des outils que vos équipes utilisent vraiment +hero: + title: + - Des outils que vos équipes + - utilisent vraiment. + quote: On paie un CRM que personne ne remplit. + lead: "Votre stack s'est empilée au fil des besoins, et personne n'en a la vue d'ensemble. Nous dessinons l'architecture d'abord, puis nous implémentons, connectons et faisons adopter" + highlight: des outils au service de votre organisation Revenue. + cta: Remettre ma stack au service de la vente +bowtie: + activeStages: + - demand-generation + - discovery + - negotiation + - closing + - onboarding + - adoption + - expansion + caption: "Le socle de tout le cycle : les outils et la donnée sur lesquels chaque étape repose." +symptoms: + title: + - Beaucoup d'outils. + - Aucun système. + items: + - Le CRM est rempli par obligation, et le vrai suivi vit dans des tableurs. + - Le même client existe en trois versions entre le CRM, la facturation et l'ERP. + - Chaque équipe a ajouté ses outils, sans plan d'ensemble. + - Vous repoussez une migration de CRM depuis des mois, par peur de tout casser. + - Personne n'administre vraiment les outils, et chaque évolution passe par un prestataire. +audience: + - team: Direction + roles: CRO, COO + benefit: Une stack qui sert la stratégie, et un budget outils qui se justifie. + - team: Ops + roles: RevOps, Sales Ops + benefit: Un modèle de données propre, une gouvernance, moins de bricolage. + - team: IT + roles: DSI + benefit: Des intégrations maîtrisées, sécurisées et documentées. + - team: Terrain + roles: Sales, CS, Marketing + benefit: Des outils qu'on a envie d'utiliser, et moins de saisie. +workstreams: + title: L'architecture d'abord, les outils ensuite + intro: "Notre règle : aucun outil avant d'avoir dessiné le système." + items: + - category: Architecture + step: 1 + title: Une stack pensée pour votre organisation + description: Audit de l'existant, architecture cible, choix des outils sans parti pris d'éditeur, feuille de route. + deliverable: Schéma d'architecture cible et plan de déploiement. + - category: Implémentation + step: 2 + title: Un CRM implémenté ou migré sans casse + description: Paramétrage HubSpot ou Salesforce, migration depuis l'ancien outil ou les tableurs, reprise et nettoyage des données. + deliverable: Un CRM en production, données reprises et vérifiées. + - category: Intégration et données + step: 3 + title: Une donnée de référence, partout + description: CRM relié à la facturation, à l'ERP, au marketing et au support. Règles de synchronisation, modèle de données unique. + deliverable: Intégrations en production et modèle de données documenté. + - category: Adoption + step: 4 + title: Des outils simples à utiliser + description: Vues par métier, champs utiles au bon moment, automatisation des saisies, formation des équipes. + deliverable: Un CRM utilisé, mesuré par des indicateurs d'adoption. + - category: Gouvernance + step: 5 + title: Une stack qui évolue avec vous + description: Rôles d'administration, règles de qualité, processus de demande d'évolution, accompagnement en continu si besoin. + deliverable: Un cadre de gouvernance et une équipe interne capable de le tenir. +aiBridge: + title: Une stack propre est une stack prête pour l'IA. + bullets: + - Les saisies se remplissent à partir des mails, des appels et des documents. + - Vos assistants IA se connectent au CRM dans un cadre de sécurité maîtrisé. + - Les règles de qualité de données sont surveillées en continu. +method: + subtitle: De l'audit de la stack à une équipe qui l'administre seule. + steps: + - text: Le Design Sprint cartographie vos outils, vos flux de données et leurs usages réels, et dessine l'architecture cible. + - text: La squad implémente, migre et connecte, avec vos équipes métier et votre DSI. + - text: Vos administrateurs internes tiennent la stack. Nous restons en appui si vous le souhaitez. + tools: + - hubspot + - salesforce + - chargebee + - zendesk + - notion + - oneflow + - hyperline + note: "Et vos ERP, outils de facturation et de marketing. [Badges partenaires officiels à intégrer : HubSpot, Notion]" +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour les équipes.]" + metrics: + - value: "[X]" + label: "[Taux d'adoption du CRM, avant et après]" + - value: "[X]" + label: "[Outils supprimés ou temps de saisie économisé]" +diagnostic: + question: Combien payez-vous pour des outils que vos équipes n'utilisent pas ? + subtitle: Trente minutes avec un architecte du revenu pour le savoir, et décider quoi garder, connecter ou arrêter. +--- diff --git a/scripts/__tests__/fixtures/challenges/sales-compensation.md b/scripts/__tests__/fixtures/challenges/sales-compensation.md new file mode 100644 index 0000000..f7b2f43 --- /dev/null +++ b/scripts/__tests__/fixtures/challenges/sales-compensation.md @@ -0,0 +1,99 @@ +--- +key: sc +order: 4 +name: Sales Compensation +tagline: Une rémunération variable qui tire dans le bon sens +hero: + title: + - Une rémunération variable + - qui tire dans le bon sens. + quote: Mes commerciaux optimisent leur commission, pas mon chiffre. + lead: "Votre plan de commissionnement est le document que vos commerciaux lisent le plus attentivement. S'il contredit votre stratégie, c'est lui qui l'emporte. Nous concevons, modélisons, outillons et pilotons" + highlight: une rémunération variable alignée sur ce que vous voulez vraiment vendre. + cta: Revoir mon plan de rémunération variable +bowtie: + activeStages: + - negotiation + - closing + - expansion + caption: "Là où le plan oriente les comportements : le choix des deals, la signature, le développement des comptes." +symptoms: + title: + - Un plan qui coûte cher et se calcule à la main. + - Et qui récompense les mauvais deals. + items: + - Les commissions se calculent dans un tableur, chaque mois, avec des erreurs et des contestations. + - Le plan récompense le volume signé, quand vous avez besoin de marge, de récurrence ou de nouveaux clients. + - Vos commerciaux ne savent pas ce qu'ils vont toucher avant la fin du mois. + - Le plan change chaque année sans que personne n'ait mesuré l'effet du précédent. + - Le coût du variable progresse plus vite que votre chiffre, sans que vous sachiez pourquoi. +audience: + - team: Ventes + roles: Directeur commercial, CRO + benefit: Des plans qui orientent les efforts vers les deals que veut l'entreprise. + - team: Finance + roles: CFO, DAF + benefit: Un coût du variable modélisé et maîtrisé, sans calcul manuel. + - team: RH + roles: DRH, People + benefit: Des plans lisibles, équitables et faciles à expliquer à l'embauche. + - team: Terrain + roles: Commerciaux et CSM + benefit: Savoir ce qu'on gagne, et pourquoi, sans attendre la fin du mois. +workstreams: + intro: De la stratégie au bulletin de paie, sans ressaisie. + items: + - category: Alignement + step: 1 + title: Un plan qui découle de votre stratégie + description: Objectifs par rôle, part fixe et variable, accélérateurs, traitement de la marge, des nouveaux clients et des renouvellements. + deliverable: Plans de rémunération variable par rôle, validés par la direction. + - category: KPIs et pilotage + step: 2 + title: Un coût modélisé avant d'être payé + description: Simulation du plan sur votre historique de ventes, scénarios de dépassement, coût de vente rapporté au chiffre. + deliverable: Un modèle financier du plan, partagé avec votre direction financière. + - category: Processus et techno + step: 3 + title: Des commissions calculées automatiquement + description: Données de vente prises dans le CRM, règles paramétrées dans un outil de commissionnement, export vers la paie. + deliverable: Un calcul automatisé, du deal signé à la paie. + - category: Enablement + step: 4 + title: Un plan que chaque commercial comprend + description: Présentation du plan aux équipes, simulateur individuel, suivi de ses gains en temps réel. + deliverable: Supports de lancement et visibilité en continu pour chaque commercial. + - category: Gouvernance + step: 5 + title: Un plan qui s'ajuste sur des faits + description: Mesure de l'effet du plan sur les comportements, traitement des cas particuliers, revue annuelle avec vos RH. + deliverable: Un rituel de revue et des règles de gestion des exceptions. +aiBridge: + title: Chaque commercial sait ce que lui rapporte le deal sur lequel il travaille. + bullets: + - Le commercial pose sa question en français et obtient le détail de sa commission. + - Les scénarios de plan se simulent en quelques minutes, sur vos vraies données. + - Les anomalies de calcul sont repérées avant la paie. +method: + subtitle: De l'analyse du plan actuel à un variable qui se pilote seul. + steps: + - text: Le Design Sprint analyse votre plan actuel, son coût et les comportements qu'il produit vraiment. + - text: La squad conçoit et outille le plan avec votre direction commerciale, votre direction financière et vos RH. + - text: Le calcul tourne seul, et vos équipes ajustent le plan chaque année sur des faits. + tools: + - qobra + - hubspot + - salesforce + note: Et votre outil de paie. Nous partons de votre stack. +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour la direction et les équipes.]" + metrics: + - value: "[X]" + label: "[Temps de calcul mensuel des commissions, avant et après]" + - value: "[X]" + label: "[Évolution du comportement visé : marge, récurrence ou nouveaux clients]" +diagnostic: + question: Votre plan récompense-t-il ce que vous voulez vraiment vendre ? + subtitle: Trente minutes avec un architecte du revenu pour le vérifier, et savoir par où commencer. +--- diff --git a/scripts/__tests__/fixtures/challenges/sales-experience.md b/scripts/__tests__/fixtures/challenges/sales-experience.md new file mode 100644 index 0000000..c5b8726 --- /dev/null +++ b/scripts/__tests__/fixtures/challenges/sales-experience.md @@ -0,0 +1,107 @@ +--- +key: se +order: 1 +name: Sales Experience +tagline: Une organisation commerciale qui passe à l'échelle +hero: + title: + - Une organisation commerciale + - qui passe à l'échelle. + quote: Deux commerciaux font l'essentiel du chiffre. + lead: "Quand la performance tient à quelques talents, ajouter des commerciaux ne suffit pas. Nous construisons l'organisation, les processus, les outils et les indicateurs qui font tourner" + highlight: tout le cycle, du premier lead à l'argent encaissé. + cta: Structurer mon organisation commerciale +bowtie: + activeStages: + - demand-generation + - discovery + - negotiation + - closing + - onboarding + caption: "Du ciblage jusqu'à l'encaissement : tout le cycle de vente, outillé de bout en bout." +symptoms: + title: + - Vous recrutez des commerciaux. + - Le chiffre ne suit pas. + items: + - Vos commerciaux trouvent que les leads marketing ne valent pas la peine d'être rappelés. + - Les leads arrivent sans règle claire sur qui les traite, ni en combien de temps. + - Vos commerciaux prospectent, closent et gèrent leurs comptes à la fois. + - Vous ne savez pas à quelle étape du cycle vous perdez vos deals. + - Les devis se font à la main, et la facturation ressaisit tout ce que la vente a signé. + - Un nouveau commercial met des mois à produire, parce que rien n'est outillé pour lui. +audience: + - team: Ventes + roles: Directeur commercial, CRO + benefit: Une organisation qui tient la croissance sans dépendre de deux commerciaux. + - team: Ventes + roles: Managers commerciaux + benefit: Des rôles clairs, des process partagés, des équipes qui montent en compétence. + - team: Marketing + roles: Head of Marketing + benefit: Des leads mieux ciblés, assignés au bon commercial, et suivis. + - team: Ops + roles: Sales Ops, RevOps + benefit: Un quote-to-cash fluide et des KPIs que tout le monde comprend. +workstreams: + intro: Six chantiers, priorisés selon votre point de départ. Chacun livre un système qui reste chez vous. + items: + - category: Leads + title: Des leads mieux ciblés, qualifiés et assignés + description: Cible définie avec la vente, scoring, enrichissement, règles de routage et délais de prise en charge, boucle de retour entre marketing et vente. + deliverable: Un circuit des leads paramétré, du formulaire au commercial. + - category: Organisation + title: Les bons rôles, au bon moment du cycle + description: Segmentation des comptes, spécialisation prospection, vente et gestion de comptes, passations entre rôles, dimensionnement des équipes. + deliverable: Modèle d'organisation commerciale et plan de capacité. + - category: Processus et techno + title: Un cycle de vente outillé de bout en bout + description: Étapes et critères de sortie, séquences, tâches et automatisations dans le CRM. + deliverable: Processus de vente paramétré dans vos outils. + - category: Quote-to-cash + title: Du devis signé à l'argent encaissé, sans ressaisie + description: Catalogue et règles de prix, devis générés depuis le CRM, validations, signature, passage automatique à la facturation et suivi des encaissements. + deliverable: Un flux continu du deal gagné à la facture, relié à votre finance. + - category: KPIs et pilotage + title: Des managers qui pilotent sur des faits + description: Conversion par étape, durée du cycle, productivité et montée en charge par commercial, rituels de revue d'activité. + deliverable: Tableaux de bord managers et rituels de pilotage. + - category: Enablement + title: La méthode intégrée aux outils + description: Playbook accessible dans le CRM, contenus de vente au bon moment du cycle, parcours d'intégration outillé. + deliverable: Playbook et parcours d'intégration, réutilisables à chaque recrutement. +aiBridge: + title: Ce qui se dit en rendez-vous devient une donnée de pilotage. + bullets: + - "Les appels alimentent le CRM : qualification, objections, prochaines étapes." + - Les écarts au processus remontent dans les tableaux de bord des managers. + - Le playbook arrive dans l'outil du commercial, au moment où il en a besoin. +method: + subtitle: De l'analyse du funnel à une organisation qui tient sans nous. + steps: + - text: Le Design Sprint analyse votre funnel de bout en bout, votre organisation et vos outils, et identifie où la performance se perd. + - text: La squad construit l'organisation, les processus et les indicateurs dans vos outils, avec votre direction commerciale. + - text: Vos managers pilotent sur les indicateurs, et chaque recrutement s'intègre dans un système déjà en place. + tools: + - hubspot + - salesforce + - lemlist + - chargebee + - full-enrich + - aircall + - modjo + - oneflow + - hyperline + note: "Du ciblage à l'encaissement. Et ceux que vous avez déjà : nous partons de votre stack." +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour la direction commerciale.]" + metrics: + - value: "[X]" + label: "[Conversion lead vers opportunité, avant et après]" + - value: "[X]" + label: "[Délai entre signature et facturation]" +diagnostic: + question: À quelle étape de votre cycle perdez-vous vos deals ? + subtitle: Trente minutes avec un architecte du revenu pour le mesurer, et savoir par où commencer. +--- diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 02d8acf..488eb45 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { validateRepository } from '../validate-repository.js'; @@ -98,3 +99,41 @@ describe('validateRepository', () => { expect(assetWarnings).toEqual([]); }); }); + +describe('validateRepository with challenge files', () => { + const CHALLENGE = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'fixtures/challenges/revenue-performance.md'), + 'utf8', + ); + + it('accepts a French challenge file', () => { + ['salesforce', 'modjo', 'notion'].forEach((tool) => write(`tools/${tool}.md`, TOOL)); + write('challenges/fr/revenue-performance.md', CHALLENGE); + + expect(validateRepository(repoRoot).failures).toEqual([]); + }); + + it('rejects a challenge file with a markdown body', () => { + write('challenges/fr/revenue-performance.md', `${CHALLENGE}\nUn paragraphe.\n`); + + expect(validateRepository(repoRoot).failures).toEqual([ + { + file: 'challenges/fr/revenue-performance.md', + schemaErrors: [{ path: [], message: expect.stringContaining('body must be empty') }], + crossRefErrors: [], + }, + ]); + }); + + it('reports a cap error with the field, its length, the cap and the overage', () => { + write('challenges/fr/revenue-performance.md', CHALLENGE.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); + + expect(validateRepository(repoRoot).failures).toEqual([ + { + file: 'challenges/fr/revenue-performance.md', + schemaErrors: [{ path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }], + crossRefErrors: [], + }, + ]); + }); +}); diff --git a/scripts/schemas/challenge.schema.js b/scripts/schemas/challenge.schema.js new file mode 100644 index 0000000..e8340ba --- /dev/null +++ b/scripts/schemas/challenge.schema.js @@ -0,0 +1,120 @@ +import { z } from 'zod'; +import { STAGE_IDS } from './stage-ids.js'; + +// Caps are character counts measured on the French mockup text (spec #91): +// the longest value in the six mockups plus about 20%. Fixed-height flip +// cards and windows break when a text runs over, so the error states the +// overage the author has to cut. +const cappedText = (cap) => + z + .string() + .trim() + .min(1, { error: 'must not be empty' }) + .max(cap, { + error: (iss) => { + const length = iss.input.length; + return `${length} characters, cap ${cap} (${length - cap} over)`; + }, + }); + +// Slugs carry no cap: they never render as text. +const slug = z.string().trim().min(1, { error: 'must not be empty' }); + +export const CHALLENGE_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; + +const activeStages = z + .array(z.enum(STAGE_IDS)) + .min(1) + .max(STAGE_IDS.length) + .refine((stages) => new Set(stages).size === stages.length, { + message: 'Stage ids must be unique', + }); + +const workstream = z.strictObject({ + category: cappedText(26), + step: z.number().int().min(1).max(6).optional(), + title: cappedText(64), + description: cappedText(210), + deliverable: cappedText(170), + note: cappedText(100).optional(), +}); + +export const challengeSchema = z.strictObject({ + key: z.enum(CHALLENGE_KEYS), + order: z.number().int().min(1).max(6), + name: cappedText(30), + tagline: cappedText(60), + hero: z.strictObject({ + title: z.tuple([cappedText(34), cappedText(42)]), + quote: cappedText(80), + lead: cappedText(240), + highlight: cappedText(110), + cta: cappedText(48), + }), + bowtie: z.strictObject({ + activeStages, + caption: cappedText(140), + }), + symptoms: z.strictObject({ + title: z.tuple([cappedText(64), cappedText(44)]), + items: z.array(cappedText(130)).min(5).max(6), + }), + audience: z + .array( + z.strictObject({ + team: cappedText(20), + roles: cappedText(30), + benefit: cappedText(100), + }), + ) + .length(4), + workstreams: z.strictObject({ + title: cappedText(50).optional(), + intro: cappedText(120), + items: z.array(workstream).min(4).max(6), + }), + aiBridge: z.strictObject({ + title: cappedText(92), + bullets: z.array(cappedText(125)).length(3), + }), + method: z.strictObject({ + title: cappedText(30).optional(), + subtitle: cappedText(75), + steps: z + .array( + z.strictObject({ + title: cappedText(28).optional(), + text: cappedText(150), + }), + ) + .length(3), + // Tool slugs. Their cross-reference check comes in a later ticket. + // Spec #91 says 0 to 6. Two mockups exceed it, logos included (Sales + // Experience 9, Revenue Systems 7), so the cap is the largest mockup count. + tools: z.array(slug).max(9), + note: cappedText(170), + }), + case: z.strictObject({ + // Optional until Ocobo confirms a Story for the Revenue AI client case + // (assumption A5 in spec #91). + story: slug.optional(), + // Spec #91 says 90. The Revenue AI headline is 98 characters, so the cap + // follows the spec's own rule: longest mockup value plus about 20%. + headline: cappedText(120), + summary: cappedText(220), + metrics: z + .array( + z.strictObject({ + value: cappedText(12), + label: cappedText(90), + }), + ) + .length(2), + }), + diagnostic: z.strictObject({ + question: cappedText(88), + subtitle: cappedText(125), + }), +}); + +export const validateChallenge = (data) => challengeSchema.safeParse(data); diff --git a/scripts/schemas/stage-ids.js b/scripts/schemas/stage-ids.js new file mode 100644 index 0000000..4865769 --- /dev/null +++ b/scripts/schemas/stage-ids.js @@ -0,0 +1,13 @@ +// The seven Stages of the bow tie, in bow tie order. This is a copy of the +// website's list (`app/modules/bowtie-stages.ts` in ocobo-revops/website). +// A change here needs a PR in both repos: an id the website does not know is +// silently dropped from the bow tie. +export const STAGE_IDS = Object.freeze([ + 'demand-generation', + 'discovery', + 'negotiation', + 'closing', + 'onboarding', + 'adoption', + 'expansion', +]); diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js index 046c4b3..3c70353 100644 --- a/scripts/validate-repository.js +++ b/scripts/validate-repository.js @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; import { validateBlogPost } from './schemas/blog-post.schema.js'; +import { validateChallenge } from './schemas/challenge.schema.js'; import { validateJob } from './schemas/job.schema.js'; import { validateStory } from './schemas/story.schema.js'; import { validateTeamMember } from './schemas/team-member.schema.js'; @@ -16,6 +17,12 @@ const VALIDATORS = { 'blog-post': { patterns: ['blog/fr/*.md', 'blog/en/*.md'], validate: validateBlogPost }, story: { patterns: ['stories/fr/*.md', 'stories/en/*.md'], validate: validateStory }, job: { patterns: ['jobs/fr/*.md', 'jobs/en/*.md'], validate: validateJob }, + // Every challenge section is a frontmatter field, so a body would never render. + challenge: { + patterns: ['challenges/fr/*.md', 'challenges/en/*.md'], + validate: validateChallenge, + frontmatterOnly: true, + }, }; // Cross-reference errors share the schema error shape ({ path, message }) so @@ -67,12 +74,12 @@ export const validateRepository = (repoRoot) => { const failures = []; const storySlugs = new Set(); - for (const [type, { patterns, validate }] of Object.entries(VALIDATORS)) { + for (const [type, { patterns, validate, frontmatterOnly }] of Object.entries(VALIDATORS)) { for (const pattern of patterns) { const files = glob.sync(pattern, { cwd: repoRoot }); for (const file of files) { const raw = readFileSync(join(repoRoot, file), 'utf8'); - const { data } = matter(raw); + const { data, content } = matter(raw); const slug = file.replace(/\.md$/, '').split('/').pop(); if (type === 'story') storySlugs.add(slug); @@ -80,6 +87,9 @@ export const validateRepository = (repoRoot) => { const schemaErrors = result.success ? [] : result.error.issues.map((i) => ({ path: i.path, message: i.message, })); + if (frontmatterOnly && content.trim() !== '') { + schemaErrors.push({ path: [], message: 'markdown body must be empty, content lives in frontmatter' }); + } const crossRefErrors = validateCrossRefs(type, data, resolver); if (schemaErrors.length > 0 || crossRefErrors.length > 0) {