> ## 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.

***

## Use case: Safety briefing at check-in

Legal-document HTML is often a **safety briefing** collected only at check-in (`Later (at check-in)`). The guest must **start** every embedded video before confirm / check-in is enabled.

1. Parse `legal-documents.content` as in this guide (poster + play button, not a live iframe on bind).
2. On play tap, start the player (or open the watch URL) and mark that video as started.
3. Keep the confirm / sign / check-in action **disabled** until every `data-video-embed` node in the document has been started.
4. Starting the video is enough. Do not require the video to play to the end unless your product adds that later.

Signed PDFs still cannot play video: they get a static poster plus the clickable watch URL (see [PDF rendering](#pdf-rendering)).

***

## 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.

***

## 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.

**1. 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`.

**2. 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.

**3. 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. At check-in, treat this tap as "video started" and only then enable confirm.

**4. 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.

### 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.
* Enable accept, sign, or check-in while any embedded video is still unplayed. Checkout and check-in **block** confirmation until every video in the document has been **started** via the play control (watch-through is not required). Show the same copy as web: **Start the video before you can sign or accept**. See [Use case: Safety briefing at check-in](#use-case-safety-briefing-at-check-in).

***

## 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](https://docs.anny.co/developers/guides/json-api-conventions.md) — locale headers and sparse fieldsets
* [Authentication](https://docs.anny.co/developers/guides/authentication.md) — tokens for fetching resources and legal documents


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.