Formats

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 :

  1. Tout .md a 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.
  2. Une même page existe en plusieurs langues : example.en.md et example.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 :

FichierRôleObligatoire
example.md / example.mdxsource Markdown, dans la langue par défautun des deux : .md ou .json complet
example.jsonjumeau du .md (kind: "story", jamais fusionné), ou compagnon qui le complète par id (kind: "sidecar"). Règle unique : AGENTS.mdnon
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.jsoncompagnon propre à une langue : textes traduits, données localiséesnon

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 (section i18n présente), et seulement s'il figure dans i18n.locales. Ainsi hello.story.md reste un nom de base hello.story, sans ambiguïté.
  • Un fichier sans suffixe est dans la langue par défaut (i18n.defaultLocale, sinon lang du frontmatter). example.md et example.en.md ne peuvent pas coexister si en est 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.png remplace cover.png en français.
  • Un seul nom de base = un seul slug : example.fr.md est publié en /fr/<route>/example, et non en /example.fr.

Exemples :

Code
talks/ launch.md # deck, anglais (langue par défaut) launch.fr.md # même deck, français launch.json # compagnon partagé : données, props, liens launch.fr.json # textes localisés restants (alt d'images, légendes de graphiques) events/ meetups.json # calendrier complet en JSON, toutes langues dans le même fichier roadmap/ product.md # roadmap, anglais product.json # statuts, dates, propriétaires (sans traduction)

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 sur format, avec DeckSource, MotionSource, ReportSource, ToonSource, BookSource, RoadmapSource, StoriesSource, CalendarSource et DocsSource.
  • 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 par sites/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 :
JSONJSON
{ "$schema": "https://recit.dev/schema/v1/deck.json", "kind": "story", "version": 1, "format": "deck" }
  • version est obligatoire. Toute rupture du schéma donne une v2, avec une migration migrateStorySource(v1) → v2 et recit migrate.
  • Versionnement (règle B1, 06-roadmap.md) : un nouveau format ou un nouveau kind est un ajout compatible en v1. L'union reste ouverte en lecture : un lecteur v1 qui rencontre un format ou un kind inconnu renvoie une erreur claire (« installez @recit/… »), pas une erreur de schéma. Seule une rupture donne une v2.
  • Formats ajoutés en v1, chacun dans sa phase : glossary et directory (plan 12, P14, lot en P9), atlas (plan 13, P15), et form seulement si FO5 a lieu (plan 10). Chacun ajoute sa variante à StorySourceSchema et à StoryFormat.
  • settings.pdf est 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: ni report: dans le frontmatter (08-formats.md).

2.2 Forme commune

TypeScriptTypeScript
type Locale = string; // BCP 47, limité à i18n.locales type Localized<T = string> = T | Partial<Record<Locale, T>>; // valeur simple, ou une valeur par langue type StorySource = { $schema?: string; kind: "story"; version: 1; format: StoryFormat; // deck | motion | docs | report | toon | book | roadmap | stories | calendar, puis glossary, directory, atlas (B1) meta: { title: Localized; description?: Localized; author?: string; lang?: Locale }; settings?: FormatSettings; // section du format (deck, motion, report…) de recit.config.ts data?: Record<string, string | DataInline>; // sources de données (report, roadmap, calendar) scenes: SceneSource[]; }; type SceneSource = { id: string; // obligatoire en JSON (stable entre les langues) attrs?: Record<string, string | number | boolean>; // = attributs du séparateur `--- …` body?: Localized; // Markdown de la scène (prose, directives) notes?: Localized; } & FormatSceneFields; // champs typés propres au format (voir 2.3) type Sidecar = { $schema?: string; kind: "sidecar"; version: 1; meta?: Partial<StorySource["meta"]>; data?: StorySource["data"]; scenes?: Record<string /* id */, Partial<SceneSource>>; // fusion par id de scène strings?: Record<string, Localized>; // textes réutilisables : :t[key] dans le Markdown };
  • kind est 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'appelle kind : l'annuaire utilise entries (12-glossaire-annuaire.md), la carte mode (13-cartes.md).
  • settings = la section du format dans recit.config.ts, pour tous les formats. La section du site qui publie les récits s'appelle donc publish: { 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, ::table et :::kpi restent des directives dans le body de la scène. SceneSource ne gagne pas de champ blocks ; les schémas des blocs valident les directives.

2.3 Champs typés par format

