> ## Documentation Index
> Fetch the complete documentation index at: https://docs.anny.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Rich Content Media

> How images and videos are embedded in TipTap HTML fields, and how native apps should render them.

Resource descriptions, service descriptions, and legal document content are stored as **HTML strings** produced by the TipTap editor. Native apps must not treat these fields as plain text or as a generic WebView dump — images and videos use custom nodes with `data-*` attributes.

This guide is the contract for rendering that HTML in iOS, Android, and other non-web clients.

***

## Where this HTML appears

| JSON:API type     | Attribute     | i18n attribute     | Typical use                                       |
| ----------------- | ------------- | ------------------ | ------------------------------------------------- |
| `resources`       | `description` | `description_i18n` | Resource detail                                   |
| `services`        | `description` | `description_i18n` | Service / booking-option detail                   |
| `legal-documents` | `content`     | `content_i18n`     | Checkout, check-in, public legal page, signed PDF |

Locale selection follows `Accept-Language`. The flat attribute is the resolved translation; `*_i18n` is the full map (admin only).

<Note>
  The same HTML shape is used in all three places. Implement one renderer and reuse it.
</Note>

***

## Storage format

Content is **HTML**, not TipTap JSON. Custom nodes serialize to semantic tags plus `data-*` attributes so non-JS clients still have a fallback.

Allowed tags after server sanitization include `h1`–`h3`, `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `ul`, `ol`, `li`, `table`, `thead`/`tbody`/`tr`/`th`/`td`, `a`, `img`, `div`, and `iframe` (video hosts only).

`script`, `style`, and `javascript:` URLs are stripped on save.

***

## Images

Images persist as a normal `<img>` with extra metadata:

```html theme={null}
<img
  class="tiptap-image"
  src="https://cdn.example.com/storage/images/description-images/original/abc.jpg"
  alt="Meeting room"
  loading="lazy"
  data-image-id="123"
  data-width="50%"
  data-align="center"
/>
```

| Attribute       | Required                | Meaning                                                      |
| --------------- | ----------------------- | ------------------------------------------------------------ |
| `src`           | Yes                     | Absolute or root-relative URL of the uploaded image          |
| `alt`           | No                      | Accessible label                                             |
| `data-image-id` | Yes for uploaded images | Internal `images` resource id                                |
| `data-width`    | No                      | Display width: `25%`, `50%`, `75%`, `100%`, or a pixel value |
| `data-align`    | No                      | `left`, `center`, or `right`                                 |

### Native rendering

1. Render `src` with an image view. If `src` is root-relative (`/storage/...`), prefix the API origin.
2. Honor `data-width`: treat percentages as a fraction of the content column, not the device width if you have padded margins.
3. Apply `data-align` as horizontal alignment of the image block.
4. Use `alt` for VoiceOver / TalkBack.
5. Do **not** require `data-image-id` to display the image. It is for lifecycle tracking on the backend.

<Tip>
  Prefer the `src` already present in the HTML. You do not need a second request to `/api/v1/images/{id}` just to show the picture.
</Tip>

***

## Videos

Videos are **not** stored as `<video>` or as a live iframe. The editor persists a fallback link:

```html theme={null}
<a
  class="video-embed"
  data-video-embed="true"
  data-platform="youtube"
  data-video-id="dQw4w9WgXcQ"
  data-src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  data-embed-url="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
  data-thumbnail-url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
  data-width="640"
  data-height="360"
  data-aspect-ratio="16/9"
  title="Safety briefing"
  href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  target="_blank"
  rel="noopener noreferrer"
>Link to video</a>
```

Older or sanitized content may instead look like:

```html theme={null}
<div class="video-embed" data-video-embed="true">
  <iframe
    src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
    width="640"
    height="360"
    allowfullscreen
    sandbox="allow-same-origin allow-scripts allow-presentation"
    referrerpolicy="no-referrer"
  ></iframe>
