Spunto CMS
Concepts

Corps des cartes

Le corps d'une carte est un document en blocs — validé à l'écriture, incapable de transporter du HTML, et toujours lisible aussi en texte brut.

Chaque carte a un corps, et c'est un document fait de blocs — pas une chaîne Markdown, pas du HTML.

Deux colonnes, une règle

Une carte porte son corps deux fois :

  • blocks — le document : une liste ordonnée de blocs typés ;
  • body — la projection en texte brut de ce document, sans aucun marqueur.

La règle d'écriture est : jamais les deux.

Écrire blocks pose le document et en recalcule body. Écrire body pose le texte et ramène le document à l'état dérivé de ce texte.

La lecture est plus simple : l'API renvoie toujours les deux. Une carte écrite avant l'arrivée des corps en blocs n'a pas de document stocké, et on en dérive un de son texte à la lecture — aucun consommateur n'a donc à savoir sous quel régime se trouve une carte, et aucun n'a à analyser du Markdown pour afficher du contenu.

Types de bloc

paragraph, heading, bulleted, numbered, todo, quote, callout, code, divider, image, embed, bookmark, toggle.

En réponse, type est une chaîne ouverte. La liste ci-dessus est ce qu'on peut écrire, et l'API refuse tout le reste à l'écriture — mais un corps que vous lisez pourra un jour porter un type qui n'existait pas quand vous avez écrit votre rendu. Rendez un type inconnu en dégradé (son text, s'il en a un) plutôt que d'échouer, et renvoyez-le tel quel si vous réenregistrez le document.

Chaque bloc a un id stable (un UUIDv7, conservé d'une écriture à l'autre) et un indent compris entre 0 et 5. Puis, selon le type : text pour les blocs textuels, level pour un titre, checked pour une case à cocher, code et language pour du code, fileId et alt pour une image, url (plus embedUrl et provider) pour un embed, url plus un title et une description facultatifs pour un signet.

Le texte est fait de segments, pas de balisage

Le text d'un bloc textuel est une liste de segments (spans) : une chaîne et ses marques.

{
  "type": "paragraph",
  "text": [
    { "text": "See the " },
    { "text": "reference", "link": "https://cms.spunto.net/api/docs" },
    { "text": " for details. " },
    { "text": "Required.", "bold": true }
  ]
}

Les marques sont bold, italic, code, strike et link. Un lien est une URL absolue http, https ou mailto.

Un segment peut aussi être une mention d'une autre carte — { "text": "Bug de connexion", "mention": "<id de la carte>" }. Voir les mentions.

Il n'y a rien à assainir

Le format ne peut pas exprimer de HTML. Pas « le HTML est retiré » — il n'y a aucun endroit où le mettre. Un segment est une chaîne et un ensemble de marques booléennes, et la liste fermée des types de bloc est imposée à l'écriture.

C'est toute la raison d'être de cette forme. Un corps en Markdown ou en HTML aurait rendu chaque consommateur responsable d'assainir le même contenu, correctement, pour toujours.

Les images sont des identifiants

Un bloc image porte un fileId, jamais une URL.

Dans ce produit, les fichiers n'ont aucune adresse partageable — les octets sont servis par l'API depuis une route scopée, avec la même autorisation que tout le reste. Chaque consommateur compose l'URL qu'il sait servir. Voir fichiers et pièces jointes.

alt sert aussi de légende, et c'est lui qui atterrit dans la projection texte.

Les embeds sont une liste blanche

Un bloc embed montre une vidéo, une maquette ou un bac à sable de code dans la carte. On écrit l'adresse de la page — { "type": "embed", "url": "https://youtu.be/…" } — et l'API ne l'accepte que de YouTube, Vimeo, Loom, Figma, Google Docs, Google Sheets, Google Slides, CodeSandbox ou CodePen, en https. Le reste est un 400 qui les liste — pour toute autre page, écrivez un signet.

Un document Google est montré par sa page /preview, jamais /edit : le cadre montre ce que le lecteur a le droit de voir chez Google. Un document partagé à « tous ceux qui ont le lien » s'affiche pour tout le monde ; un document restreint ne s'affiche qu'aux lecteurs connectés à un compte Google qui y a accès, et les autres voient la page « accès refusé » de Google. Rien du partage Google ne passe par Spunto. Un document publié sur le Web (/d/e/2PACX-…/pub) est montré par son lecteur de publication.

L'API ajoute ensuite deux champs qu'on n'écrit jamais : provider, et embedUrl, l'adresse du lecteur du fournisseur. C'est elle qui va dans une <iframe> ; l'API la calcule à chaque écriture, donc aucun consommateur ne recompose l'adresse d'un lecteur, et ce qu'un client envoie pour ces deux champs est ignoré. Pour vérifier un lien avant de l'écrire, GET /workspaces/{workspaceId}/embeds/resolve?url=… répond avec la même règle. La projection texte d'un embed est son url.

Le document ne porte toujours aucun balisage : un embed est une adresse prise dans une liste fermée, pas une iframe.

Les signets : toute autre page

Un bloc bookmark est une carte de lien — titre, description, domaine — pour une page qui n'est pas dans la liste des embeds : { "type": "bookmark", "url": "https://…", "title": "…" }. Rien n'est chargé dans la carte ; c'est un lien qui s'ouvre dans un nouvel onglet. title et description sont du texte brut, bornés à 300 et 1 000 caractères, et facultatifs (sans titre, on montre le domaine).

POST /workspaces/{workspaceId}/link-previews { "url" } dit ce que deviendrait une adresse collée : un embed (calculé sans rien charger) ou un signet, avec le titre et la description de la page quand l'API a pu les lire. La lecture d'une page par le serveur est gardée : adresses publiques seulement, vérifiées après la résolution DNS et de nouveau à chaque redirection (donc ni localhost, ni réseau privé, ni serveur de métadonnées du cloud), ports 80 et 443 seulement, 4 secondes et 512 Kio au plus, du HTML seulement. Une page illisible — ou qu'elle refuse de lire — donne quand même un signet, sans titre. Ça n'a lieu qu'une fois, au moment du coller : ouvrir une carte ne fait jamais rien charger au serveur.

La projection texte d'un signet est son titre et son adresse.

Les dépliables

Un bloc toggle est une ligne qu'on clique pour montrer ce qu'il y a dessous. Son propre text est cette ligne ; son contenu, ce sont les blocs qui le suivent avec un indent plus grand — la même règle que celle qui imbrique un élément de liste, donc pas de champ children.

Ouvert ou fermé n'est pas dans le document : c'est un état de lecture, propre à chacun, et tout dépliable s'ouvre fermé. Le stocker ferait de chaque clic une écriture. Le contenu d'un dépliable fermé reste dans blocks et dans la projection texte — il est caché, pas retiré.

Les mentions : citer une carte par son id

Dans l'éditeur, @ ou [[ ouvre une recherche parmi les cartes de l'espace ; en choisir une écrit un segment qui porte l'id de la carte :

{ "text": "Bug de connexion", "mention": "0190a1b2-…" }

L'id est vérifié à l'écriture — une carte de cet espace, ou un 400. Ce qui couvre aussi une carte d'un autre espace : d'ici, elle est simplement introuvable. Une mention déjà présente continue de s'enregistrer après la suppression de sa carte.

text est un instantané du titre au moment de l'écriture. C'est ce qui atterrit dans la projection texte et ce qu'affiche un client qui ignore les mentions — mais pas ce qu'il faut rendre. ItemDetail.mentions liste chaque carte citée avec sa clé (SPR-12), son title, son icon et sa homeCollectionId actuels : la mention suit un renommage sans que la carte qui cite soit réécrite. Une carte absente de cette liste a été supprimée : rendez-la de façon neutre, pas avec l'instantané. En ?version=published, seules les cartes publiées sont résolues, avec leur titre publié — un site ne lit jamais le titre d'un brouillon par une mention.

Les corps ne sont pas dans les résultats de liste

Item — ce que renvoie une page de vue — n'a pas de corps.

Seul ItemDetail porte body et blocks, et seules les routes qui renvoient une carte unique l'utilisent. Une vue de 50 lignes ne va pas chercher 50 documents.

Ni body ni blocks ne peuvent servir au tri ou au filtre. Il n'existe aucun index de recherche sur les corps, donc rien d'honnête à proposer : la recherche de l'espace de travail porte sur les titres des cartes et les noms des listes, et le dit.

Sur cette page