FormatUne scène JSON contientExemple de champ localisable
decklayout, cols, image, stepsbody, notes, image.alt
motiontype (bloc), props du bloc emaki, hold, dur, voiceoverprops.text, voiceover
docstitle, sidebarbody, title
reportaucun champ propre : ::chart, ::table et :::kpi sont des directives du body (B2)body
toonsrc, alt, height, gutter, background, bubbles[] {x, y, tail, shape, text}, captions[], sfx[]bubbles[].text, captions[], alt
booksection assemblée d'un chapitre de settings.chapters : body, epigraph, figures[]body, figures[].caption
roadmaplane, items[] {id, title, status, start, end, quarter, owner, link, tags}items[].title, items[].description
storiesbackground, duration, cta {label, href}, overlaybody, cta.label
calendarevents[] {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 (avec front et back, 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.chapters fait foi.
  • report (P6) : pas de champs typés charts[], tables[] ni kpis[] ; les blocs restent des directives du body (règle B2).
  • toon (P7) : alt est localisable et obligatoire pour le PDF balisé (15-export-pdf.md PDF2.1) ; le fond s'appelle background, jamais bg (règle B4).
  • À ajouter dans la phase de chaque plan : lignes glossary et directory (P14, plan 12 §8, avec l'exception de repli des termes), atlas (P15, plan 13), Place partagé (P10, règle B3), et la bible avec la règle cast.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

  • compileStory accepte les deux entrées. Le Markdown est d'abord converti en StorySource (markdownToSource), puis tout suit le même chemin : StorySource → Story (compilé) → format.
  • recit convert example.md --to json produit le JSON canonique (kind: "story"). --to md fait 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)) et compile(json) == compile(toMd(json)).
  • motion : l'ancien *.deck.json d'emaki reste accepté en entrée (format machine, passe par le handover et parseDeck). MotionSource est son équivalent au niveau source, et recit convert hello.deck.json --to md permet la migration (05-video.md V5).

3. Fusion et résolution

Pour afficher example dans la langue L :

Code
1. Source principale (la première trouvée) : example.L.md → example.<repli de L>.md → example.md → example.json (kind: story) 2. Compagnons, fusionnés dans cet ordre (le dernier gagne) : example.json (kind: sidecar) → example.L.json 3. Valeurs Localized : on prend L, puis la chaîne de repli, puis defaultLocale 4. Assets : cover.L.png → cover.png
  • Chaîne de repli : fr-CA → fr → defaultLocale. Elle est configurable (i18n.fallback: { "pt-BR": ["pt", "es"] }).
  • Fusion par id de scène. Le compagnon peut compléter (attrs, champs typés, data) ou remplacer (body). Un id du 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 check compare 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.
  • :t[key] dans le Markdown insère strings.key du 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.md est un récit comme un autre, de nom de base example.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'est en. Elle sert seulement à <html lang>, à l'interface (packs UI_STRINGS), au sens d'écriture, aux formats de date et de nombre et à la césure.
  • Les valeurs Localized d'un JSON restent permises : on prend celle de lang, sinon la première.

Un projet qui n'a qu'une langue n'a donc rien à configurer ni à apprendre.

Avec i18n : mode multilingue

TypeScriptTypeScript
// recit.config.ts export default defineConfig({ i18n: { defaultLocale: "en", locales: ["en", "fr", "de", "ja", "ar", "pt-BR", "zh-Hans", "sw", "hi"], // n'importe quelle langue fallback: { "pt-BR": ["pt", "es"] }, // facultatif strict: false, // facultatif, true conseillé en CI routing: "prefix-except-default", // facultatif : /fr/…, la langue par défaut sans préfixe | "prefix-always" }, });
  • 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 locales est obligatoire dans la section. defaultLocale vaut par défaut la première langue de la liste.
  • Interface (boutons, lecteurs, console présentateur) :
    • Récit livre les packs en et fr. Ce sont les deux langues de UI_STRINGS aujourd'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/packs au 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.
  • 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.md au fil de l'eau.

5. Ce que la langue change dans chaque format

DomaineRè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'écrituredir 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 heuresIntl.DateTimeFormat (calendar, roadmap, report), fuseau timezone explicite pour les événements
Nombres, devises, pourcentagesIntl.NumberFormat (:value, axes des graphiques)
Typographiecésure (hyphens: auto + lang), espaces insécables françaises, guillemets selon la langue (« », “ ”, „ “), veuves et orphelines (book, report)
Policeslint 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
motionduré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)
toonlettrage 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)
bookun 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
reportlibellés des graphiques et des tableaux localisés. Les données restent les mêmes
roadmap, calendartitres et descriptions localisés. Statuts, dates et récurrences communs
storiesune story par langue, CTA localisés
PDF--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-stories génère les routes par langue (/fr/present/<slug>…) à partir des variantes de fichiers, et passe la langue au viewer.
  • @recit/site-kit fournit :
    • 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> et x-default ;
    • la balise <html lang dir> ;
    • un sitemap.xml par 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.dev reste 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 outilRôle
recit convert x.md --to json / --to mdconversion canonique
recit validate x.jsonvalidation zod, avec erreurs localisées (chemin JSON, et ligne quand on part du Markdown)
recit i18n statustableau de couverture par page et par langue (scènes traduites, chaînes manquantes)
recit i18n extract x.md --locale decrée x.de.json (compagnon) avec toutes les chaînes à traduire, en reprenant le texte source
recit i18n checkparité entre langues (§3), à lancer en CI
recit migratemigration 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

PackageAjout
@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/cliconvert, 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-storiesroutes par langue, StoryEmbed locale, hreflang
@recit/site-kitsélecteur de langue, sitemaps, alternates
@recit/mcpdescribe_format, translate_story
chaque formatses champs typés (§2.3) et ses règles de langue (§5)
sites/wwwpublie 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. markdownToSource et sourceToMarkdown, 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.* dans recit.config.ts.
  • L3 (P3) — @recit/core/i18n : migration de UI_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-stories et @recit/site-kit : routes par langue, sélecteur, hreflang, sitemaps, Pagefind par langue. examples/deck-showcase en en et fr. 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 : wordsPerSecond par langue, voix off et sous-titres, render --all-locales.
  • L8 (P5) — Publication des JSON Schema sur recit.dev, MCP describe_format et translate_story.

Critère de sortie de L1 à L5 :

  • examples/deck-showcase existe en .md, en .json et en .fr.md, avec une compilation identique en md et en json ;
  • recit i18n check passe ;
  • le site sert /present/showcase et /fr/present/showcase avec hreflang, le sélecteur de langue et une recherche par langue.

10. Risques

RisqueParade
Deux sources de vérité (.md et .json) qui divergentun 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 utilisateursversion obligatoire, schémas publiés par version (/schema/v1/), recit migrate
Le framework de sites sans i18ntout 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 languelint de couverture des glyphes, avec erreur en mode strict