</div>
```

Detect a video node if **any** of these are true:

* `a[data-video-embed]` or `*[data-video-embed]`
* `a.video-embed` / `div.video-embed`
* `iframe` whose `src` host is an allowed provider (see below)

### Attributes

| Attribute            | Meaning                                                                |
| -------------------- | ---------------------------------------------------------------------- |
| `data-platform`      | `youtube`, `vimeo`, or `loom`                                          |
| `data-video-id`      | Provider video id                                                      |
| `data-src` / `href`  | Canonical watch URL (open this in the system browser or in-app player) |
| `data-embed-url`     | Iframe embed URL, already parameterized                                |
| `data-thumbnail-url` | Poster image, when the editor could resolve one                        |
| `data-aspect-ratio`  | Usually `16/9`                                                         |
| `title`              | Video title, when fetched                                              |

### Supported providers

| Platform | Watch URL examples                                         | Embed host                                    |
| -------- | ---------------------------------------------------------- | --------------------------------------------- |
| YouTube  | `youtube.com/watch?v=`, `youtu.be/`, `/shorts/`, `/embed/` | `www.youtube-nocookie.com`, `www.youtube.com` |
| Vimeo    | `vimeo.com/{id}`, `player.vimeo.com/video/{id}`            | `player.vimeo.com`                            |
| Loom     | `loom.com/share/{id}`, `loom.com/embed/{id}`               | `www.loom.com`                                |

Only **HTTPS** embed hosts from that list are accepted by the API. Reject any other iframe host.

YouTube thumbnail fallback when `data-thumbnail-url` is missing:

```text theme={null}
https://i.ytimg.com/vi/{videoId}/hqdefault.jpg
```

Vimeo and Loom thumbnails are only available when `data-thumbnail-url` is present (resolved via oEmbed at edit time).

***

## How to render videos in a native app

Match the web TipTap renderer: **thumbnail + play button first**, load the player only after a tap.

<Steps>
  <Step title="Parse the node">
    Read `data-src` / `href` as the watch URL, `data-embed-url` as the player URL, and `data-thumbnail-url` as the poster. If you only have an iframe, derive the watch URL from the embed `src`.
  </Step>

  <Step title="Draw the poster">
    Show the thumbnail as a 16:9 cover image. If no thumbnail is available, use a neutral grey background (`#6B7280`). Center a circular play control (dark translucent disc, white triangle) on top.
  </Step>

  <Step title="Handle the tap">
    Preferred: replace the poster with an in-app player / `WKWebView` / `WebView` loaded with `data-embed-url` (add `autoplay=1` after the user gesture). Fallback: open the watch URL in the system browser or YouTube / Vimeo / Loom app.
  </Step>

  <Step title="Always keep the plain URL">
    Under the poster, render the watch URL as a tappable text link. Signed PDFs do the same. This keeps the video reachable when embeds are blocked, offline, or inaccessible.
  </Step>
</Steps>

### Layout

* Default aspect ratio: **16:9** (`data-aspect-ratio` wins when present).
* Width: fill the content column; do not exceed it.
* The play control should stay visually centered on the poster.
* Use `title` (or “YouTube / Vimeo / Loom video”) as the accessibility label of the play control.

### Do not

* Autoplay or preload the iframe on bind. That wastes bandwidth and breaks data-saving modes.
* Execute arbitrary iframe hosts. Stick to the allow-list above.
* Treat the anchor text (`Link to video`) as the only UI. Replace the node with the poster + URL treatment.

***

## Files and entity links

Rich content may also include:

```html theme={null}
<a class="tiptap-file" data-type="file" data-file-id="{uuid}" href="https://.../files/{uuid}">filename.pdf</a>
```

```html theme={null}
<a class="entity-link" data-entity-model="resources" data-entity-id="meeting-room" href="/resources/meeting-room">Meeting room</a>
```

Render files as a download / preview row. Render entity links as in-app navigation to the referenced resource, service, plan, or bundle using `data-entity-model` + `data-entity-id` (slug).

***

## PDF rendering

Signed legal-document PDFs cannot play video. The backend transforms TipTap HTML before TCPDF:

* **Images** are embedded (resolved from `data-image-id` when the file is available).
* **Videos** become a static 16:9 poster (thumbnail + play button, or grey if no thumbnail) **and** the plain watch URL as a clickable link under it.

Native print / share flows should follow the same rule if they generate their own PDF.

***

## Minimal detection cheat sheet

```ts theme={null}
function isVideoNode(el: Element): boolean {
  return el.getAttribute('data-video-embed') === 'true'
    || el.classList.contains('video-embed')
    || (el.tagName === 'IFRAME' && isAllowedVideoHost(el.getAttribute('src')))
}

function isImageNode(el: Element): boolean {
  return el.tagName === 'IMG' && !el.closest('[data-video-embed]')
}
```

Parse with a real HTML parser (`NSAttributedString` + custom HTML, Android `Html` is not enough). Walk the DOM, swap image/video nodes for native views, and render the remaining safe tags as text.

***

## Related

* [JSON-API Conventions](/developers/guides/json-api-conventions) — locale headers and sparse fieldsets
* [Authentication](/developers/guides/authentication) — tokens for fetching resources and legal documents
