How Blog posts, Project stories and Service pages turn CMS documents, images, code and videos into pages and share cards.
| Reference | Value |
|---|---|
| Subject | Article body rendering, galleries, code, READMEs, OG cards; for contributors changing detail pages |
| Verified source | main at 9dc18495 (table blocks from #636); other linked f40e8704 files are unchanged there, checked 2026-11-07; image blocks, galleries, build-published CMS images and project share copy re-verified on main at 0461e045 (#673, #675, #677, #678, #691), checked 2026-11-10 |
| Support state | Media contract, table blocks, share cards and four Café Arona mirrors checked against main 9dc18495. Image publication, image layouts, gallery pairs and project share copy checked against main 0461e045; no production build receipt for an upload-only image from this pass. Production video playback has no fresh acceptance receipt from this documentation pass |
Rich text is stored in Directus as block-editor JSON (BlockEditorDoc in packages/shared). The exporter validates it and writes it into generated modules; Blog bodies live in their own blog-bodies.ts module. BlockRenderer.svelte maps each block type to a component in src/lib/components/cms/blocks/.
| Block | Renders as |
|---|---|
header, paragraph, nestedlist, quote, delimiter |
Heading, prose, nested list, quote, divider |
code |
Server-side Shiki highlighting ($lib/server/code-highlights); a mermaid fence renders as a diagram |
image |
CMS image (committed mirror or build-published file) with caption. An optional data.variants.light follows the theme, or with data.showBothThemes shows labelled dark and light captures side by side. Optional data.layout is row or beside (see Images) |
paragraph holding only a YouTube URL |
Click-to-play video player |
table |
Table.svelte: a semantic table, with a header row when withHeadings is set, inside a keyboard-focusable scroll region named from the first row |
The Blog body field (blog_posts_translations.body) enables these editor tools: header, nested list, code, image, paragraph, checklist, quote, underline and table. Table cells arrive as raw inline HTML (TableBlock, saved by @editorjs/table 2.4.5). sanitizeTableCell in packages/shared keeps only b, strong, i, em, u, code, br and a (href, title; http, https, mailto and tel only). Both the Svelte renderer and serializeBlocksToHtml use it. Cell text counts toward reading text but never creates TOC headings. Other text blocks still pass their inline marks through as saved. The checklist tool has no block schema or renderer, so a checklist fails export validation (see Decisions and limitations).
Body text uses article-body.css, one .reading-detail scale for Blog, Project and Service detail pages, legal copy and the home biography, with a 70ch measure and code and tables that scroll inside their own box. Sizes come from the @yesid/tokens reading roles and are on the Design system integration page.
Blog and Project pages use article-media.svelte.ts, MediaGallery.svelte and MediaLightbox.svelte. One article context connects inline openers and both gallery layouts.
| Contract | Behavior |
|---|---|
| Identity | Blog lists deduplicate images by file UUID or URL, preserving the first caption; Project galleries deduplicate by dark/light pair; videos by YouTube ID |
| Theme pairs | A Project gallery shows both captures of a pair, Dark then Light, whatever the active theme, each tagged in the page language (Dark/Light, Sombre/Clair, Oscuro/Claro). Blog lists keep one theme-selected item per file. During live export an unpaired example.dark.png gains its unique example.light.png sibling as variants.light; an authored light variant wins and two matches fail the build (project-images.ts, build-media.ts) |
| Layouts | layout: "row" on consecutive images renders one row of up to three columns; layout: "beside" puts the image next to the following paragraph, table or nested list (a third of the width for a portrait image) and otherwise renders normally. Both stack below 768px. Portrait captures, with stored height above width, are centred and capped at 400px or their natural width, whichever is smaller (image-layouts.ts, BlockRenderer.svelte, ImageBlock.svelte) |
| Image/video selection | Separate lists, clamped Previous/Next; arrow keys and touch gestures navigate the active list |
| Diagrams | Mermaid SVG opens enlarged in the same lightbox; diagram selection has no list navigation |
| Focus | Escape closes; close restores the connected opener without scrolling |
| Project layout | Inline images sit beside their text; on mobile the gallery stack follows the article, Images then Videos when present; desktop gallery occupies its rail |
| Context-free blocks | Image and diagram blocks retain their supported standalone behavior without an article controller |
| Production assets | A committed mirror wins for its file UUID. The live build publishes any other referenced raster image: the exporter fetches it from the pinned CMS origin with the Build Bot token, accepts PNG, JPEG, WebP, GIF or AVIF up to 20 MiB, 16,000 px per axis, 40 million pixels and one frame, and writes it with responsive WebP variants to /images/cms/<file-uuid>/<sha256>.<ext>. A new SVG still needs a reviewed mirror. A missing, unreadable or invalid image fails the production build. Visitors never request CMS media; CSP is unchanged. Fixture builds show a labelled placeholder for unmirrored images (build-media.ts, content-media.ts) |
| Café Arona mirrors | Four source images under static/images/work/cafe-arona-20261105/: Home desktop/mobile, Product desktop and Team desktop. PNG originals, responsive WebP variants, asset manifest and both UUID maps are committed. A 2026-11-09 case-study set (Café Arona, Transit, yesid.design, yesid.dev) is also mirrored, with variant reuse bound to fixtures/media-variants-receipt.json (#675) |
Browser Back anchoring and locale reading-position restoration have separate owners on Web application.
| Contract | Detail |
|---|---|
| Authoring | A paragraph that is only an HTTPS YouTube URL, or only one link to it, becomes a player. Accepted hosts: youtube.com and its www and m hosts, youtu.be, www.youtube-nocookie.com; watch, embed, shorts and live paths; 11-character IDs. Plain link text, with HTML entities decoded, becomes the caption |
| Playback | Thumbnail from i.ytimg.com; the youtube-nocookie.com iframe loads only after a click |
| Galleries | Blog and Project pages collect every video once by video ID, alongside images |
| CSP | Adds frame-src https://www.youtube-nocookie.com and img-src https://i.ytimg.com; previews also allow the Vercel toolbar frame |
| Storage | YouTube hosts the videos. There is no CMS video upload field |
Source: youtube.ts, YouTubeVideo.svelte.
A project can link a public GitHub README. At build time markdown.ts fetches it, renders it with marked, sanitizes it with sanitize-html and highlights fences on the server. A failed fetch omits the section and logs a diagnostic; it does not fail the build.
| Card | Produced by | Served |
|---|---|---|
| Home, locale defaults, route and service cards | apps/web/scripts/generate-og-cards.ts, committed under static/og/. The four English route cards (about, contact, projects, services) are CMS mirrors with digests in assets-manifest.json |
Static files |
| Blog and Project cards | /og/[type]/[slug].png: the shared src/lib/og/card.ts layout rasterized with resvg, palette and fonts from @yesid/seo-kit/og, prerendered for every published entry and locale |
Deployment-static PNGs; Project cards carry a localized PROJECT, PROJET or PROYECTO eyebrow and a subtitle from the localized CMS one-liner (English fallback), else leading complete overview sentences up to 200 characters, else the site default. The subtitle renders in full at 18px or larger; export fails a one-liner that cannot fit or uses unsupported characters (project-copy.ts, validate-project-lines.ts, glyph-coverage.ts) |
| Unknown Blog or Project card path | vercel.json rewrite |
/og/default.en.png |
Page titles and share-image alt descriptions use one localized brand-suffix policy (adapters/compose-page-seo.ts, route-seo-factories.ts). Every card has an eyebrow, a title closed by an orange dot, an optional support line, the wordmark and a call to action naming where the link lands. Yellow marks actions that reach Yesid (Home, Contact); orange marks the rest (og/share-copy.ts). Titles and support lines shrink to fit first; text that still overflows ends with an ellipsis, except the Project subtitle, which must fit in full. Project metadata descriptions try the one-liner with the localized call to action, then the one-liner alone, then leading complete overview sentences with the call to action when it fits. The result must be 50 to 200 characters, otherwise the site default is used. Sentence boundaries follow EN, FR and ES punctuation, keep titles, initials, decimals and e.g./i.e./p. ex. inside a sentence, and drop unfinished tails. The article cover image and the BlogPosting JSON-LD image have separate roles.