09 — Langues et JSON : un récit, plusieurs représentations, plusieurs langues
Statut : fait · Phase : P3 · Mis à jour : 24/10/2026.
Deux idées, liées :
- Tout
.mda un équivalent JSON typé. Un récit peut s'écrire en Markdown (pour les humains), en JSON (pour les machines, les CMS, les agents, les données), ou dans les deux : le JSON complète alors le Markdown du même nom. - Une même page existe en plusieurs langues :
example.en.mdetexample.fr.md, ou un seul JSON qui contient toutes les langues. Les deux mécanismes se combinent.
Ce document s'applique aux neuf formats (deck, motion, docs, report, toon, book, roadmap,
stories, calendar), à ceux des plans 10 à 13 ajoutés dans leur phase (glossary, directory, atlas,
règle B1), et aux sites *.recit.dev.
1. Les fichiers d'un récit
Pour un récit de nom de base example :
| Fichier | Rôle | Obligatoire |
|---|---|---|
example.md / example.mdx | source Markdown, dans la langue par défaut | un des deux : .md ou .json complet |
example.json | jumeau du .md (kind: "story", jamais fusionné), ou compagnon qui le complète par id (kind: "sidecar"). Règle unique : AGENTS.md | non |
example.fr.md, example.de.md… | la même page dans une autre langue (même structure, mêmes id de scène) | non |
example.fr.json | compagnon propre à une langue : textes traduits, données localisées | non |
Règles de nommage :
- Le suffixe de langue est le dernier segment avant l'extension, au format BCP 47 :
fr,en,pt-BR,zh-Hans. Il n'est reconnu qu'en mode multilingue (sectioni18nprésente), et seulement s'il figure dansi18n.locales. Ainsihello.story.mdreste un nom de basehello.story, sans ambiguïté. - Un fichier sans suffixe est dans la langue par défaut (
i18n.defaultLocale, sinonlangdu frontmatter).example.mdetexample.en.mdne peuvent pas coexister sienest la langue par défaut (erreur). - Les assets (images, données CSV, cases de webtoon) sont partagés entre les langues. Une
variante localisée suit la même règle :
cover.fr.pngremplacecover.pngen français. - Un seul nom de base = un seul slug :
example.fr.mdest publié en/fr/<route>/example, et non en/example.fr.
Exemples :
Code
2. Le JSON typé
2.1 Un schéma par format, une seule source
- Les schémas sont écrits en zod 4, dans
@recit/core/schema:StorySourceSchema, union discriminée surformat, avecDeckSource,MotionSource,ReportSource,ToonSource,BookSource,RoadmapSource,StoriesSource,CalendarSourceetDocsSource. - Les types TypeScript sont inférés (
z.infer), pour ne jamais écrire deux fois la même structure. - Les JSON Schema sont générés au build (
z.toJSONSchema) et publiés parsites/www:https://recit.dev/schema/v1/story.json(union) ;https://recit.dev/schema/v1/<format>.json;https://recit.dev/schema/v1/sidecar.json.
- Chaque fichier JSON déclare son schéma, ce qui donne l'autocomplétion et la validation dans VS Code et les éditeurs :
JSON
versionest obligatoire. Toute rupture du schéma donne unev2, avec une migrationmigrateStorySource(v1) → v2etrecit migrate.- Versionnement (règle B1,
06-roadmap.md) : un nouveau format ou un nouveaukindest un ajout compatible env1. L'union reste ouverte en lecture : un lecteurv1qui rencontre un format ou unkindinconnu renvoie une erreur claire (« installez @recit/… »), pas une erreur de schéma. Seule une rupture donne unev2. - Formats ajoutés en
v1, chacun dans sa phase :glossaryetdirectory(plan 12, P14, lot en P9),atlas(plan 13, P15), etformseulement si FO5 a lieu (plan 10). Chacun ajoute sa variante àStorySourceSchemaet àStoryFormat. settings.pdfest typé par format dans sa phase (deck PDF0, report PDF1, toon PDF2, book PDF3,15-export-pdf.md§6). Les réglages sont à plat, sans clébook:nireport:dans le frontmatter (08-formats.md).
2.2 Forme commune
TypeScript
kindest le discriminant des documents JSON, et seulement lui :"story","sidecar", puis"bible"et"asset"(P16,14-ia-scenario.md§8), ajoutés sous la règle B1. Aucun réglage ni attribut ne s'appellekind: l'annuaire utiliseentries(12-glossaire-annuaire.md), la cartemode(13-cartes.md).settings= la section du format dansrecit.config.ts, pour tous les formats. La section du site qui publie les récits s'appelle doncpublish: { dir, routePrefix, decks }(ex-stories:, tranché le 24/10,02-architecture.md§5) :stories:porte les réglages du format stories.- Blocs transverses (tranché le 24/10, règle B2) : en
v1,:::form,```pixel,:::textcard,::map,::chart,::tableet:::kpirestent des directives dans lebodyde la scène.SceneSourcene gagne pas de champblocks; les schémas des blocs valident les directives.
2.3 Champs typés par format
| Format | Une scène JSON contient | Exemple de champ localisable |
|---|---|---|
deck | layout, cols, image, steps | body, notes, image.alt |
motion | type (bloc), props du bloc emaki, hold, dur, voiceover | props.text, voiceover |
docs | title, sidebar | body, title |
report | aucun champ propre : ::chart, ::table et :::kpi sont des directives du body (B2) | body |
toon | src, alt, height, gutter, background, bubbles[] {x, y, tail, shape, text}, captions[], sfx[] | bubbles[].text, captions[], alt |
book | section assemblée d'un chapitre de settings.chapters : body, epigraph, figures[] | body, figures[].caption |
roadmap | lane, items[] {id, title, status, start, end, quarter, owner, link, tags} | items[].title, items[].description |
stories | background, duration, cta {label, href}, overlay | body, cta.label |
calendar | events[] {id, title, start, end, allDay, rrule, timezone, location, url, tags} | events[].title, events[].location |
- book (P8) : les chapitres sont déclarés dans
settings.chapters(avecfrontetback,15-export-pdf.md§3.3,08-formats.md§3) ; les scènes sont les sections assemblées des chapitres. En cas de doute,settings.chaptersfait foi. - report (P6) : pas de champs typés
charts[],tables[]nikpis[]; les blocs restent des directives dubody(règle B2). - toon (P7) :
altest localisable et obligatoire pour le PDF balisé (15-export-pdf.mdPDF2.1) ; le fond s'appellebackground, jamaisbg(règle B4). - À ajouter dans la phase de chaque plan : lignes
glossaryetdirectory(P14, plan 12 §8, avec l'exception de repli des termes),atlas(P15, plan 13),Placepartagé (P10, règle B3), et la bible avec la règlecast.fr.md(P16, plan 14 §8).
Les champs non textuels (dates, nombres, statuts, coordonnées, URLs) ne sont jamais
localisables : un statut ou une date est le même dans toutes les langues. Le formatage se fait à
l'affichage avec Intl.
2.4 Équivalence Markdown ⇄ JSON
compileStoryaccepte les deux entrées. Le Markdown est d'abord converti enStorySource(markdownToSource), puis tout suit le même chemin :StorySource→Story(compilé) → format.recit convert example.md --to jsonproduit le JSON canonique (kind: "story").--to mdfait l'inverse. Le Markdown émis utilise la syntaxe canonique (02-architecture.md§3).- Propriété testée pour chaque exemple et chaque fixture :
compile(md) == compile(toJson(md))etcompile(json) == compile(toMd(json)). - motion : l'ancien
*.deck.jsond'emaki reste accepté en entrée (format machine, passe par le handover etparseDeck).MotionSourceest son équivalent au niveau source, etrecit convert hello.deck.json --to mdpermet la migration (05-video.mdV5).
3. Fusion et résolution
Pour afficher example dans la langue L :
Code
- Chaîne de repli :
fr-CA→fr→defaultLocale. Elle est configurable (i18n.fallback: { "pt-BR": ["pt", "es"] }). - Fusion par
idde scène. Le compagnon peut compléter (attrs, champs typés,data) ou remplacer (body). Uniddu compagnon absent de la source produit une erreur (example.fr.json: scene "pricing" does not exist in example.fr.md). - Parité entre langues :
recit i18n checkcompare les variantes d'une page.- Erreur : scènes manquantes ou en trop, ordre différent, attributs structurels différents
(
layout,type, dates). - Avertissement : texte non traduit, identique à la langue par défaut.
- Erreur : scènes manquantes ou en trop, ordre différent, attributs structurels différents
(
:t[key]dans le Markdown insèrestrings.keydu compagnon, dans la langue courante. C'est utile pour des libellés répétés (CTA, mentions légales) sans dupliquer le fichier.
Mode strict
i18n.strict: true (recommandé en CI) transforme les avertissements de traduction en erreurs. En
dev, une bannière « non traduit » s'affiche sur les scènes concernées (HUD du deck, overlay sur les
autres formats).
4. Configuration : une fonctionnalité optionnelle
Le multilingue est optionnel. Il s'active uniquement en ajoutant une section i18n au
recit.config.ts, le fichier de configuration unique qui remplace recit.config.ts (voir
02-architecture.md §5).
Sans i18n (par défaut) : mode monolingue
- Aucun suffixe de langue n'est interprété :
example.fr.mdest un récit comme un autre, de nom de baseexample.fr. - Aucune route préfixée, aucun sélecteur de langue, aucun
hreflang, aucune vérification de parité. - La langue de chaque page vient du frontmatter
lang:, et à défaut c'esten. Elle sert seulement à<html lang>, à l'interface (packsUI_STRINGS), au sens d'écriture, aux formats de date et de nombre et à la césure. - Les valeurs
Localizedd'un JSON restent permises : on prend celle delang, sinon la première.
Un projet qui n'a qu'une langue n'a donc rien à configurer ni à apprendre.
Avec i18n : mode multilingue
TypeScript
- Toutes les langues du monde sont acceptées : n'importe quelle étiquette BCP 47 valide (langue,
écriture, région), vérifiée et normalisée avec
Intl.getCanonicalLocales. Il n'y a pas de liste fermée dans Récit. Ajouter une langue, c'est ajouter une entrée àlocales. - Seul
localesest obligatoire dans la section.defaultLocalevaut par défaut la première langue de la liste. - Interface (boutons, lecteurs, console présentateur) :
- Récit livre les packs
enetfr. Ce sont les deux langues deUI_STRINGSaujourd'hui ; - pour une autre langue, l'interface se replie sur
en, avec un avertissement en dev ; - on peut fournir un pack dans la config (
i18n.ui: { ja: "./i18n/ja.json" }). Les packs de la communauté sont publiés dans@recit/core/i18n/packsau fil des contributions ; - les dates, nombres, pluriels, noms de langues et de mois viennent d'
Intl, et fonctionnent donc pour toutes les langues sans pack.
- Récit livre les packs
- Le frontmatter
lang:d'un fichier sans suffixe reste accepté. S'il contredit le suffixe, c'est une erreur. - Passer d'un projet monolingue à un projet multilingue ne casse rien. Les fichiers existants
deviennent ceux de la langue par défaut, et on ajoute des
*.fr.mdau fil de l'eau.
5. Ce que la langue change dans chaque format
| Domaine | Règle |
|---|---|
| Interface (boutons, console présentateur, lecteurs, HUD) | UI_STRINGS existe déjà en fr/en dans @presentation/components/i18n.ts. Il devient @recit/core/i18n avec des packs par langue (en, fr d'abord), et chaque format y ajoute ses clés. Si une langue manque, on se replie sur en |
| Direction d'écriture | dir est déduit de la langue (ar, he, fa → rtl) et posé sur la racine de chaque scène. Les layouts utilisent des propriétés logiques (margin-inline-start…). split, image-left et image-right s'inversent en RTL, sauf mirror=false |
| Dates et heures | Intl.DateTimeFormat (calendar, roadmap, report), fuseau timezone explicite pour les événements |
| Nombres, devises, pourcentages | Intl.NumberFormat (:value, axes des graphiques) |
| Typographie | césure (hyphens: auto + lang), espaces insécables françaises, guillemets selon la langue (« », “ ”, „ “), veuves et orphelines (book, report) |
| Polices | lint de couverture des glyphes (il existe déjà dans emaki) étendu à tous les formats : une police du thème qui ne couvre pas le japonais ou l'arabe produit une erreur, ou un repli déclaré dans le thème |
| motion | durées dépendantes de la langue : wordsPerSecond par langue (motion.wordsPerSecond: { en: 2.6, fr: 2.4, ja: 7 }), voix off (voiceover) et sous-titres par langue, un MP4 par langue (recit render --locale fr, ou --all-locales) |
| toon | lettrage généré, donc traduisible sans retoucher les images. Les bulles s'agrandissent automatiquement, avec un avertissement si le texte déborde. Sens de lecture inversé possible pour les mangas (toon.direction: rtl) |
| book | un EPUB par langue ; un PDF par langue pour tous les formats de la liste fermée de 15-export-pdf.md §1. dc:language dans l'OPF, table des matières et index traduits |
| report | libellés des graphiques et des tableaux localisés. Les données restent les mêmes |
| roadmap, calendar | titres et descriptions localisés. Statuts, dates et récurrences communs |
| stories | une story par langue, CTA localisés |
--locale et --all-locales pilotent aussi recit export --to pdf : --all-locales produit un PDF par locale ({slug}.{locale}.pdf par défaut), chacun une entrée de exports.json (15-export-pdf.md §3.8). Le lint de couverture des glyphes devient bloquant pour un PDF | |
| Accessibilité | lang sur <html> et sur chaque passage d'une autre langue (:lang[mot]{l=de}), textes alternatifs localisés obligatoires en mode strict |
6. Sites et intégration du framework de sites
le framework de sites n'a aucun support multilingue (aucune notion de locale, i18n ou
hreflang dans le code du cœur 0.93). C'est donc à Récit de le fournir, sans patcher le cœur si
possible :
@recit/site-storiesgénère les routes par langue (/fr/present/<slug>…) à partir des variantes de fichiers, et passe la langue au viewer.@recit/site-kitfournit :- le sélecteur de langue dans l'en-tête, qui garde la page courante si elle existe dans l'autre langue, et renvoie sinon vers l'accueil de cette langue ;
- les balises
<link rel="alternate" hreflang>etx-default; - la balise
<html lang dir>; - un
sitemap.xmlpar langue.
- Recherche : Pagefind indexe séparément chaque langue d'après
<html lang>. On le vérifie dans les e2e. - Pages de documentation (MDX du framework de sites) :
docs.recit.devreste en anglais (règle du projet). Si l'on veut un jour une doc traduite, il faudra un plugin de pages*.fr.mdx, ou un changement du framework de sites (packages/site). - Les exemples des sites de format sont publiés en anglais et en français dès leur sortie : c'est la démonstration de la fonctionnalité.
7. Outillage
| Commande ou outil | Rôle |
|---|---|
recit convert x.md --to json / --to md | conversion canonique |
recit validate x.json | validation zod, avec erreurs localisées (chemin JSON, et ligne quand on part du Markdown) |
recit i18n status | tableau de couverture par page et par langue (scènes traduites, chaînes manquantes) |
recit i18n extract x.md --locale de | crée x.de.json (compagnon) avec toutes les chaînes à traduire, en reprenant le texte source |
recit i18n check | parité entre langues (§3), à lancer en CI |
recit migrate | migration des JSON vers la dernière version du schéma |
MCP describe_format(format) | renvoie le JSON Schema du format et la spec Markdown |
MCP translate_story(path, locale) | propose la variante dans la langue cible (.L.md ou .L.json). L'auteur valide, et aucune traduction automatique n'a lieu au build |
| Éditeur | $schema donne l'autocomplétion. Plus tard, une extension VS Code (aperçu, sélecteur de langue) |
8. Packages concernés
| Package | Ajout |
|---|---|
@recit/core | ./schema (schémas zod, types, JSON Schema), ./i18n (résolution de langue, repli, Localized, packs d'interface), markdownToSource, sourceToMarkdown, fusion des compagnons, recit i18n check (logique) ; ExportManifestSchema (exports.json) et ExportOriginSchema, publiés en JSON Schema, sous-chemin ./print (P6) ; ./jsonld (P10) |
@recit/cli | convert, validate, migrate, i18n status | extract | check, --locale et --all-locales sur build, export et render (liste complète : 03-packaging.md §6) |
@recit/components | ./terms : plugin :term et liens automatiques (P14, plan 12 ; le cœur ne garde que le nom, règle B4) |
@recit/site-stories | routes par langue, StoryEmbed locale, hreflang |
@recit/site-kit | sélecteur de langue, sitemaps, alternates |
@recit/mcp | describe_format, translate_story |
| chaque format | ses champs typés (§2.3) et ses règles de langue (§5) |
sites/www | publie recit.dev/schema/v1/*.json |
9. Phases
Le socle (fichiers, JSON, résolution) arrive avec le modèle Story, en P3, pour que chaque
format soit multilingue dès sa sortie.
- L1 (P3) —
@recit/core/schema:StorySource,Sidecar,Localized, schémas deck et docs. JSON Schema générés.markdownToSourceetsourceToMarkdown, avec le test d'équivalence sur tous les exemples. - L2 (P3) — Résolution des fichiers (
example.L.md, compagnons, assets localisés), chaîne de repli,:t[key],i18n.*dansrecit.config.ts. - L3 (P3) —
@recit/core/i18n: migration deUI_STRINGS(fr/en),dir, propriétés CSS logiques dans@recit/theme. Restent : packs d'interface par langue, RTL complet (journal.md§2). - L4 (P3) — CLI :
convert,validate,i18n status | extract | check,--locale. - L5 (P3) —
@recit/site-storieset@recit/site-kit: routes par langue, sélecteur,hreflang, sitemaps, Pagefind par langue.examples/deck-showcaseenenetfr. Restent : sélecteur de langue plein écran, bandeau « non traduit » (journal.md§2). - L6 (P4 et suivantes) — Chaque nouveau format livre son schéma JSON, ses champs localisables et ses règles de langue (§5), avec un exemple bilingue.
- L7 (P4) — motion :
wordsPerSecondpar langue, voix off et sous-titres,render --all-locales. - L8 (P5) — Publication des JSON Schema sur recit.dev, MCP
describe_formatettranslate_story.
Critère de sortie de L1 à L5 :
examples/deck-showcaseexiste en.md, en.jsonet en.fr.md, avec une compilation identique enmdet enjson;recit i18n checkpasse ;- le site sert
/present/showcaseet/fr/present/showcaseavechreflang, le sélecteur de langue et une recherche par langue.
10. Risques
| Risque | Parade |
|---|---|
Deux sources de vérité (.md et .json) qui divergent | un kind: "sidecar" complète par id et ne redéfinit jamais la liste des scènes (un id inconnu est une erreur) ; un kind: "story" posé à côté d'un .md est un jumeau, jamais fusionné, et l'aller-retour le vérifie |
| Des variantes de langue qui dérivent (scènes ajoutées dans une seule langue) | recit i18n check en CI (parité stricte des id et des attributs structurels) |
Une ambiguïté de nom (hello.story.md) | seules les langues déclarées dans i18n.locales sont reconnues comme suffixe |
| Des schémas JSON qui cassent les utilisateurs | version obligatoire, schémas publiés par version (/schema/v1/), recit migrate |
| Le framework de sites sans i18n | tout passe par @recit/site-stories et @recit/site-kit. La doc du framework de sites reste en anglais |
| Des polices qui ne couvrent pas une langue | lint de couverture des glyphes, avec erreur en mode strict |